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>
239 lines
8.8 KiB
Go
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
|
|
}
|