syncova-backup/packages/repository/scan.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

436 lines
16 KiB
Go

package repository
import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"time"
)
// ScanFindingSeverity ist die Schwere eines Befunds.
type ScanFindingSeverity string
const (
// ScanSeverityWarning meldet ein Problem ohne unmittelbaren Datenverlust.
ScanSeverityWarning ScanFindingSeverity = "warning"
// ScanSeverityCritical meldet einen Datenverlust oder eine Beschädigung.
ScanSeverityCritical ScanFindingSeverity = "critical"
)
// ScanFinding ist ein einzelner Befund eines Integritätslaufs.
type ScanFinding struct {
// Severity ist die Schwere des Befunds.
Severity ScanFindingSeverity `json:"severity"`
// BackupID benennt das betroffene Backup.
BackupID string `json:"backup_id,omitempty"`
// ChunkIdentifier benennt den betroffenen Chunk.
ChunkIdentifier string `json:"chunk_id,omitempty"`
// Message erklärt den Befund verständlich.
Message string `json:"message"`
// RecommendedAction nennt den nächsten sinnvollen Schritt.
RecommendedAction string `json:"recommended_action,omitempty"`
}
// ScanReport ist das Ergebnis eines Integritätslaufs.
type ScanReport struct {
// RepositoryID benennt das geprüfte Repository.
RepositoryID string `json:"repository_id"`
// StartedAt ist der Beginn des Laufs in UTC.
StartedAt time.Time `json:"started_at"`
// CompletedAt ist das Ende des Laufs in UTC.
CompletedAt time.Time `json:"completed_at"`
// VerifiedChunkContents meldet, ob die Chunk-Inhalte neu gehasht wurden.
VerifiedChunkContents bool `json:"verified_chunk_contents"`
// BackupsChecked ist die Zahl geprüfter Backups.
BackupsChecked int `json:"backups_checked"`
// BackupsHealthy ist die Zahl einwandfreier Backups.
BackupsHealthy int `json:"backups_healthy"`
// ChunksChecked ist die Zahl geprüfter Chunks.
ChunksChecked int64 `json:"chunks_checked"`
// BytesChecked ist die Menge gelesener Daten.
BytesChecked int64 `json:"bytes_checked"`
// MissingChunks ist die Zahl fehlender Chunks.
MissingChunks int64 `json:"missing_chunks"`
// CorruptedChunks ist die Zahl beschädigter Chunks.
CorruptedChunks int64 `json:"corrupted_chunks"`
// OrphanedChunks ist die Zahl von keinem Backup benutzter Chunks.
OrphanedChunks int64 `json:"orphaned_chunks"`
// Findings sind die Einzelbefunde.
Findings []ScanFinding `json:"findings"`
// AffectedBackupIDs sind Backups, die nicht vollständig wiederherstellbar sind.
AffectedBackupIDs []string `json:"affected_backup_ids,omitempty"`
}
// IsHealthy meldet, ob der Lauf ohne kritischen Befund endete.
func (report *ScanReport) IsHealthy() bool {
return report.MissingChunks == 0 && report.CorruptedChunks == 0 && len(report.AffectedBackupIDs) == 0
}
// Summary fasst das Ergebnis in einem Satz zusammen.
//
// Die Formulierung ist bewusst deutlich: ein beschädigtes Repository darf nicht
// beschönigt werden (PROMPT.md §14, §140).
func (report *ScanReport) Summary() string {
if report.IsHealthy() {
return fmt.Sprintf("Alle %d Backups sind vollständig; %d Chunks geprüft.",
report.BackupsChecked, report.ChunksChecked)
}
return fmt.Sprintf("%d von %d Backups sind NICHT vollständig wiederherstellbar (%d Chunks fehlen, %d sind beschädigt).",
len(report.AffectedBackupIDs), report.BackupsChecked, report.MissingChunks, report.CorruptedChunks)
}
// Scan prüft die Unversehrtheit des gesamten Repositorys.
//
// Geprüft wird von den Manifesten aus: für jedes Backup wird festgestellt, ob
// alle benötigten Chunks vorhanden und - auf Wunsch - unbeschädigt sind. Der
// umgekehrte Weg über die Chunk-Ablage würde nicht zeigen, ob ein Backup
// wiederherstellbar ist.
func (localRepository *LocalRepository) Scan(scanContext context.Context, scanOptions ScanOptions) (*ScanReport, error) {
scanReport := &ScanReport{
RepositoryID: localRepository.descriptor.RepositoryID,
StartedAt: localRepository.timeSource().UTC(),
VerifiedChunkContents: scanOptions.VerifyChunkContents,
Findings: make([]ScanFinding, 0),
}
backupIDs, listError := localRepository.listManifestBackupIDs()
if listError != nil {
return nil, listError
}
// referencedChunks sammelt alle benutzten Chunks, um am Ende verwaiste zu erkennen.
referencedChunks := make(map[string]struct{})
// checkedChunks verhindert, dass ein von mehreren Backups genutzter Chunk
// mehrfach gelesen wird. Bei guter Deduplizierung spart das den Großteil der Arbeit.
checkedChunks := make(map[string]error)
for backupIndex, backupID := range backupIDs {
if contextError := scanContext.Err(); contextError != nil {
return nil, contextError
}
scanReport.BackupsChecked++
manifest, manifestError := localRepository.ReadManifest(scanContext, backupID)
if manifestError != nil {
scanReport.Findings = append(scanReport.Findings, ScanFinding{
Severity: ScanSeverityCritical,
BackupID: backupID,
Message: fmt.Sprintf("Das Manifest ist nicht verwendbar: %v", manifestError),
RecommendedAction: "Backup als nicht wiederherstellbar behandeln und erneut sichern.",
})
scanReport.AffectedBackupIDs = append(scanReport.AffectedBackupIDs, backupID)
continue
}
backupIsIntact := true
for chunkIdentifier, chunkReference := range manifest.UniqueChunkReferences() {
if contextError := scanContext.Err(); contextError != nil {
return nil, contextError
}
referencedChunks[chunkIdentifier] = struct{}{}
// Ein bereits geprüfter Chunk wird nicht erneut gelesen.
previousResult, alreadyChecked := checkedChunks[chunkIdentifier]
if alreadyChecked {
if previousResult != nil {
backupIsIntact = false
}
continue
}
chunkError := localRepository.verifySingleChunk(scanContext, chunkReference, scanOptions, scanReport)
checkedChunks[chunkIdentifier] = chunkError
if chunkError != nil {
backupIsIntact = false
scanReport.Findings = append(scanReport.Findings, ScanFinding{
Severity: ScanSeverityCritical,
BackupID: backupID,
ChunkIdentifier: chunkIdentifier,
Message: chunkError.Error(),
RecommendedAction: "Betroffenes Backup erneut erstellen; eine Wiederherstellung ist unvollständig.",
})
}
}
if backupIsIntact {
scanReport.BackupsHealthy++
} else {
scanReport.AffectedBackupIDs = append(scanReport.AffectedBackupIDs, backupID)
}
if scanOptions.ProgressCallback != nil {
scanOptions.ProgressCallback(ScanProgress{
BackupsChecked: backupIndex + 1,
BackupsTotal: len(backupIDs),
ChunksChecked: scanReport.ChunksChecked,
BytesChecked: scanReport.BytesChecked,
})
}
}
// Verwaiste Chunks sind kein Datenverlust, aber belegter Speicherplatz.
orphanedCount, orphanError := localRepository.countOrphanedChunks(scanContext, referencedChunks)
if orphanError != nil {
localRepository.logger.Warn("verwaiste chunks konnten nicht ermittelt werden",
slog.String("error", orphanError.Error()))
} else {
scanReport.OrphanedChunks = orphanedCount
if orphanedCount > 0 {
scanReport.Findings = append(scanReport.Findings, ScanFinding{
Severity: ScanSeverityWarning,
Message: fmt.Sprintf("%d Chunks werden von keinem Backup mehr benötigt und belegen unnötig Speicherplatz.",
orphanedCount),
RecommendedAction: "Bereinigung ausführen, sobald keine Sicherung läuft.",
})
}
}
scanReport.CompletedAt = localRepository.timeSource().UTC()
localRepository.logger.Info("integritätslauf abgeschlossen",
slog.Int("backups_geprueft", scanReport.BackupsChecked),
slog.Int("backups_intakt", scanReport.BackupsHealthy),
slog.Int64("chunks_geprueft", scanReport.ChunksChecked),
slog.Int64("chunks_fehlend", scanReport.MissingChunks),
slog.Int64("chunks_beschaedigt", scanReport.CorruptedChunks),
)
if persistError := localRepository.persistScanReport(scanReport); persistError != nil {
localRepository.logger.Warn("der prüfbericht konnte nicht abgelegt werden",
slog.String("error", persistError.Error()))
}
return scanReport, nil
}
// verifySingleChunk prüft einen einzelnen Chunk.
//
// Womit verglichen wird, hängt von der Ablageform ab:
//
// - Ein untransformierter Block wird gegen seine Kennung geprüft; sie ist der
// Hash seines Inhalts.
// - Ein komprimierter oder verschlüsselter Block wird gegen die im Manifest
// vermerkte Prüfsumme der abgelegten Form geprüft. Seine Kennung beschreibt
// den Klartext und passt nicht zu den abgelegten Bytes.
//
// Würde in beiden Fällen gegen die Kennung geprüft, meldete der Lauf für jeden
// verschlüsselten Block einen Fehlalarm — und ein Prüfwerkzeug, das ständig
// grundlos Alarm schlägt, wird bald nicht mehr ernst genommen.
func (localRepository *LocalRepository) verifySingleChunk(scanContext context.Context, chunkReference ChunkReference, scanOptions ScanOptions, scanReport *ScanReport) error {
chunkIdentifier := chunkReference.Identifier
targetPath, pathError := localRepository.chunkPath(chunkIdentifier)
if pathError != nil {
return pathError
}
fileInformation, statError := os.Stat(targetPath)
if statError != nil {
if errors.Is(statError, os.ErrNotExist) {
scanReport.MissingChunks++
return fmt.Errorf("Der benötigte Chunk fehlt im Repository (%s).", chunkIdentifier)
}
return fmt.Errorf("Der Chunk konnte nicht geprüft werden: %v", statError)
}
scanReport.ChunksChecked++
// Ohne Inhaltsprüfung ist nur belegt, dass der Chunk existiert.
if !scanOptions.VerifyChunkContents {
return nil
}
chunkData, readError := os.ReadFile(targetPath)
if readError != nil {
return fmt.Errorf("Der Chunk konnte nicht gelesen werden: %v", readError)
}
scanReport.BytesChecked += int64(len(chunkData))
actualDigest := computeChunkIdentifier(chunkData)
// Die erwartete Prüfsumme richtet sich nach der Ablageform.
expectedDigest := chunkReference.StoredDigest
if expectedDigest == "" {
expectedDigest = chunkIdentifier
}
if actualDigest != expectedDigest {
scanReport.CorruptedChunks++
return fmt.Errorf("Der Chunk ist beschädigt: sein Inhalt passt nicht zu seiner Prüfsumme (%s, %d Byte).",
chunkIdentifier, fileInformation.Size())
}
return nil
}
// countOrphanedChunks zählt Chunks, die kein Backup mehr benötigt.
func (localRepository *LocalRepository) countOrphanedChunks(scanContext context.Context, referencedChunks map[string]struct{}) (int64, error) {
var orphanedCount int64
walkError := localRepository.walkChunks(scanContext, func(chunkIdentifier string, chunkSize int64) error {
if _, isReferenced := referencedChunks[chunkIdentifier]; !isReferenced {
orphanedCount++
}
return nil
})
return orphanedCount, walkError
}
// walkChunks ruft eine Funktion für jeden abgelegten Chunk auf.
func (localRepository *LocalRepository) walkChunks(walkContext context.Context, visitChunk func(chunkIdentifier string, chunkSize int64) error) error {
chunksRoot := localRepository.rootPath + string(os.PathSeparator) + directoryChunks
return walkChunkDirectory(walkContext, chunksRoot, 0, visitChunk)
}
// walkChunkDirectory durchläuft die Chunk-Ablage rekursiv.
func walkChunkDirectory(walkContext context.Context, directoryPath string, currentDepth int, visitChunk func(chunkIdentifier string, chunkSize int64) error) error {
directoryEntries, readError := os.ReadDir(directoryPath)
if readError != nil {
if errors.Is(readError, os.ErrNotExist) {
return nil
}
return fmt.Errorf("das chunk-verzeichnis konnte nicht gelesen werden: %w", readError)
}
for _, directoryEntry := range directoryEntries {
if contextError := walkContext.Err(); contextError != nil {
return contextError
}
entryPath := directoryPath + string(os.PathSeparator) + directoryEntry.Name()
if directoryEntry.IsDir() {
if walkError := walkChunkDirectory(walkContext, entryPath, currentDepth+1, visitChunk); walkError != nil {
return walkError
}
continue
}
// Nur wohlgeformte Kennungen zählen als Chunk; temporäre Reste eines
// abgebrochenen Schreibvorgangs werden übergangen.
if validateChunkIdentifier(directoryEntry.Name()) != nil {
continue
}
entryInformation, infoError := directoryEntry.Info()
if infoError != nil {
continue
}
if visitError := visitChunk(directoryEntry.Name(), entryInformation.Size()); visitError != nil {
return visitError
}
}
return nil
}
// persistScanReport legt einen Prüfbericht im Repository ab.
//
// Der Bericht gehört ins Repository und nicht nur in die Datenbank: nach einem
// Wiederaufbau soll erkennbar bleiben, wann zuletzt geprüft wurde.
func (localRepository *LocalRepository) persistScanReport(scanReport *ScanReport) error {
encodedReport, encodeError := jsonMarshalIndent(scanReport)
if encodeError != nil {
return encodeError
}
reportName := fmt.Sprintf("scan-%s.json", scanReport.StartedAt.Format("20060102-150405"))
reportPath := localRepository.rootPath + string(os.PathSeparator) + directoryVerification + string(os.PathSeparator) + reportName
return writeFileAtomically(reportPath, encodedReport, dataFilePermissions)
}
// PruneOrphanedChunks entfernt Chunks, die kein Backup mehr benötigt.
//
// Der Vorgang ist potenziell datenzerstörend und deshalb ausdrücklich getrennt
// vom Scan (PROMPT.md §141). Er darf niemals laufen, während ein Backup
// schreibt: ein gerade abgelegter Chunk hätte noch kein Manifest und würde als
// verwaist gelten. Die Schreibsperre des Repositorys verhindert das.
func (localRepository *LocalRepository) PruneOrphanedChunks(pruneContext context.Context, dryRun bool) (removedCount int64, freedBytes int64, pruneError error) {
if localRepository.lockHandle == nil {
return 0, 0, fmt.Errorf("die bereinigung verlangt eine schreibsperre; das repository wurde nur lesend geöffnet")
}
backupIDs, listError := localRepository.listManifestBackupIDs()
if listError != nil {
return 0, 0, listError
}
referencedChunks := make(map[string]struct{})
for _, backupID := range backupIDs {
manifest, manifestError := localRepository.ReadManifest(pruneContext, backupID)
if manifestError != nil {
// Ein unlesbares Manifest bedeutet: die Menge der benötigten Chunks
// ist unbekannt. Es wird nichts gelöscht, denn eine Bereinigung auf
// unvollständiger Grundlage könnte Daten vernichten.
return 0, 0, fmt.Errorf("die bereinigung wurde abgebrochen: das manifest von %s ist nicht lesbar (%w)",
backupID, manifestError)
}
for chunkIdentifier := range manifest.UniqueChunkIdentifiers() {
referencedChunks[chunkIdentifier] = struct{}{}
}
}
walkError := localRepository.walkChunks(pruneContext, func(chunkIdentifier string, chunkSize int64) error {
if _, isReferenced := referencedChunks[chunkIdentifier]; isReferenced {
return nil
}
removedCount++
freedBytes += chunkSize
if dryRun {
return nil
}
chunkFilePath, pathError := localRepository.chunkPath(chunkIdentifier)
if pathError != nil {
return pathError
}
// In einem gehärteten Repository trägt jeder Block ein Löschkennzeichen.
// Es wird hier aufgehoben — und nur hier, für Blöcke, die nachweislich
// kein Manifest mehr referenziert.
if releaseError := localRepository.releaseStoredChunk(chunkFilePath); releaseError != nil {
return fmt.Errorf("ein verwaister chunk liess sich nicht zum entfernen freigeben: %w", releaseError)
}
if removeError := os.Remove(chunkFilePath); removeError != nil && !errors.Is(removeError, os.ErrNotExist) {
return fmt.Errorf("ein verwaister chunk konnte nicht entfernt werden: %w", removeError)
}
return nil
})
if walkError != nil {
return 0, 0, walkError
}
localRepository.logger.Info("verwaiste chunks bereinigt",
slog.Int64("chunks", removedCount),
slog.Int64("freigegebene_bytes", freedBytes),
slog.Bool("nur_simulation", dryRun))
return removedCount, freedBytes, nil
}