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>
436 lines
16 KiB
Go
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
|
|
}
|