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>
461 lines
16 KiB
Go
461 lines
16 KiB
Go
package recovery
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/syncova/syncova/packages/backupengine"
|
|
"github.com/syncova/syncova/packages/platform/crypto"
|
|
"github.com/syncova/syncova/packages/repository"
|
|
)
|
|
|
|
// discardLogger unterdrueckt die Protokollausgabe der Tests.
|
|
func discardLogger() *slog.Logger {
|
|
return slog.New(slog.NewJSONHandler(io.Discard, nil))
|
|
}
|
|
|
|
// buildTestSecretStore erzeugt einen Schluesselspeicher.
|
|
func buildTestSecretStore(testInstance *testing.T) crypto.SecretStore {
|
|
testInstance.Helper()
|
|
|
|
builtStore, storeError := crypto.NewLocalSecretStore(
|
|
map[string][]byte{"v1": bytes.Repeat([]byte{0x51}, 32)}, "v1")
|
|
if storeError != nil {
|
|
testInstance.Fatalf("der Schluesselspeicher liess sich nicht bauen: %v", storeError)
|
|
}
|
|
|
|
return builtStore
|
|
}
|
|
|
|
// backupFixture ist ein vorbereitetes Repository mit einem Backup.
|
|
type backupFixture struct {
|
|
// RepositoryPath ist der Pfad des Repositorys.
|
|
RepositoryPath string
|
|
// Repository ist das geoeffnete Repository.
|
|
Repository *repository.LocalRepository
|
|
// BackupID ist die Kennung des angelegten Backups.
|
|
BackupID string
|
|
// SourceData sind die gesicherten Inhalte je Pfad.
|
|
SourceData map[string][]byte
|
|
}
|
|
|
|
// buildBackupFixture legt ein Repository mit einem verschluesselten Backup an.
|
|
func buildBackupFixture(testInstance *testing.T, encrypted bool) *backupFixture {
|
|
testInstance.Helper()
|
|
|
|
repositoryPath := filepath.Join(testInstance.TempDir(), "repository")
|
|
|
|
createdRepository, createError := repository.Create(context.Background(), repositoryPath,
|
|
repository.CreateOptions{Name: "Pruefung", CreatedByVersion: "test"}, discardLogger())
|
|
if createError != nil {
|
|
testInstance.Fatalf("das Repository liess sich nicht anlegen: %v", createError)
|
|
}
|
|
|
|
var secretStore crypto.SecretStore
|
|
if encrypted {
|
|
secretStore = buildTestSecretStore(testInstance)
|
|
}
|
|
|
|
engine := backupengine.NewEngine(createdRepository, secretStore, discardLogger())
|
|
|
|
sourceData := map[string][]byte{
|
|
"dokumente/bericht.txt": []byte(strings.Repeat("Inhalt eines Berichts. ", 500)),
|
|
"dokumente/notiz.txt": []byte("Eine kurze Notiz."),
|
|
"bilder/foto.bin": buildIncompressibleData(64 << 10),
|
|
}
|
|
|
|
backupSources := []backupengine.BackupSource{
|
|
{Path: "dokumente", EntryType: "directory", Mode: "0755"},
|
|
{Path: "bilder", EntryType: "directory", Mode: "0755"},
|
|
}
|
|
|
|
for entryPath, entryContent := range sourceData {
|
|
backupSources = append(backupSources, backupengine.BackupSource{
|
|
Path: entryPath,
|
|
EntryType: "file",
|
|
Mode: "0644",
|
|
Reader: bytes.NewReader(entryContent),
|
|
})
|
|
}
|
|
|
|
if _, backupError := engine.Backup(context.Background(), backupengine.BackupOptions{
|
|
BackupID: "pruef-backup",
|
|
BackupType: repository.BackupTypeFull,
|
|
Source: repository.SourceInformation{SourceType: "filesystem", SourceID: "/test"},
|
|
CompressionLevel: backupengine.CompressionOff,
|
|
EncryptionEnabled: encrypted,
|
|
}, backupSources); backupError != nil {
|
|
testInstance.Fatalf("das Backup liess sich nicht anlegen: %v", backupError)
|
|
}
|
|
|
|
testInstance.Cleanup(func() { _ = createdRepository.Close() })
|
|
|
|
return &backupFixture{
|
|
RepositoryPath: repositoryPath,
|
|
Repository: createdRepository,
|
|
BackupID: "pruef-backup",
|
|
SourceData: sourceData,
|
|
}
|
|
}
|
|
|
|
// buildIncompressibleData erzeugt reproduzierbare, kaum komprimierbare Daten.
|
|
func buildIncompressibleData(byteCount int) []byte {
|
|
generatedData := make([]byte, byteCount)
|
|
|
|
var generatorState uint64 = 0x2545F4914F6CDD1D
|
|
for byteIndex := range generatedData {
|
|
generatorState ^= generatorState << 13
|
|
generatorState ^= generatorState >> 7
|
|
generatorState ^= generatorState << 17
|
|
generatedData[byteIndex] = byte(generatorState)
|
|
}
|
|
|
|
return generatedData
|
|
}
|
|
|
|
// findFinding sucht einen Befund anhand seines Codes.
|
|
func findFinding(report *ValidationReport, findingCode string) *ValidationFinding {
|
|
for findingIndex := range report.Findings {
|
|
if report.Findings[findingIndex].Code == findingCode {
|
|
return &report.Findings[findingIndex]
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// TestValidationAcceptsIntactBackup prueft den Normalfall.
|
|
func TestValidationAcceptsIntactBackup(testInstance *testing.T) {
|
|
fixture := buildBackupFixture(testInstance, true)
|
|
validator := NewValidator(fixture.Repository)
|
|
|
|
report, validateError := validator.Validate(context.Background(), ValidationRequest{
|
|
BackupID: fixture.BackupID,
|
|
TargetPath: filepath.Join(testInstance.TempDir(), "ziel"),
|
|
DeepChunkCheck: true,
|
|
})
|
|
if validateError != nil {
|
|
testInstance.Fatalf("die Pruefung schlug fehl: %v", validateError)
|
|
}
|
|
|
|
if !report.CanProceed() {
|
|
testInstance.Fatalf("ein unversehrtes Backup wurde abgelehnt: %v", report.BlockingFindings())
|
|
}
|
|
|
|
if report.FileCount != 3 {
|
|
testInstance.Errorf("es wurden %d Dateien gezaehlt, erwartet waren 3", report.FileCount)
|
|
}
|
|
|
|
if report.MissingChunkCount != 0 {
|
|
testInstance.Errorf("es fehlen angeblich %d Bloecke", report.MissingChunkCount)
|
|
}
|
|
|
|
if report.UniqueChunkCount == 0 {
|
|
testInstance.Error("es wurden keine Bloecke geprueft; die Pruefung ist ohne Aussage")
|
|
}
|
|
}
|
|
|
|
// TestValidationDetectsMissingChunk ist der wichtigste Test dieser Datei.
|
|
//
|
|
// Ein Manifest allein belegt nur, dass jemand einmal etwas gesichert hat —
|
|
// nicht, dass die Daten noch da sind. Ein versehentlich aufgeraeumtes
|
|
// Verzeichnis oder ein unvollstaendig kopiertes Repository muss **vor** dem
|
|
// Ernstfall auffallen.
|
|
func TestValidationDetectsMissingChunk(testInstance *testing.T) {
|
|
fixture := buildBackupFixture(testInstance, true)
|
|
|
|
backupManifest, readError := fixture.Repository.ReadManifest(context.Background(), fixture.BackupID)
|
|
if readError != nil {
|
|
testInstance.Fatalf("das Manifest liess sich nicht lesen: %v", readError)
|
|
}
|
|
|
|
// Ein Block wird aus der Ablage entfernt.
|
|
var removedIdentifier string
|
|
for chunkIdentifier := range backupManifest.UniqueChunkIdentifiers() {
|
|
removedIdentifier = chunkIdentifier
|
|
break
|
|
}
|
|
|
|
chunkPath := filepath.Join(fixture.RepositoryPath, "chunks",
|
|
removedIdentifier[0:2], removedIdentifier[2:4], removedIdentifier)
|
|
|
|
if removeError := os.Remove(chunkPath); removeError != nil {
|
|
testInstance.Fatalf("der Block liess sich nicht entfernen: %v", removeError)
|
|
}
|
|
|
|
validator := NewValidator(fixture.Repository)
|
|
|
|
report, validateError := validator.Validate(context.Background(), ValidationRequest{
|
|
BackupID: fixture.BackupID,
|
|
TargetPath: filepath.Join(testInstance.TempDir(), "ziel"),
|
|
DeepChunkCheck: true,
|
|
})
|
|
if validateError != nil {
|
|
testInstance.Fatalf("die Pruefung schlug fehl: %v", validateError)
|
|
}
|
|
|
|
if report.CanProceed() {
|
|
testInstance.Fatal("ein Backup mit fehlendem Block wurde als wiederherstellbar gemeldet")
|
|
}
|
|
|
|
missingFinding := findFinding(report, "CHUNKS_MISSING")
|
|
if missingFinding == nil {
|
|
testInstance.Fatalf("der fehlende Block wurde nicht benannt: %+v", report.Findings)
|
|
}
|
|
|
|
// Die Meldung muss das betroffene Objekt nennen; eine blosse Zahl hilft
|
|
// niemandem weiter.
|
|
if missingFinding.Detail == "" {
|
|
testInstance.Error("der Befund nennt kein betroffenes Objekt")
|
|
}
|
|
|
|
if report.MissingChunkCount != 1 {
|
|
testInstance.Errorf("es wurden %d fehlende Bloecke gemeldet, erwartet war 1", report.MissingChunkCount)
|
|
}
|
|
|
|
if !strings.Contains(report.Summary(), "NICHT WIEDERHERSTELLBAR") {
|
|
testInstance.Errorf("die Zusammenfassung beschoenigt den Befund: %q", report.Summary())
|
|
}
|
|
}
|
|
|
|
// TestValidationWithoutDeepCheckSaysSo prueft die Ehrlichkeit der flachen Pruefung.
|
|
//
|
|
// Ohne Blockpruefung ist nicht festgestellt, ob die Daten da sind. Das muss im
|
|
// Bericht stehen, sonst haelt man einen halben Nachweis fuer einen ganzen.
|
|
func TestValidationWithoutDeepCheckSaysSo(testInstance *testing.T) {
|
|
fixture := buildBackupFixture(testInstance, true)
|
|
validator := NewValidator(fixture.Repository)
|
|
|
|
report, _ := validator.Validate(context.Background(), ValidationRequest{
|
|
BackupID: fixture.BackupID,
|
|
TargetPath: filepath.Join(testInstance.TempDir(), "ziel"),
|
|
DeepChunkCheck: false,
|
|
})
|
|
|
|
skippedFinding := findFinding(report, "CHUNK_CHECK_SKIPPED")
|
|
if skippedFinding == nil {
|
|
testInstance.Fatal("die uebersprungene Blockpruefung wurde nicht ausgewiesen")
|
|
}
|
|
|
|
if skippedFinding.Severity != SeverityInformation {
|
|
testInstance.Errorf("der Hinweis wurde als %q eingestuft", skippedFinding.Severity)
|
|
}
|
|
|
|
// Sie verhindert nichts — sie sagt nur, was nicht geprueft wurde.
|
|
if !report.CanProceed() {
|
|
testInstance.Error("die flache Pruefung verhinderte die Wiederherstellung")
|
|
}
|
|
}
|
|
|
|
// TestValidationRefusesNonEmptyTargetWithoutConsent ist der Schutztest.
|
|
//
|
|
// Die vorhandenen Daten koennten genau die sein, die man eigentlich retten
|
|
// will.
|
|
func TestValidationRefusesNonEmptyTargetWithoutConsent(testInstance *testing.T) {
|
|
fixture := buildBackupFixture(testInstance, true)
|
|
|
|
occupiedTarget := testInstance.TempDir()
|
|
if writeError := os.WriteFile(filepath.Join(occupiedTarget, "vorhanden.txt"),
|
|
[]byte("wichtig"), 0o644); writeError != nil {
|
|
testInstance.Fatalf("die Datei liess sich nicht anlegen: %v", writeError)
|
|
}
|
|
|
|
validator := NewValidator(fixture.Repository)
|
|
|
|
report, _ := validator.Validate(context.Background(), ValidationRequest{
|
|
BackupID: fixture.BackupID,
|
|
TargetPath: occupiedTarget,
|
|
})
|
|
|
|
if report.CanProceed() {
|
|
testInstance.Fatal("ein nicht leeres Ziel wurde ohne Zustimmung angenommen")
|
|
}
|
|
|
|
if findFinding(report, "TARGET_NOT_EMPTY") == nil {
|
|
testInstance.Errorf("das volle Zielverzeichnis wurde nicht benannt: %+v", report.Findings)
|
|
}
|
|
|
|
// Mit ausdruecklicher Zustimmung geht es — aber mit Warnung.
|
|
consentedReport, _ := validator.Validate(context.Background(), ValidationRequest{
|
|
BackupID: fixture.BackupID,
|
|
TargetPath: occupiedTarget,
|
|
OverwriteExisting: true,
|
|
})
|
|
|
|
if !consentedReport.CanProceed() {
|
|
testInstance.Fatalf("mit Zustimmung wurde weiterhin abgelehnt: %v", consentedReport.BlockingFindings())
|
|
}
|
|
|
|
overwriteFinding := findFinding(consentedReport, "TARGET_WILL_BE_OVERWRITTEN")
|
|
if overwriteFinding == nil || overwriteFinding.Severity != SeverityWarning {
|
|
testInstance.Error("das Ueberschreiben wurde nicht als Warnung ausgewiesen")
|
|
}
|
|
}
|
|
|
|
// TestValidationDetectsUnknownBackup prueft ein fehlendes Manifest.
|
|
func TestValidationDetectsUnknownBackup(testInstance *testing.T) {
|
|
fixture := buildBackupFixture(testInstance, true)
|
|
validator := NewValidator(fixture.Repository)
|
|
|
|
report, validateError := validator.Validate(context.Background(), ValidationRequest{
|
|
BackupID: "gibt-es-nicht",
|
|
TargetPath: filepath.Join(testInstance.TempDir(), "ziel"),
|
|
})
|
|
|
|
// Ein unbekanntes Backup ist ein Befund, kein Programmfehler: Die Pruefung
|
|
// soll berichten, nicht abbrechen.
|
|
if validateError != nil {
|
|
testInstance.Fatalf("die Pruefung brach ab, statt zu berichten: %v", validateError)
|
|
}
|
|
|
|
if report.CanProceed() {
|
|
testInstance.Fatal("ein unbekanntes Backup wurde als wiederherstellbar gemeldet")
|
|
}
|
|
|
|
if findFinding(report, "MANIFEST_UNREADABLE") == nil {
|
|
testInstance.Errorf("das fehlende Manifest wurde nicht benannt: %+v", report.Findings)
|
|
}
|
|
}
|
|
|
|
// TestValidationRestrictsToSubtree prueft die Teilbaumauswahl.
|
|
//
|
|
// Der haeufigste Fall im Betrieb ist nicht die vollstaendige Wiederherstellung,
|
|
// sondern eine einzelne versehentlich geloeschte Datei.
|
|
func TestValidationRestrictsToSubtree(testInstance *testing.T) {
|
|
fixture := buildBackupFixture(testInstance, true)
|
|
validator := NewValidator(fixture.Repository)
|
|
|
|
report, _ := validator.Validate(context.Background(), ValidationRequest{
|
|
BackupID: fixture.BackupID,
|
|
TargetPath: filepath.Join(testInstance.TempDir(), "ziel"),
|
|
PathPrefix: "dokumente",
|
|
DeepChunkCheck: true,
|
|
})
|
|
|
|
if !report.CanProceed() {
|
|
testInstance.Fatalf("der Teilbaum wurde abgelehnt: %v", report.BlockingFindings())
|
|
}
|
|
|
|
// Zwei Dateien liegen unter dokumente/, die dritte unter bilder/.
|
|
if report.FileCount != 2 {
|
|
testInstance.Errorf("es wurden %d Dateien gezaehlt, erwartet waren 2", report.FileCount)
|
|
}
|
|
}
|
|
|
|
// TestValidationRespectsDirectoryBoundary prueft die Praefixgrenze.
|
|
//
|
|
// "dokumente" darf nicht auch "dokumentation" treffen — sonst kaeme bei einer
|
|
// gezielten Wiederherstellung mehr zurueck als verlangt.
|
|
func TestValidationRespectsDirectoryBoundary(testInstance *testing.T) {
|
|
fixture := buildBackupFixture(testInstance, true)
|
|
validator := NewValidator(fixture.Repository)
|
|
|
|
report, _ := validator.Validate(context.Background(), ValidationRequest{
|
|
BackupID: fixture.BackupID,
|
|
TargetPath: filepath.Join(testInstance.TempDir(), "ziel"),
|
|
PathPrefix: "dokument",
|
|
})
|
|
|
|
if report.CanProceed() {
|
|
testInstance.Fatal("das Praefix 'dokument' traf Objekte unter 'dokumente'")
|
|
}
|
|
|
|
if findFinding(report, "NO_MATCHING_ENTRIES") == nil {
|
|
testInstance.Errorf("der leere Teilbaum wurde nicht benannt: %+v", report.Findings)
|
|
}
|
|
}
|
|
|
|
// TestValidationReportsUnencryptedBackup prueft den Hinweis auf fehlende
|
|
// Verschluesselung.
|
|
func TestValidationReportsUnencryptedBackup(testInstance *testing.T) {
|
|
fixture := buildBackupFixture(testInstance, false)
|
|
validator := NewValidator(fixture.Repository)
|
|
|
|
report, _ := validator.Validate(context.Background(), ValidationRequest{
|
|
BackupID: fixture.BackupID,
|
|
TargetPath: filepath.Join(testInstance.TempDir(), "ziel"),
|
|
})
|
|
|
|
unencryptedFinding := findFinding(report, "BACKUP_UNENCRYPTED")
|
|
if unencryptedFinding == nil {
|
|
testInstance.Fatalf("das unverschluesselte Backup wurde nicht benannt: %+v", report.Findings)
|
|
}
|
|
|
|
// Es ist ein Hinweis, kein Hindernis: Wiederherstellen laesst es sich.
|
|
if unencryptedFinding.Severity != SeverityWarning {
|
|
testInstance.Errorf("der Hinweis wurde als %q eingestuft", unencryptedFinding.Severity)
|
|
}
|
|
|
|
if !report.CanProceed() {
|
|
testInstance.Error("ein unverschluesseltes Backup wurde fuer nicht wiederherstellbar erklaert")
|
|
}
|
|
}
|
|
|
|
// TestValidationDetectsMissingParentDirectory prueft ein unerreichbares Ziel.
|
|
func TestValidationDetectsMissingParentDirectory(testInstance *testing.T) {
|
|
fixture := buildBackupFixture(testInstance, true)
|
|
validator := NewValidator(fixture.Repository)
|
|
|
|
report, _ := validator.Validate(context.Background(), ValidationRequest{
|
|
BackupID: fixture.BackupID,
|
|
TargetPath: "/gibt-es-nicht/und-hier-auch-nicht/ziel",
|
|
})
|
|
|
|
if report.CanProceed() {
|
|
testInstance.Fatal("ein Ziel ohne vorhandenes Elternverzeichnis wurde angenommen")
|
|
}
|
|
|
|
if findFinding(report, "TARGET_PARENT_MISSING") == nil {
|
|
testInstance.Errorf("das fehlende Elternverzeichnis wurde nicht benannt: %+v", report.Findings)
|
|
}
|
|
}
|
|
|
|
// TestValidationWritesNothing ist der Grundtest der Vorabpruefung.
|
|
//
|
|
// Sie soll gefahrlos jederzeit laufen koennen — auch als regelmaessiger
|
|
// Nachweis, dass die Backups weiterhin wiederherstellbar sind.
|
|
func TestValidationWritesNothing(testInstance *testing.T) {
|
|
fixture := buildBackupFixture(testInstance, true)
|
|
validator := NewValidator(fixture.Repository)
|
|
|
|
targetPath := testInstance.TempDir()
|
|
|
|
if _, validateError := validator.Validate(context.Background(), ValidationRequest{
|
|
BackupID: fixture.BackupID,
|
|
TargetPath: targetPath,
|
|
DeepChunkCheck: true,
|
|
}); validateError != nil {
|
|
testInstance.Fatalf("die Pruefung schlug fehl: %v", validateError)
|
|
}
|
|
|
|
targetEntries, readError := os.ReadDir(targetPath)
|
|
if readError != nil {
|
|
testInstance.Fatalf("das Ziel liess sich nicht lesen: %v", readError)
|
|
}
|
|
|
|
if len(targetEntries) != 0 {
|
|
testInstance.Fatalf("die Pruefung hinterliess %d Objekte im Ziel", len(targetEntries))
|
|
}
|
|
}
|
|
|
|
// TestValidationReportsFreeSpace prueft die Platzangabe.
|
|
func TestValidationReportsFreeSpace(testInstance *testing.T) {
|
|
fixture := buildBackupFixture(testInstance, true)
|
|
validator := NewValidator(fixture.Repository)
|
|
|
|
report, _ := validator.Validate(context.Background(), ValidationRequest{
|
|
BackupID: fixture.BackupID,
|
|
TargetPath: testInstance.TempDir(),
|
|
})
|
|
|
|
if report.AvailableTargetBytes <= 0 {
|
|
testInstance.Errorf("der freie Platz wurde als %d gemeldet", report.AvailableTargetBytes)
|
|
}
|
|
}
|