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>
487 lines
16 KiB
Go
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()
|
|
}
|