syncova-backup/packages/recovery/validation.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

552 lines
19 KiB
Go

// Package recovery stellt Daten aus einem Repository wieder her.
//
// Der Kern ist nicht das Zurueckschreiben — das leistet packages/agent bereits.
// Der Kern ist alles darum herum: die Pruefung **vor** dem Schreiben, die
// Sitzung mit Pruefpunkt und der Schutz gegen versehentliches Ueberschreiben.
//
// Das folgt dem Produktgrundsatz: Ein Backup gilt erst als vertrauenswuerdig,
// wenn Integritaet geprueft und Wiederherstellbarkeit nachgewiesen wurde. Ein
// Nachweis, der erst im Ernstfall erbracht wird, ist keiner.
package recovery
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/syncova/syncova/packages/repository"
)
// ValidationSeverity ist das Gewicht eines Befunds.
type ValidationSeverity string
const (
// SeverityBlocking verhindert die Wiederherstellung.
//
// Ein solcher Befund wird nicht durch eine Bestaetigung ueberstimmbar: Es
// gibt keinen Weg, aus fehlenden Bloecken Daten zu machen.
SeverityBlocking ValidationSeverity = "blocking"
// SeverityWarning verlangt Aufmerksamkeit, verhindert aber nichts.
SeverityWarning ValidationSeverity = "warning"
// SeverityInformation ist ein Hinweis.
SeverityInformation ValidationSeverity = "information"
)
// ValidationFinding ist ein einzelner Befund der Vorabpruefung.
type ValidationFinding struct {
// Code ist die maschinenlesbare Kennung in SCREAMING_SNAKE_CASE.
Code string `json:"code"`
// Severity ist das Gewicht.
Severity ValidationSeverity `json:"severity"`
// Message erklaert den Befund verstaendlich.
Message string `json:"message"`
// Detail nennt das betroffene Objekt, sofern eines benennbar ist.
Detail string `json:"detail,omitempty"`
}
// ValidationReport ist das Ergebnis der Vorabpruefung.
type ValidationReport struct {
// BackupID ist das gepruefte Backup.
BackupID string `json:"backup_id"`
// TargetPath ist das gepruefte Ziel.
TargetPath string `json:"target_path"`
// Findings sind die Befunde.
Findings []ValidationFinding `json:"findings"`
// EntryCount ist die Zahl wiederherzustellender Objekte.
EntryCount int `json:"entry_count"`
// FileCount ist die Zahl wiederherzustellender Dateien.
FileCount int `json:"file_count"`
// TotalBytes ist die zurueckzuschreibende Datenmenge.
TotalBytes int64 `json:"total_bytes"`
// UniqueChunkCount ist die Zahl benoetigter Bloecke.
UniqueChunkCount int `json:"unique_chunk_count"`
// MissingChunkCount ist die Zahl fehlender Bloecke.
MissingChunkCount int `json:"missing_chunk_count"`
// AvailableTargetBytes ist der freie Platz am Ziel; -1 bedeutet unbekannt.
AvailableTargetBytes int64 `json:"available_target_bytes"`
// CheckedAt ist der Zeitpunkt der Pruefung in UTC.
CheckedAt time.Time `json:"checked_at"`
// DurationSeconds ist die Dauer der Pruefung.
DurationSeconds float64 `json:"duration_seconds"`
}
// CanProceed meldet, ob die Wiederherstellung beginnen darf.
func (report *ValidationReport) CanProceed() bool {
for _, finding := range report.Findings {
if finding.Severity == SeverityBlocking {
return false
}
}
return true
}
// BlockingFindings liefert die verhindernden Befunde.
func (report *ValidationReport) BlockingFindings() []ValidationFinding {
blocking := make([]ValidationFinding, 0)
for _, finding := range report.Findings {
if finding.Severity == SeverityBlocking {
blocking = append(blocking, finding)
}
}
return blocking
}
// Summary fasst das Ergebnis in einem Satz zusammen.
func (report *ValidationReport) Summary() string {
if !report.CanProceed() {
return fmt.Sprintf("NICHT WIEDERHERSTELLBAR: %d Hindernisse, %d fehlende Bloecke.",
len(report.BlockingFindings()), report.MissingChunkCount)
}
warningCount := 0
for _, finding := range report.Findings {
if finding.Severity == SeverityWarning {
warningCount++
}
}
if warningCount > 0 {
return fmt.Sprintf("Wiederherstellbar mit %d Hinweisen: %d Dateien, %s.",
warningCount, report.FileCount, formatByteCount(report.TotalBytes))
}
return fmt.Sprintf("Wiederherstellbar: %d Dateien, %s.", report.FileCount, formatByteCount(report.TotalBytes))
}
// ValidationRequest beschreibt eine zu pruefende Wiederherstellung.
type ValidationRequest struct {
// BackupID ist das wiederherzustellende Backup.
BackupID string
// TargetPath ist das Zielverzeichnis.
TargetPath string
// PathPrefix beschraenkt auf einen Teilbaum; leer bedeutet alles.
PathPrefix string
// OverwriteExisting erlaubt das Ueberschreiben vorhandener Daten.
OverwriteExisting bool
// DeepChunkCheck prueft jeden Block einzeln auf Vorhandensein.
//
// Das ist der eigentliche Nachweis der Wiederherstellbarkeit und kostet bei
// grossen Backups Zeit — deshalb abschaltbar. Abgeschaltet prueft die
// Vorabpruefung nur das Manifest; sie kann dann nicht mehr sagen, ob die
// Daten wirklich da sind.
DeepChunkCheck bool
}
// ErrBackupUnreadable meldet ein nicht lesbares Backup.
var ErrBackupUnreadable = errors.New("das backup konnte nicht gelesen werden")
// Validator prueft eine Wiederherstellung, ohne etwas zu schreiben.
type Validator struct {
// sourceRepository ist das Repository mit dem Backup.
sourceRepository *repository.LocalRepository
}
// NewValidator erzeugt die Vorabpruefung.
func NewValidator(sourceRepository *repository.LocalRepository) *Validator {
return &Validator{sourceRepository: sourceRepository}
}
// Validate prueft, ob eine Wiederherstellung gelingen kann.
//
// Es wird **nichts geschrieben**. Die Pruefung ist damit gefahrlos und kann
// jederzeit laufen — auch als regelmaessiger Nachweis, dass die Backups
// weiterhin wiederherstellbar sind, lange bevor jemand sie braucht.
func (validator *Validator) Validate(validationContext context.Context, validationRequest ValidationRequest) (*ValidationReport, error) {
startTime := time.Now()
report := &ValidationReport{
BackupID: validationRequest.BackupID,
TargetPath: validationRequest.TargetPath,
Findings: make([]ValidationFinding, 0, 4),
AvailableTargetBytes: -1,
CheckedAt: time.Now().UTC(),
}
backupManifest, readError := validator.sourceRepository.ReadManifest(validationContext, validationRequest.BackupID)
if readError != nil {
// Ohne Manifest gibt es nichts zu pruefen und nichts wiederherzustellen.
report.Findings = append(report.Findings, ValidationFinding{
Code: "MANIFEST_UNREADABLE",
Severity: SeverityBlocking,
Message: "Das Manifest des Backups ist nicht lesbar. Ohne es laesst sich nicht feststellen, was gesichert wurde.",
Detail: readError.Error(),
})
report.DurationSeconds = time.Since(startTime).Seconds()
return report, nil
}
validator.checkManifestCompleteness(backupManifest, report)
validator.checkEncryptionKey(backupManifest, report)
selectedEntries := selectEntries(backupManifest, validationRequest.PathPrefix)
if len(selectedEntries) == 0 {
report.Findings = append(report.Findings, ValidationFinding{
Code: "NO_MATCHING_ENTRIES",
Severity: SeverityBlocking,
Message: "Das Backup enthaelt unter diesem Pfad keine Objekte.",
Detail: validationRequest.PathPrefix,
})
}
validator.summarizeEntries(selectedEntries, report)
if validationRequest.DeepChunkCheck {
validator.checkChunkAvailability(validationContext, selectedEntries, report)
} else {
report.Findings = append(report.Findings, ValidationFinding{
Code: "CHUNK_CHECK_SKIPPED",
Severity: SeverityInformation,
Message: "Die Bloecke wurden nicht einzeln geprueft. Ob die Daten tatsaechlich " +
"vorhanden sind, ist damit nicht festgestellt.",
})
}
validator.checkTarget(validationRequest, report)
report.DurationSeconds = time.Since(startTime).Seconds()
return report, nil
}
// checkManifestCompleteness prueft den Abschlussvermerk des Manifests.
func (validator *Validator) checkManifestCompleteness(backupManifest *repository.Manifest, report *ValidationReport) {
// Ein Backup ohne Abschlussvermerk ist unvollstaendig, unabhaengig davon,
// was in der Datenbank steht (SYNCOVA_ARCHITECTURE.md §10).
if !backupManifest.Complete {
report.Findings = append(report.Findings, ValidationFinding{
Code: "BACKUP_INCOMPLETE",
Severity: SeverityBlocking,
Message: "Das Backup traegt keinen Abschlussvermerk. Es wurde begonnen, aber nie " +
"vollstaendig festgeschrieben.",
})
}
}
// checkEncryptionKey prueft, ob der noetige Schluessel verfuegbar ist.
func (validator *Validator) checkEncryptionKey(backupManifest *repository.Manifest, report *ValidationReport) {
if backupManifest.EncryptionKeyVersion == "" {
report.Findings = append(report.Findings, ValidationFinding{
Code: "BACKUP_UNENCRYPTED",
Severity: SeverityWarning,
Message: "Dieses Backup ist unverschluesselt abgelegt.",
})
return
}
// Der Datenschluessel gehoert zum Repository. Fehlt er, liegen die Daten da
// und sind trotzdem unlesbar — der unangenehmste denkbare Fall, weil alles
// vollstaendig aussieht.
storedVersion, versionError := validator.sourceRepository.DataKeyVersion()
if versionError != nil {
report.Findings = append(report.Findings, ValidationFinding{
Code: "DATA_KEY_MISSING",
Severity: SeverityBlocking,
Message: "Der Datenschluessel des Repositorys ist nicht auffindbar. Die Bloecke " +
"liegen vor, lassen sich aber nicht entschluesseln.",
Detail: versionError.Error(),
})
return
}
if storedVersion != backupManifest.EncryptionKeyVersion {
report.Findings = append(report.Findings, ValidationFinding{
Code: "KEY_VERSION_MISMATCH",
Severity: SeverityWarning,
Message: fmt.Sprintf("Das Backup wurde mit Schluesselversion %s erzeugt, das Repository "+
"fuehrt %s. Der aeltere Schluessel muss weiterhin verfuegbar sein.",
backupManifest.EncryptionKeyVersion, storedVersion),
})
}
}
// summarizeEntries zaehlt die wiederherzustellenden Objekte.
func (validator *Validator) summarizeEntries(selectedEntries []repository.ManifestEntry, report *ValidationReport) {
report.EntryCount = len(selectedEntries)
for _, manifestEntry := range selectedEntries {
if manifestEntry.EntryType == "file" {
report.FileCount++
report.TotalBytes += manifestEntry.SizeBytes
}
}
}
// checkChunkAvailability prueft, ob jeder benoetigte Block vorhanden ist.
//
// Das ist der eigentliche Nachweis der Wiederherstellbarkeit. Ein Manifest
// allein belegt nur, dass jemand einmal etwas gesichert hat — nicht, dass die
// Daten noch da sind. Ein versehentlich aufgeraeumtes Verzeichnis, ein
// unvollstaendig kopiertes Repository, ein fehlgeschlagenes Prune: Alles das
// faellt hier auf und nicht erst im Ernstfall.
func (validator *Validator) checkChunkAvailability(checkContext context.Context, selectedEntries []repository.ManifestEntry, report *ValidationReport) {
requiredChunks := make(map[string]string)
for _, manifestEntry := range selectedEntries {
for _, chunkReference := range manifestEntry.Chunks {
if _, alreadySeen := requiredChunks[chunkReference.Identifier]; alreadySeen {
continue
}
requiredChunks[chunkReference.Identifier] = manifestEntry.Path
}
}
report.UniqueChunkCount = len(requiredChunks)
// Die betroffenen Objekte werden gesammelt, aber nur die ersten genannt:
// Bei einem verlorenen Verzeichnis waeren es sonst tausende Zeilen.
const maximumNamedObjects = 5
affectedPaths := make([]string, 0, maximumNamedObjects)
for chunkIdentifier, owningPath := range requiredChunks {
if checkContext.Err() != nil {
report.Findings = append(report.Findings, ValidationFinding{
Code: "CHECK_CANCELLED",
Severity: SeverityBlocking,
Message: "Die Pruefung wurde abgebrochen und ist damit ohne Aussage.",
})
return
}
chunkExists, existenceError := validator.sourceRepository.HasChunk(checkContext, chunkIdentifier)
if existenceError != nil {
report.Findings = append(report.Findings, ValidationFinding{
Code: "CHUNK_CHECK_FAILED",
Severity: SeverityBlocking,
Message: "Die Bloecke des Backups liessen sich nicht pruefen.",
Detail: existenceError.Error(),
})
return
}
if !chunkExists {
report.MissingChunkCount++
if len(affectedPaths) < maximumNamedObjects {
affectedPaths = append(affectedPaths, owningPath)
}
}
}
if report.MissingChunkCount > 0 {
detailText := strings.Join(affectedPaths, ", ")
if report.MissingChunkCount > len(affectedPaths) {
detailText += fmt.Sprintf(" und %d weitere", report.MissingChunkCount-len(affectedPaths))
}
report.Findings = append(report.Findings, ValidationFinding{
Code: "CHUNKS_MISSING",
Severity: SeverityBlocking,
Message: fmt.Sprintf("%d von %d benoetigten Bloecken fehlen im Repository. "+
"Die betroffenen Dateien lassen sich nicht wiederherstellen.",
report.MissingChunkCount, report.UniqueChunkCount),
Detail: detailText,
})
}
}
// checkTarget prueft das Zielverzeichnis.
func (validator *Validator) checkTarget(validationRequest ValidationRequest, report *ValidationReport) {
if strings.TrimSpace(validationRequest.TargetPath) == "" {
report.Findings = append(report.Findings, ValidationFinding{
Code: "TARGET_MISSING",
Severity: SeverityBlocking,
Message: "Es wurde kein Zielverzeichnis angegeben.",
})
return
}
targetInformation, statError := os.Stat(validationRequest.TargetPath)
switch {
case statError != nil && os.IsNotExist(statError):
// Ein nicht vorhandenes Ziel ist kein Hindernis — es wird angelegt.
// Sein Elternverzeichnis muss aber beschreibbar sein.
validator.checkParentWritable(validationRequest.TargetPath, report)
case statError != nil:
report.Findings = append(report.Findings, ValidationFinding{
Code: "TARGET_UNREACHABLE",
Severity: SeverityBlocking,
Message: "Auf das Zielverzeichnis kann nicht zugegriffen werden.",
Detail: statError.Error(),
})
return
case !targetInformation.IsDir():
report.Findings = append(report.Findings, ValidationFinding{
Code: "TARGET_NOT_DIRECTORY",
Severity: SeverityBlocking,
Message: "Das Ziel ist kein Verzeichnis.",
})
return
default:
validator.checkExistingTarget(validationRequest, report)
}
validator.checkFreeSpace(validationRequest.TargetPath, report)
}
// checkParentWritable prueft das Elternverzeichnis eines neuen Ziels.
func (validator *Validator) checkParentWritable(targetPath string, report *ValidationReport) {
parentDirectory := filepath.Dir(targetPath)
parentInformation, statError := os.Stat(parentDirectory)
if statError != nil {
report.Findings = append(report.Findings, ValidationFinding{
Code: "TARGET_PARENT_MISSING",
Severity: SeverityBlocking,
Message: "Das uebergeordnete Verzeichnis des Ziels existiert nicht.",
Detail: parentDirectory,
})
return
}
if !parentInformation.IsDir() {
report.Findings = append(report.Findings, ValidationFinding{
Code: "TARGET_PARENT_NOT_DIRECTORY",
Severity: SeverityBlocking,
Message: "Das uebergeordnete Verzeichnis des Ziels ist kein Verzeichnis.",
Detail: parentDirectory,
})
}
}
// checkExistingTarget prueft ein bereits vorhandenes Zielverzeichnis.
func (validator *Validator) checkExistingTarget(validationRequest ValidationRequest, report *ValidationReport) {
directoryEntries, readError := os.ReadDir(validationRequest.TargetPath)
if readError != nil {
report.Findings = append(report.Findings, ValidationFinding{
Code: "TARGET_UNREADABLE",
Severity: SeverityBlocking,
Message: "Das Zielverzeichnis laesst sich nicht lesen.",
Detail: readError.Error(),
})
return
}
if len(directoryEntries) == 0 {
return
}
// Ein nicht leeres Ziel ist der gefaehrliche Fall: Ohne ausdrueckliche
// Zustimmung wird nicht ueberschrieben, denn die vorhandenen Daten koennten
// genau die sein, die man eigentlich retten will.
if !validationRequest.OverwriteExisting {
report.Findings = append(report.Findings, ValidationFinding{
Code: "TARGET_NOT_EMPTY",
Severity: SeverityBlocking,
Message: fmt.Sprintf("Das Zielverzeichnis enthaelt bereits %d Objekte. Ohne ausdrueckliche "+
"Zustimmung zum Ueberschreiben wird nicht wiederhergestellt.", len(directoryEntries)),
})
return
}
report.Findings = append(report.Findings, ValidationFinding{
Code: "TARGET_WILL_BE_OVERWRITTEN",
Severity: SeverityWarning,
Message: fmt.Sprintf("Das Zielverzeichnis enthaelt %d Objekte, die ueberschrieben werden. "+
"Diese Daten sind danach verloren.", len(directoryEntries)),
})
}
// safetyMarginFactor haelt Platz fuer Dateisystem-Verwaltungsdaten frei.
//
// Ein Dateisystem braucht mehr als die Nutzdaten: Verzeichniseintraege,
// Inodes, Blockverschnitt. Fuenf Prozent sind ein grober, aber brauchbarer
// Aufschlag — genauer liesse es sich nur je Dateisystem bestimmen.
const safetyMarginFactor = 1.05
// checkFreeSpace prueft den freien Platz am Ziel.
func (validator *Validator) checkFreeSpace(targetPath string, report *ValidationReport) {
availableBytes, spaceError := determineAvailableBytes(targetPath)
if spaceError != nil {
// Der freie Platz ist eine Zusatzinformation. Ihn nicht zu kennen darf
// eine Wiederherstellung nicht verhindern.
report.Findings = append(report.Findings, ValidationFinding{
Code: "FREE_SPACE_UNKNOWN",
Severity: SeverityInformation,
Message: "Der freie Platz am Ziel liess sich nicht ermitteln.",
})
return
}
report.AvailableTargetBytes = availableBytes
requiredBytes := int64(float64(report.TotalBytes) * safetyMarginFactor)
if availableBytes < requiredBytes {
report.Findings = append(report.Findings, ValidationFinding{
Code: "INSUFFICIENT_SPACE",
Severity: SeverityBlocking,
Message: fmt.Sprintf("Am Ziel sind %s frei, benoetigt werden etwa %s. Die "+
"Wiederherstellung wuerde unterwegs abbrechen.",
formatByteCount(availableBytes), formatByteCount(requiredBytes)),
})
}
}
// selectEntries waehlt die Objekte eines Teilbaums.
func selectEntries(backupManifest *repository.Manifest, pathPrefix string) []repository.ManifestEntry {
trimmedPrefix := strings.Trim(strings.TrimSpace(pathPrefix), "/")
if trimmedPrefix == "" {
return backupManifest.Entries
}
selected := make([]repository.ManifestEntry, 0, len(backupManifest.Entries))
for _, manifestEntry := range backupManifest.Entries {
// Der Vergleich beruecksichtigt die Verzeichnisgrenze: "dokumente" darf
// nicht auch "dokumentation" treffen.
if manifestEntry.Path == trimmedPrefix || strings.HasPrefix(manifestEntry.Path, trimmedPrefix+"/") {
selected = append(selected, manifestEntry)
}
}
return selected
}
// byteUnitSuffixes sind die Einheiten der Groessenausgabe.
var byteUnitSuffixes = []string{"B", "KiB", "MiB", "GiB", "TiB", "PiB"}
// formatByteCount gibt eine Bytezahl lesbar aus.
func formatByteCount(byteCount int64) string {
if byteCount < 1024 {
return fmt.Sprintf("%d B", byteCount)
}
scaledValue := float64(byteCount)
unitIndex := 0
for scaledValue >= 1024 && unitIndex < len(byteUnitSuffixes)-1 {
scaledValue /= 1024
unitIndex++
}
return fmt.Sprintf("%.1f %s", scaledValue, byteUnitSuffixes[unitIndex])
}