syncova-backup/packages/backupengine/restore.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

239 lines
8.8 KiB
Go

package backupengine
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"log/slog"
"time"
"github.com/syncova/syncova/packages/repository"
)
// ErrManifestEntryNotFound meldet ein im Backup nicht enthaltenes Objekt.
var ErrManifestEntryNotFound = errors.New("das objekt ist in diesem backup nicht enthalten")
// ErrRestoreVerificationFailed meldet eine Wiederherstellung, deren Ergebnis
// nicht zu den gesicherten Daten passt.
//
// Das ist der schwerste denkbare Fall: die Daten sind zurückgeschrieben, aber
// nicht die richtigen. Er darf niemals als Erfolg gelten (PROMPT.md §140).
var ErrRestoreVerificationFailed = errors.New("die wiederhergestellten daten stimmen nicht mit dem gesicherten stand überein")
// RestoreResult beschreibt eine abgeschlossene Wiederherstellung.
type RestoreResult struct {
// BackupID ist das verwendete Backup.
BackupID string `json:"backup_id"`
// Path ist das wiederhergestellte Objekt.
Path string `json:"path"`
// BytesRestored ist die Menge zurückgeschriebener Daten.
BytesRestored int64 `json:"bytes_restored"`
// ChunksRead ist die Zahl gelesener Blöcke.
ChunksRead int64 `json:"chunks_read"`
// Verified meldet, ob der Inhaltshash gegengeprüft wurde.
Verified bool `json:"verified"`
// Duration ist die Gesamtdauer.
Duration time.Duration `json:"duration"`
}
// RestoreOptions steuern eine Wiederherstellung.
type RestoreOptions struct {
// BackupID ist das zu verwendende Backup.
BackupID string
// Path benennt das wiederherzustellende Objekt.
Path string
// ProgressCallback meldet den Fortschritt.
ProgressCallback ProgressCallback
}
// Restore schreibt ein gesichertes Objekt in den übergebenen Datenstrom.
//
// Der Rückweg ist die Umkehrung des Backups: entschlüsseln, entpacken, Prüfsumme
// gegen die Kennung halten. Die letzte Prüfung ist die wichtigste — sie belegt,
// dass der zurückgewonnene Block bitgenau dem gesicherten entspricht
// (PROMPT.md §14).
func (engine *Engine) Restore(restoreContext context.Context, restoreOptions RestoreOptions, outputWriter io.Writer) (RestoreResult, error) {
startTime := time.Now()
backupManifest, manifestError := engine.targetRepository.ReadManifest(restoreContext, restoreOptions.BackupID)
if manifestError != nil {
return RestoreResult{}, manifestError
}
manifestEntry, entryFound := findManifestEntry(backupManifest, restoreOptions.Path)
if !entryFound {
return RestoreResult{}, fmt.Errorf("%w: %q", ErrManifestEntryNotFound, restoreOptions.Path)
}
chunkTransformer, transformerError := engine.buildRestoreTransformer(backupManifest)
if transformerError != nil {
return RestoreResult{}, transformerError
}
defer chunkTransformer.Close()
progressReporter := NewProgressReporter(restoreOptions.ProgressCallback, 0)
// Der Inhaltshash wird während des Schreibens gebildet und am Ende gegen
// den im Manifest vermerkten gehalten.
contentDigest := sha256.New()
verifyingWriter := io.MultiWriter(outputWriter, contentDigest)
var bytesRestored int64
var chunksRead int64
for _, chunkReference := range manifestEntry.Chunks {
if contextError := restoreContext.Err(); contextError != nil {
return RestoreResult{}, contextError
}
plaintextChunk, chunkError := engine.restoreSingleChunk(restoreContext, chunkReference, chunkTransformer)
if chunkError != nil {
return RestoreResult{}, chunkError
}
writtenBytes, writeError := verifyingWriter.Write(plaintextChunk)
if writeError != nil {
return RestoreResult{}, fmt.Errorf("die wiederhergestellten daten konnten nicht geschrieben werden: %w", writeError)
}
bytesRestored += int64(writtenBytes)
chunksRead++
progressReporter.recordWrittenChunk(int64(writtenBytes), chunkReference.StoredLength)
}
// Der Gesamtvergleich deckt auch eine falsche Reihenfolge der Blöcke auf,
// die den Einzelprüfungen entginge.
wasVerified := false
if manifestEntry.ContentHash != "" {
actualContentHash := hex.EncodeToString(contentDigest.Sum(nil))
if actualContentHash != manifestEntry.ContentHash {
engine.logger.Error("wiederherstellung fehlgeschlagen: inhaltsprüfsumme weicht ab",
slog.String("backup_id", restoreOptions.BackupID),
slog.String("path", restoreOptions.Path),
slog.String("expected", manifestEntry.ContentHash),
slog.String("actual", actualContentHash))
return RestoreResult{}, fmt.Errorf("%w (objekt %q)", ErrRestoreVerificationFailed, restoreOptions.Path)
}
wasVerified = true
}
progressReporter.ReportFinal()
engine.logger.Info("wiederherstellung abgeschlossen",
slog.String("backup_id", restoreOptions.BackupID),
slog.String("path", restoreOptions.Path),
slog.Int64("bytes", bytesRestored),
slog.Int64("chunks", chunksRead),
slog.Bool("geprueft", wasVerified))
return RestoreResult{
BackupID: restoreOptions.BackupID,
Path: restoreOptions.Path,
BytesRestored: bytesRestored,
ChunksRead: chunksRead,
Verified: wasVerified,
Duration: time.Since(startTime),
}, nil
}
// restoreSingleChunk gewinnt den Klartext eines Blocks zurück.
func (engine *Engine) restoreSingleChunk(restoreContext context.Context, chunkReference repository.ChunkReference, chunkTransformer *ChunkTransformer) ([]byte, error) {
// Die Prüfsumme der abgelegten Form deckt eine Beschädigung auf dem
// Datenträger auf, bevor überhaupt entschlüsselt wird.
storedChunk, readError := engine.targetRepository.ReadStoredChunk(restoreContext,
chunkReference.Identifier, chunkReference.StoredDigest)
if readError != nil {
return nil, readError
}
plaintextChunk, restoreError := chunkTransformer.Restore(storedChunk)
if restoreError != nil {
return nil, fmt.Errorf("der block %s konnte nicht zurückgewonnen werden: %w",
chunkReference.Identifier, restoreError)
}
// Die abschließende Prüfung: der zurückgewonnene Klartext muss exakt die
// Kennung ergeben, unter der er abgelegt wurde. Sie deckt jeden Fehler in
// Entschlüsselung, Dekompression und Ablage auf.
plaintextDigest := sha256.Sum256(plaintextChunk)
actualIdentifier := hex.EncodeToString(plaintextDigest[:])
if actualIdentifier != chunkReference.Identifier {
return nil, fmt.Errorf("%w: der block %s ergab nach der rückgewinnung die kennung %s",
repository.ErrChunkCorrupted, chunkReference.Identifier, actualIdentifier)
}
return plaintextChunk, nil
}
// buildRestoreTransformer baut den Transformer zu einem gesicherten Backup.
//
// Die Angaben stammen ausschließlich aus dem Manifest: Kompressionsverfahren,
// Schlüsselversion und verschlüsselter Datenschlüssel. Damit lässt sich ein
// Backup auch dann wiederherstellen, wenn die ursprünglichen Einstellungen
// längst geändert wurden (PROMPT.md §143).
func (engine *Engine) buildRestoreTransformer(backupManifest *repository.Manifest) (*ChunkTransformer, error) {
transformerOptions := TransformerOptions{}
if backupManifest.CompressionAlgorithm != "" {
if backupManifest.CompressionAlgorithm != compressionAlgorithm {
return nil, fmt.Errorf("das backup wurde mit dem unbekannten verfahren %q komprimiert",
backupManifest.CompressionAlgorithm)
}
// Die Stufe ist beim Entpacken ohne Belang; entscheidend ist nur, dass
// ein Dekompressor eingerichtet wird.
transformerOptions.CompressionLevel = CompressionBalanced
}
_, isEncrypted := backupManifest.Source.Attributes[encryptionAlgorithmAttribute]
if !isEncrypted {
return NewChunkTransformer(transformerOptions)
}
if engine.secretStore == nil {
return nil, fmt.Errorf("%w: das backup ist verschlüsselt", ErrEncryptionUnavailable)
}
// Der Datenschlüssel liegt im Repository und gilt für alle seine Backups.
// Ohne den übergeordneten Schlüssel ist er nicht zu öffnen - und damit das
// gesamte Repository unlesbar (PROMPT.md §142).
dataEncryptionKey, keyError := engine.targetRepository.LoadOrCreateDataKey(context.Background(), engine.secretStore)
if keyError != nil {
return nil, fmt.Errorf("der datenschlüssel des repositorys konnte nicht geöffnet werden "+
"(schlüsselversion %q): %w", backupManifest.EncryptionKeyVersion, keyError)
}
transformerOptions.DataEncryptionKey = dataEncryptionKey
return NewChunkTransformer(transformerOptions)
}
// findManifestEntry sucht ein Objekt im Manifest.
func findManifestEntry(backupManifest *repository.Manifest, objectPath string) (repository.ManifestEntry, bool) {
for _, manifestEntry := range backupManifest.Entries {
if manifestEntry.Path == objectPath {
return manifestEntry, true
}
}
return repository.ManifestEntry{}, false
}
// ListEntries liefert die in einem Backup enthaltenen Objekte.
func (engine *Engine) ListEntries(listContext context.Context, backupID string) ([]repository.ManifestEntry, error) {
backupManifest, manifestError := engine.targetRepository.ReadManifest(listContext, backupID)
if manifestError != nil {
return nil, manifestError
}
return backupManifest.Entries, nil
}