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

487 lines
16 KiB
Go

// Package verification prueft Backups und bewertet ihre Wiederherstellbarkeit.
//
// Der Zweck ist eine einzige ehrliche Aussage je Backup: Laesst es sich
// wiederherstellen? Dafuer gibt es genau drei Erkenntnisquellen, in
// aufsteigender Aussagekraft:
//
// 1. **Das Manifest** sagt, was gesichert werden sollte.
// 2. **Die Blockpruefung** sagt, ob die Daten noch da und unbeschaedigt sind.
// 3. **Der Wiederherstellungstest** sagt, dass es tatsaechlich geht.
//
// Nur die dritte ist ein Nachweis. Die ersten beiden sind Indizien — gute, aber
// eben Indizien. Diese Unterscheidung traegt das ganze Paket, und sie ist der
// Grund, warum ein Backup „erfolgreich" und trotzdem nicht „wiederherstellbar"
// sein kann.
package verification
import (
"context"
"errors"
"fmt"
"time"
"github.com/syncova/syncova/packages/repository"
)
// VerificationType benennt die Art einer Pruefung.
type VerificationType string
const (
// TypeManifest prueft nur das Manifest.
//
// Schnell und schwach: Sie stellt fest, dass das Backup abgeschlossen wurde
// und in sich stimmig ist — nicht, ob die Daten noch da sind.
TypeManifest VerificationType = "manifest"
// TypeChunkPresence prueft, ob jeder benoetigte Block vorhanden ist.
//
// Ohne die Bloecke zu lesen: Ein Verzeichniseintrag genuegt. Damit faellt
// ein aufgeraeumtes oder unvollstaendig kopiertes Repository auf.
TypeChunkPresence VerificationType = "chunk_presence"
// TypeChunkIntegrity liest jeden Block und prueft seine Pruefsumme.
//
// Die teuerste Pruefung ohne Wiederherstellung — und die einzige, die
// stille Datenverfaelschung auf dem Datentraeger aufdeckt.
TypeChunkIntegrity VerificationType = "chunk_integrity"
// TypeChain prueft die gesamte Kette bis zur Vollsicherung.
TypeChain VerificationType = "chain"
// TypeRestoreTest stellt das Backup tatsaechlich wieder her.
TypeRestoreTest VerificationType = "restore_test"
)
// Severity ist das Gewicht eines Befunds.
type Severity string
const (
// SeverityCorruption meldet beschaedigte Daten.
SeverityCorruption Severity = "corruption"
// SeverityMissing meldet fehlende Daten.
SeverityMissing Severity = "missing"
// SeverityWarning verlangt Aufmerksamkeit.
SeverityWarning Severity = "warning"
// SeverityInformation ist ein Hinweis.
SeverityInformation Severity = "information"
)
// Finding ist ein einzelner Befund einer Pruefung.
type Finding struct {
// Code ist die maschinenlesbare Kennung.
Code string `json:"code"`
// Severity ist das Gewicht.
Severity Severity `json:"severity"`
// Message erklaert den Befund verstaendlich.
Message string `json:"message"`
// Detail nennt das betroffene Objekt.
Detail string `json:"detail,omitempty"`
}
// Report ist das Ergebnis einer Pruefung.
type Report struct {
// BackupID ist das gepruefte Backup im Repository.
BackupID string `json:"backup_id"`
// VerificationType ist die durchgefuehrte Art.
VerificationType VerificationType `json:"verification_type"`
// Findings sind die Befunde.
Findings []Finding `json:"findings"`
// ChunksChecked ist die Zahl gepruefter Bloecke.
ChunksChecked int `json:"chunks_checked"`
// ChunksMissing ist die Zahl fehlender Bloecke.
ChunksMissing int `json:"chunks_missing"`
// ChunksCorrupted ist die Zahl beschaedigter Bloecke.
ChunksCorrupted int `json:"chunks_corrupted"`
// BytesRead ist die gelesene Datenmenge.
BytesRead int64 `json:"bytes_read"`
// ChainLength ist die Zahl der Backups in der Kette.
ChainLength int `json:"chain_length,omitempty"`
// StartedAt ist der Beginn in UTC.
StartedAt time.Time `json:"started_at"`
// CompletedAt ist das Ende in UTC.
CompletedAt time.Time `json:"completed_at"`
// DurationSeconds ist die Dauer.
DurationSeconds float64 `json:"duration_seconds"`
}
// IsClean meldet eine Pruefung ohne Beanstandung.
func (report *Report) IsClean() bool {
for _, finding := range report.Findings {
if finding.Severity == SeverityCorruption || finding.Severity == SeverityMissing {
return false
}
}
return true
}
// HasCorruption meldet beschaedigte Daten.
//
// Der Unterschied zu fehlenden Daten ist wesentlich: Fehlende Bloecke koennen
// aus einem unvollstaendig kopierten Repository stammen und anderswo noch
// existieren. Beschaedigte Bloecke sind verloren — und ihr Vorhandensein
// deutet auf einen Datentraeger, dem nicht mehr zu trauen ist.
func (report *Report) HasCorruption() bool {
for _, finding := range report.Findings {
if finding.Severity == SeverityCorruption {
return true
}
}
return false
}
// Summary fasst das Ergebnis in einem Satz zusammen.
func (report *Report) Summary() string {
// Der Wiederherstellungstest zaehlt keine Bloecke, sondern Dateien. Ohne
// diesen Zweig meldete er „0 von 0 Bloecken sind beschaedigt" — eine
// Meldung, die mehr verwirrt als sie sagt.
if report.VerificationType == TypeRestoreTest {
if !report.IsClean() {
return "FEHLGESCHLAGEN: Das Backup liess sich nicht vollstaendig zurueckschreiben. " +
"Die Befunde nennen die betroffenen Objekte."
}
return "Das Backup wurde zurueckgeschrieben und gegen das Original verglichen."
}
if report.HasCorruption() {
return fmt.Sprintf("BESCHAEDIGT: %d von %d Bloecken sind beschaedigt.",
report.ChunksCorrupted, report.ChunksChecked)
}
if !report.IsClean() {
return fmt.Sprintf("UNVOLLSTAENDIG: %d von %d Bloecken fehlen.",
report.ChunksMissing, report.ChunksChecked)
}
switch report.VerificationType {
case TypeManifest:
return "Das Manifest ist stimmig. Ob die Daten vorhanden sind, wurde nicht geprueft."
case TypeChunkPresence:
return fmt.Sprintf("Alle %d Bloecke sind vorhanden. Ihr Inhalt wurde nicht gelesen.",
report.ChunksChecked)
case TypeChunkIntegrity:
return fmt.Sprintf("Alle %d Bloecke sind vorhanden und unbeschaedigt.", report.ChunksChecked)
case TypeChain:
return fmt.Sprintf("Die Kette aus %d Backups ist vollstaendig.", report.ChainLength)
default:
return "Die Pruefung verlief ohne Beanstandung."
}
}
// Verifier prueft Backups eines Repositorys.
type Verifier struct {
// sourceRepository ist das zu pruefende Repository.
sourceRepository *repository.LocalRepository
}
// NewVerifier erzeugt die Pruefmaschine.
func NewVerifier(sourceRepository *repository.LocalRepository) *Verifier {
return &Verifier{sourceRepository: sourceRepository}
}
// maximumNamedObjects begrenzt die im Befund genannten Objekte.
//
// Bei einem verlorenen Verzeichnis waeren es sonst tausende Zeilen. Die Zahl
// im Bericht bleibt vollstaendig.
const maximumNamedObjects = 5
// VerifyBackup prueft ein einzelnes Backup.
func (verifier *Verifier) VerifyBackup(verifyContext context.Context, backupIdentifier string, verificationType VerificationType) (*Report, error) {
startTime := time.Now()
report := &Report{
BackupID: backupIdentifier,
VerificationType: verificationType,
Findings: make([]Finding, 0, 2),
StartedAt: time.Now().UTC(),
}
backupManifest, readError := verifier.sourceRepository.ReadManifest(verifyContext, backupIdentifier)
if readError != nil {
report.Findings = append(report.Findings, Finding{
Code: "MANIFEST_UNREADABLE",
Severity: SeverityMissing,
Message: "Das Manifest ist nicht lesbar. Ohne es ist nicht feststellbar, was gesichert wurde.",
Detail: readError.Error(),
})
finalizeReport(report, startTime)
return report, nil
}
verifier.checkManifest(backupManifest, report)
if verificationType == TypeChunkPresence || verificationType == TypeChunkIntegrity {
verifier.checkChunks(verifyContext, backupManifest, verificationType, report)
}
finalizeReport(report, startTime)
return report, nil
}
// checkManifest prueft das Manifest auf Stimmigkeit.
func (verifier *Verifier) checkManifest(backupManifest *repository.Manifest, report *Report) {
if !backupManifest.Complete {
report.Findings = append(report.Findings, Finding{
Code: "BACKUP_INCOMPLETE",
Severity: SeverityMissing,
Message: "Das Backup traegt keinen Abschlussvermerk und ist damit unvollstaendig.",
})
}
if len(backupManifest.Entries) == 0 {
// Ein Backup ohne Eintraege ist ein Gebilde, das sich als erfolgreiche
// Sicherung ausgibt, ohne etwas zu enthalten.
report.Findings = append(report.Findings, Finding{
Code: "MANIFEST_EMPTY",
Severity: SeverityWarning,
Message: "Das Backup enthaelt keine Objekte.",
})
}
// Ein Eintrag mit Groesse, aber ohne Blockverweise, beschreibt Daten, die
// es im Repository nicht gibt.
var entriesWithoutChunks int
for _, manifestEntry := range backupManifest.Entries {
if manifestEntry.EntryType == "file" && manifestEntry.SizeBytes > 0 && len(manifestEntry.Chunks) == 0 {
entriesWithoutChunks++
}
}
if entriesWithoutChunks > 0 {
report.Findings = append(report.Findings, Finding{
Code: "ENTRIES_WITHOUT_CHUNKS",
Severity: SeverityCorruption,
Message: fmt.Sprintf("%d Eintraege nennen eine Groesse, aber keine Bloecke. Das Manifest "+
"beschreibt Daten, die es nicht gibt.", entriesWithoutChunks),
})
}
}
// checkChunks prueft die Bloecke eines Backups.
//
// Bei TypeChunkPresence genuegt der Verzeichniseintrag; bei TypeChunkIntegrity
// wird jeder Block gelesen und neu gehasht.
//
// Der Vergleich geschieht gegen **StoredDigest**, nicht gegen die Kennung: Bei
// einem verschluesselten Block beschreibt die Kennung den Klartext, nicht die
// abgelegten Bytes. Ohne diese Unterscheidung meldete die Pruefung jeden
// verschluesselten Block als beschaedigt — ein Fehlalarm, der das Werkzeug
// wertlos macht.
func (verifier *Verifier) checkChunks(checkContext context.Context, backupManifest *repository.Manifest, verificationType VerificationType, report *Report) {
chunkReferences := backupManifest.UniqueChunkReferences()
report.ChunksChecked = len(chunkReferences)
missingPaths := make([]string, 0, maximumNamedObjects)
corruptedPaths := make([]string, 0, maximumNamedObjects)
ownerByChunk := buildChunkOwnerIndex(backupManifest)
for chunkIdentifier, chunkReference := range chunkReferences {
if contextError := checkContext.Err(); contextError != nil {
report.Findings = append(report.Findings, Finding{
Code: "VERIFICATION_CANCELLED",
Severity: SeverityWarning,
Message: "Die Pruefung wurde abgebrochen und ist damit ohne Aussage.",
})
return
}
if verificationType == TypeChunkPresence {
chunkExists, existenceError := verifier.sourceRepository.HasChunk(checkContext, chunkIdentifier)
if existenceError != nil || !chunkExists {
report.ChunksMissing++
if len(missingPaths) < maximumNamedObjects {
missingPaths = append(missingPaths, ownerByChunk[chunkIdentifier])
}
}
continue
}
// ReadStoredChunk statt ReadChunk oder OpenChunk: Beide pruefen gegen
// die **Kennung**, die bei einem verschluesselten Block den Klartext
// beschreibt — sie meldeten jeden solchen Block als beschaedigt.
// ReadStoredChunk vergleicht gegen StoredDigest, also gegen die
// Pruefsumme der tatsaechlich abgelegten Form, und kommt deshalb ohne
// Schluessel aus (PROMPT.md §14).
expectedDigest := chunkReference.StoredDigest
if expectedDigest == "" {
// Ohne Transformation beschreibt die Kennung die abgelegten Bytes.
expectedDigest = chunkIdentifier
}
storedData, readError := verifier.sourceRepository.ReadStoredChunk(
checkContext, chunkIdentifier, expectedDigest)
if readError != nil {
// Fehlend und beschaedigt sind zu unterscheiden: Ein fehlender
// Block koennte anderswo noch existieren, ein beschaedigter ist
// verloren und macht den Datentraeger verdaechtig.
if errors.Is(readError, repository.ErrChunkCorrupted) {
report.ChunksCorrupted++
if len(corruptedPaths) < maximumNamedObjects {
corruptedPaths = append(corruptedPaths, ownerByChunk[chunkIdentifier])
}
continue
}
report.ChunksMissing++
if len(missingPaths) < maximumNamedObjects {
missingPaths = append(missingPaths, ownerByChunk[chunkIdentifier])
}
continue
}
report.BytesRead += int64(len(storedData))
}
if report.ChunksMissing > 0 {
report.Findings = append(report.Findings, Finding{
Code: "CHUNKS_MISSING",
Severity: SeverityMissing,
Message: fmt.Sprintf("%d von %d Bloecken fehlen im Repository.",
report.ChunksMissing, report.ChunksChecked),
Detail: formatAffectedPaths(missingPaths, report.ChunksMissing),
})
}
if report.ChunksCorrupted > 0 {
report.Findings = append(report.Findings, Finding{
Code: "CHUNKS_CORRUPTED",
Severity: SeverityCorruption,
Message: fmt.Sprintf("%d von %d Bloecken sind beschaedigt: Ihr Inhalt passt nicht zu ihrer "+
"Pruefsumme. Der Datentraeger ist verdaechtig.",
report.ChunksCorrupted, report.ChunksChecked),
Detail: formatAffectedPaths(corruptedPaths, report.ChunksCorrupted),
})
}
}
// VerifyChain prueft ein Backup samt seiner Elternbackups.
//
// Eine Zusatzsicherung mit vollstaendigem Manifest braucht ihre Kette zum
// Wiederherstellen nicht — die Kette bleibt trotzdem pruefenswert: Sie zeigt,
// ob die Aufbewahrung ein Elternbackup entfernt hat, dessen Bloecke noch
// gebraucht werden.
func (verifier *Verifier) VerifyChain(verifyContext context.Context, backupIdentifier string) (*Report, error) {
startTime := time.Now()
report := &Report{
BackupID: backupIdentifier,
VerificationType: TypeChain,
Findings: make([]Finding, 0, 2),
StartedAt: time.Now().UTC(),
}
// Die Grenze schuetzt vor einer im Kreis verweisenden Kette. Sie darf nicht
// vorkommen, wuerde hier aber sonst eine Endlosschleife ergeben.
const maximumChainLength = 1000
currentIdentifier := backupIdentifier
visitedIdentifiers := make(map[string]struct{})
for chainStep := 0; chainStep < maximumChainLength; chainStep++ {
if _, alreadyVisited := visitedIdentifiers[currentIdentifier]; alreadyVisited {
report.Findings = append(report.Findings, Finding{
Code: "CHAIN_CYCLE",
Severity: SeverityCorruption,
Message: "Die Sicherungskette verweist im Kreis.",
Detail: currentIdentifier,
})
break
}
visitedIdentifiers[currentIdentifier] = struct{}{}
currentManifest, readError := verifier.sourceRepository.ReadManifest(verifyContext, currentIdentifier)
if readError != nil {
report.Findings = append(report.Findings, Finding{
Code: "CHAIN_BROKEN",
Severity: SeverityMissing,
Message: fmt.Sprintf("Das Elternbackup %s der Kette fehlt. Die Kette ist unterbrochen.",
currentIdentifier),
Detail: currentIdentifier,
})
break
}
report.ChainLength++
if !currentManifest.Complete {
report.Findings = append(report.Findings, Finding{
Code: "CHAIN_MEMBER_INCOMPLETE",
Severity: SeverityMissing,
Message: fmt.Sprintf("Das Backup %s in der Kette traegt keinen Abschlussvermerk.",
currentIdentifier),
Detail: currentIdentifier,
})
}
if currentManifest.ParentBackupID == "" {
// Die Vollsicherung ist erreicht: Die Kette hat einen Anfang.
break
}
currentIdentifier = currentManifest.ParentBackupID
}
finalizeReport(report, startTime)
return report, nil
}
// buildChunkOwnerIndex ordnet jedem Block ein Objekt zu.
//
// Ein Befund, der nur eine Blockkennung nennt, hilft niemandem weiter. Der
// Pfad des Objekts sagt, was verloren ist.
func buildChunkOwnerIndex(backupManifest *repository.Manifest) map[string]string {
ownerByChunk := make(map[string]string)
for _, manifestEntry := range backupManifest.Entries {
for _, chunkReference := range manifestEntry.Chunks {
if _, alreadyKnown := ownerByChunk[chunkReference.Identifier]; alreadyKnown {
continue
}
ownerByChunk[chunkReference.Identifier] = manifestEntry.Path
}
}
return ownerByChunk
}
// formatAffectedPaths beschreibt die betroffenen Objekte.
func formatAffectedPaths(namedPaths []string, totalCount int) string {
if len(namedPaths) == 0 {
return ""
}
detailText := namedPaths[0]
for _, additionalPath := range namedPaths[1:] {
detailText += ", " + additionalPath
}
if totalCount > len(namedPaths) {
detailText += fmt.Sprintf(" und %d weitere", totalCount-len(namedPaths))
}
return detailText
}
// finalizeReport schliesst einen Bericht ab.
func finalizeReport(report *Report, startTime time.Time) {
report.CompletedAt = time.Now().UTC()
report.DurationSeconds = time.Since(startTime).Seconds()
}