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>
434 lines
17 KiB
Go
434 lines
17 KiB
Go
package backupengine
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"github.com/syncova/syncova/packages/platform/crypto"
|
|
"github.com/syncova/syncova/packages/platform/logging"
|
|
"github.com/syncova/syncova/packages/platform/ratelimit"
|
|
"github.com/syncova/syncova/packages/repository"
|
|
)
|
|
|
|
// Engine führt Backups und Wiederherstellungen aus.
|
|
type Engine struct {
|
|
// targetRepository ist das Ziel-Repository.
|
|
targetRepository *repository.LocalRepository
|
|
// secretStore verschlüsselt die Datenschlüssel der Backups.
|
|
secretStore crypto.SecretStore
|
|
// logger protokolliert den Verlauf.
|
|
logger *slog.Logger
|
|
}
|
|
|
|
// NewEngine erzeugt eine Backup Engine.
|
|
//
|
|
// Der Secret Store darf nil sein; dann sind ausschließlich unverschlüsselte
|
|
// Backups möglich. Das ist nur für Testzwecke gedacht: PROMPT.md §119 verlangt
|
|
// Verschlüsselung als Standard.
|
|
func NewEngine(targetRepository *repository.LocalRepository, secretStore crypto.SecretStore, baseLogger *slog.Logger) *Engine {
|
|
return &Engine{
|
|
targetRepository: targetRepository,
|
|
secretStore: secretStore,
|
|
logger: logging.WithComponent(baseLogger, "backup-engine"),
|
|
}
|
|
}
|
|
|
|
// Repository liefert das Ziel-Repository.
|
|
//
|
|
// Der Zugriff wird gebraucht, um vor einer Zusatzsicherung das Manifest des
|
|
// Elternbackups zu lesen. Die Engine selbst trifft diese Entscheidung nicht —
|
|
// welches Backup das Elternbackup ist, hängt von der Quelle ab und damit von
|
|
// einer Schicht über ihr.
|
|
func (engine *Engine) Repository() *repository.LocalRepository {
|
|
return engine.targetRepository
|
|
}
|
|
|
|
// BackupOptions steuern einen Backup-Lauf.
|
|
type BackupOptions struct {
|
|
// BackupID ist die Kennung des zu erzeugenden Backups.
|
|
BackupID string
|
|
// ChainID verbindet das Backup mit seiner Kette.
|
|
ChainID string
|
|
// ParentBackupID benennt das Elternbackup einer Zusatzsicherung.
|
|
ParentBackupID string
|
|
// BackupType ist die Art des Backups.
|
|
BackupType repository.BackupType
|
|
// Source beschreibt die Herkunft der Daten.
|
|
Source repository.SourceInformation
|
|
// CompressionLevel ist die gewünschte Kompressionsstufe.
|
|
CompressionLevel CompressionLevel
|
|
// EncryptionEnabled schaltet die Verschlüsselung ein.
|
|
//
|
|
// Sie ist der Standard; ein Abschalten muss ausdrücklich geschehen
|
|
// (PROMPT.md §12, §120).
|
|
EncryptionEnabled bool
|
|
// WorkerCount ist die Zahl paralleler Arbeiter; 0 wählt einen sinnvollen Wert.
|
|
WorkerCount int
|
|
// QueueDepth ist die Tiefe der Warteschlangen; 0 wählt den Standardwert.
|
|
QueueDepth int
|
|
// ChunkerOptions steuern die Blockfindung.
|
|
ChunkerOptions ChunkerOptions
|
|
// ProgressCallback meldet den Fortschritt.
|
|
ProgressCallback ProgressCallback
|
|
// CreatedByVersion ist die erzeugende Programmversion.
|
|
CreatedByVersion string
|
|
// BandwidthLimiter begrenzt den Lesedurchsatz von den Quellen.
|
|
//
|
|
// Er gilt für den **gesamten** Lauf und wird über alle Quellen geteilt.
|
|
// Je Quelle einen eigenen zu führen ergäbe bei einem Auftrag mit drei
|
|
// Verzeichnissen das Dreifache der vereinbarten Rate.
|
|
//
|
|
// Begrenzt wird das Lesen, nicht das Schreiben. Weil die Pipeline mit
|
|
// Gegendruck arbeitet, bremst das den gesamten Ablauf: Was nicht gelesen
|
|
// wird, wird auch nicht gehasht, komprimiert, verschlüsselt und abgelegt.
|
|
// Ein Begrenzer an dieser einen Stelle bindet damit die gesamte Last.
|
|
//
|
|
// Ein nil-Begrenzer bedeutet unbegrenzt.
|
|
BandwidthLimiter *ratelimit.Limiter
|
|
}
|
|
|
|
// BackupSource beschreibt ein zu sicherndes Objekt.
|
|
type BackupSource struct {
|
|
// Path ist der Pfad des Objekts in der Quelle.
|
|
Path string
|
|
// EntryType benennt die Art des Objekts.
|
|
EntryType string
|
|
// SizeBytes ist die erwartete Größe; 0 bedeutet unbekannt.
|
|
SizeBytes int64
|
|
// ModifiedAt ist der Änderungszeitpunkt.
|
|
ModifiedAt time.Time
|
|
// Mode sind die Dateirechte in oktaler Schreibweise.
|
|
//
|
|
// Ohne sie wäre eine Wiederherstellung unvollständig: eine zurückgespielte
|
|
// Datei trüge die Standardrechte statt ihrer ursprünglichen.
|
|
Mode string
|
|
// LinkTarget ist das Ziel eines symbolischen Verweises.
|
|
LinkTarget string
|
|
// Reader liefert den Inhalt.
|
|
//
|
|
// Er bleibt nil bei Objekten ohne Daten — Verzeichnisse und symbolische
|
|
// Verweise tragen nur Metadaten.
|
|
Reader io.Reader
|
|
// ReusedChunks übernimmt die Blockverweise eines früheren Backups.
|
|
//
|
|
// Damit sichert eine Zusatzsicherung ein unverändertes Objekt, ohne es zu
|
|
// lesen. Die Ersparnis liegt in Lese- und Rechenzeit: der Speicherplatz
|
|
// wäre ohnehin durch die Deduplizierung eingespart worden.
|
|
//
|
|
// Ob ein Objekt unverändert ist, entscheidet der Aufrufer. Die Engine
|
|
// kennt keine Dateisysteme und darf diese Frage nicht beantworten — sie
|
|
// prüft aber, dass jeder übernommene Block wirklich im Repository liegt.
|
|
ReusedChunks []repository.ChunkReference
|
|
// ContentHash ist die Inhaltsprüfsumme eines übernommenen Objekts.
|
|
ContentHash string
|
|
}
|
|
|
|
// BackupResult beschreibt einen abgeschlossenen Backup-Lauf.
|
|
type BackupResult struct {
|
|
// BackupID ist die Kennung des erzeugten Backups.
|
|
BackupID string `json:"backup_id"`
|
|
// Progress sind die Endkennzahlen.
|
|
Progress Progress `json:"progress"`
|
|
// EntryCount ist die Zahl gesicherter Objekte.
|
|
EntryCount int `json:"entry_count"`
|
|
// Encrypted meldet, ob verschlüsselt wurde.
|
|
Encrypted bool `json:"encrypted"`
|
|
// CompressionAlgorithm benennt das verwendete Kompressionsverfahren.
|
|
CompressionAlgorithm string `json:"compression_algorithm,omitempty"`
|
|
// Duration ist die Gesamtdauer.
|
|
Duration time.Duration `json:"duration"`
|
|
}
|
|
|
|
// ErrEncryptionUnavailable meldet eine angeforderte, aber nicht mögliche Verschlüsselung.
|
|
var ErrEncryptionUnavailable = errors.New("die verschlüsselung wurde angefordert, es ist aber kein schlüsselspeicher eingerichtet")
|
|
|
|
// Backup sichert die übergebenen Quellen in das Repository.
|
|
//
|
|
// Der Ablauf entspricht Phase 4 des Implementierungsplans: Lesen, Chunking,
|
|
// Hash, Deduplizierung, Kompression, Verschlüsselung, Schreiben, Manifest,
|
|
// Commit. Kein Schritt hält mehr als wenige Blöcke gleichzeitig im Speicher.
|
|
func (engine *Engine) Backup(backupContext context.Context, backupOptions BackupOptions, backupSources []BackupSource) (BackupResult, error) {
|
|
startTime := time.Now()
|
|
|
|
if len(backupSources) == 0 {
|
|
// Ein Backup ohne Quellen wäre ein leeres Gebilde, das sich als
|
|
// erfolgreiche Sicherung ausgäbe (PROMPT.md §138).
|
|
return BackupResult{}, errors.New("es wurde keine quelle zum sichern angegeben")
|
|
}
|
|
|
|
// Der Datenschlüssel gehört zum Repository, nicht zum einzelnen Backup.
|
|
//
|
|
// Ein Schlüssel je Backup machte jede Deduplizierung über Backupgrenzen
|
|
// hinweg unmöglich: ein späteres Backup verwiese auf Blöcke, die mit einem
|
|
// fremden Schlüssel verschlüsselt und damit für es unlesbar wären.
|
|
var dataEncryptionKey []byte
|
|
var keyVersion string
|
|
|
|
if backupOptions.EncryptionEnabled {
|
|
if engine.secretStore == nil {
|
|
return BackupResult{}, ErrEncryptionUnavailable
|
|
}
|
|
|
|
repositoryKey, keyError := engine.targetRepository.LoadOrCreateDataKey(backupContext, engine.secretStore)
|
|
if keyError != nil {
|
|
return BackupResult{}, keyError
|
|
}
|
|
|
|
storedKeyVersion, versionError := engine.targetRepository.DataKeyVersion()
|
|
if versionError != nil {
|
|
return BackupResult{}, fmt.Errorf("die schlüsselversion konnte nicht ermittelt werden: %w", versionError)
|
|
}
|
|
|
|
dataEncryptionKey = repositoryKey
|
|
keyVersion = storedKeyVersion
|
|
}
|
|
|
|
chunkTransformer, transformerError := NewChunkTransformer(TransformerOptions{
|
|
CompressionLevel: backupOptions.CompressionLevel,
|
|
DataEncryptionKey: dataEncryptionKey,
|
|
})
|
|
if transformerError != nil {
|
|
return BackupResult{}, transformerError
|
|
}
|
|
defer chunkTransformer.Close()
|
|
|
|
workerCount, workerError := validateWorkerCount(backupOptions.WorkerCount)
|
|
if workerError != nil {
|
|
return BackupResult{}, workerError
|
|
}
|
|
|
|
queueDepth := backupOptions.QueueDepth
|
|
if queueDepth <= 0 {
|
|
queueDepth = defaultQueueDepth
|
|
}
|
|
|
|
progressReporter := NewProgressReporter(backupOptions.ProgressCallback, 0)
|
|
|
|
// Die Schreibsession entsteht **vor** der Pipeline: Diese schreibt ueber
|
|
// sie, damit die Kennzahlen des Laufs mitgezaehlt werden und ins Manifest
|
|
// gelangen.
|
|
backupWriter, beginError := engine.targetRepository.BeginBackup(backupContext, backupOptions.BackupID)
|
|
if beginError != nil {
|
|
return BackupResult{}, beginError
|
|
}
|
|
|
|
processingPipeline := &pipeline{
|
|
transformer: chunkTransformer,
|
|
sink: &repositorySink{
|
|
backupWriter: backupWriter,
|
|
targetRepository: engine.targetRepository,
|
|
},
|
|
workerCount: workerCount,
|
|
queueDepth: queueDepth,
|
|
progressReporter: progressReporter,
|
|
}
|
|
|
|
engine.logger.Info("backup gestartet",
|
|
slog.String("backup_id", backupOptions.BackupID),
|
|
slog.Int("quellen", len(backupSources)),
|
|
slog.Int("arbeiter", workerCount),
|
|
slog.String("kompression", string(backupOptions.CompressionLevel)),
|
|
slog.Bool("verschluesselt", backupOptions.EncryptionEnabled),
|
|
slog.String("bandbreite", ratelimit.FormatBandwidthLimit(backupOptions.BandwidthLimiter.BytesPerSecond())))
|
|
|
|
manifestEntries := make([]repository.ManifestEntry, 0, len(backupSources))
|
|
|
|
for _, backupSource := range backupSources {
|
|
if contextError := backupContext.Err(); contextError != nil {
|
|
// Ein Abbruch lässt kein sichtbares Backup zurück.
|
|
_ = backupWriter.Abort(backupContext)
|
|
return BackupResult{}, contextError
|
|
}
|
|
|
|
// Ein unverändertes Objekt wird nicht erneut gelesen; seine
|
|
// Blockverweise stammen aus dem Elternbackup.
|
|
//
|
|
// Das neue Manifest bleibt dadurch vollständig: es beschreibt den
|
|
// gesamten Bestand, nicht nur die Änderungen. Eine Wiederherstellung
|
|
// braucht deshalb nie die Kette — sie liest ein einziges Manifest.
|
|
// Das ist der Grund, warum das Löschen eines alten Backups ein
|
|
// neueres nicht beschädigen kann.
|
|
if len(backupSource.ReusedChunks) > 0 {
|
|
manifestEntry, reuseError := engine.reuseChunks(backupContext, backupSource, progressReporter)
|
|
if reuseError != nil {
|
|
_ = backupWriter.Abort(backupContext)
|
|
return BackupResult{}, reuseError
|
|
}
|
|
|
|
manifestEntries = append(manifestEntries, manifestEntry)
|
|
|
|
continue
|
|
}
|
|
|
|
// Objekte ohne Datenstrom durchlaufen die Pipeline nicht: ein
|
|
// Verzeichnis oder ein symbolischer Verweis trägt nur Metadaten.
|
|
// Sie gehören dennoch ins Manifest, sonst ginge bei der
|
|
// Wiederherstellung die Struktur samt Rechten verloren.
|
|
if backupSource.Reader == nil {
|
|
manifestEntries = append(manifestEntries, repository.ManifestEntry{
|
|
Path: backupSource.Path,
|
|
EntryType: backupSource.EntryType,
|
|
ModifiedAt: backupSource.ModifiedAt,
|
|
Mode: backupSource.Mode,
|
|
LinkTarget: backupSource.LinkTarget,
|
|
})
|
|
|
|
continue
|
|
}
|
|
|
|
// Die Bandbreitengrenze wirkt vor allem anderen: Der Begrenzer umhüllt
|
|
// den Quelldatenstrom, bevor Prüfsumme und Chunking daran arbeiten.
|
|
// Ohne Grenze wird der Datenstrom unverändert durchgereicht — eine
|
|
// Hülle, die nichts tut, kostet bei jedem Block einen Aufruf mehr.
|
|
limitedSourceReader := ratelimit.NewLimitedReader(backupContext,
|
|
backupSource.Reader, backupOptions.BandwidthLimiter)
|
|
|
|
// Der Inhaltshash entsteht nebenher, damit sich die Wiederherstellung
|
|
// eines einzelnen Objekts prüfen lässt, ohne alle Blöcke einzeln
|
|
// nachzurechnen.
|
|
contentDigest := sha256.New()
|
|
countingReader := io.TeeReader(limitedSourceReader, contentDigest)
|
|
|
|
// Die bekannte Quellgroesse geht mit: Ohne sie legt der Chunker den
|
|
// Lesepuffer in Hoechstblockgroesse an — vier Megabyte, auch fuer eine
|
|
// Datei von sechzehn Kilobyte.
|
|
sourceChunkerOptions := backupOptions.ChunkerOptions
|
|
sourceChunkerOptions.ExpectedSize = backupSource.SizeBytes
|
|
|
|
chunkReferences, processError := processingPipeline.process(backupContext, countingReader, sourceChunkerOptions)
|
|
if processError != nil {
|
|
_ = backupWriter.Abort(backupContext)
|
|
return BackupResult{}, fmt.Errorf("die quelle %q konnte nicht gesichert werden: %w", backupSource.Path, processError)
|
|
}
|
|
|
|
var totalLogicalBytes int64
|
|
for _, chunkReference := range chunkReferences {
|
|
totalLogicalBytes += chunkReference.LogicalLength
|
|
}
|
|
|
|
manifestEntries = append(manifestEntries, repository.ManifestEntry{
|
|
Path: backupSource.Path,
|
|
EntryType: backupSource.EntryType,
|
|
SizeBytes: totalLogicalBytes,
|
|
ModifiedAt: backupSource.ModifiedAt,
|
|
Mode: backupSource.Mode,
|
|
Chunks: chunkReferences,
|
|
ContentHash: hex.EncodeToString(contentDigest.Sum(nil)),
|
|
})
|
|
}
|
|
|
|
compressionName, encryptionName := chunkTransformer.AlgorithmNames()
|
|
|
|
backupManifest := &repository.Manifest{
|
|
ChainID: backupOptions.ChainID,
|
|
ParentBackupID: backupOptions.ParentBackupID,
|
|
BackupType: backupOptions.BackupType,
|
|
Source: backupOptions.Source,
|
|
Entries: manifestEntries,
|
|
CompressionAlgorithm: compressionName,
|
|
EncryptionKeyVersion: keyVersion,
|
|
CreatedByVersion: backupOptions.CreatedByVersion,
|
|
}
|
|
|
|
// Das Manifest vermerkt lediglich, dass verschlüsselt wurde. Der Schlüssel
|
|
// selbst liegt im Repository - er gilt für alle seine Backups.
|
|
if encryptionName != "" {
|
|
backupManifest.Source.Attributes = mergeAttributes(backupManifest.Source.Attributes, map[string]string{
|
|
encryptionAlgorithmAttribute: encryptionName,
|
|
})
|
|
}
|
|
|
|
if commitError := backupWriter.Commit(backupContext, backupManifest); commitError != nil {
|
|
return BackupResult{}, commitError
|
|
}
|
|
|
|
finalProgress := progressReporter.ReportFinal()
|
|
|
|
engine.logger.Info("backup abgeschlossen",
|
|
slog.String("backup_id", backupOptions.BackupID),
|
|
slog.Int64("bytes_verarbeitet", finalProgress.BytesProcessed),
|
|
slog.Int64("bytes_abgelegt", finalProgress.BytesWritten),
|
|
slog.Int64("chunks_neu", finalProgress.ChunksWritten),
|
|
slog.Int64("chunks_dedupliziert", finalProgress.ChunksDeduplicated),
|
|
slog.String("dauer", time.Since(startTime).String()))
|
|
|
|
return BackupResult{
|
|
BackupID: backupOptions.BackupID,
|
|
Progress: finalProgress,
|
|
EntryCount: len(manifestEntries),
|
|
Encrypted: backupOptions.EncryptionEnabled,
|
|
CompressionAlgorithm: compressionName,
|
|
Duration: time.Since(startTime),
|
|
}, nil
|
|
}
|
|
|
|
// ErrReusedChunkMissing meldet einen übernommenen Block, der im Repository fehlt.
|
|
var ErrReusedChunkMissing = errors.New("ein aus dem elternbackup übernommener block fehlt im repository")
|
|
|
|
// reuseChunks übernimmt die Blockverweise eines unveränderten Objekts.
|
|
//
|
|
// Vor der Übernahme wird die Existenz jedes Blocks geprüft. Ohne diese Prüfung
|
|
// entstünde ein Manifest, das sich als vollständiges Backup ausgibt, während
|
|
// seine Daten fehlen — genau die Art von stillem Fehler, die PROMPT.md §138
|
|
// verbietet. Geprüft wird nur die Existenz, nicht der Inhalt: den Inhalt prüft
|
|
// der Integritätslauf, und ihn hier zu lesen hübe den Zweck der Zusatzsicherung
|
|
// auf.
|
|
func (engine *Engine) reuseChunks(reuseContext context.Context, backupSource BackupSource, progressReporter *ProgressReporter) (repository.ManifestEntry, error) {
|
|
var totalLogicalBytes int64
|
|
|
|
for _, chunkReference := range backupSource.ReusedChunks {
|
|
chunkExists, existenceError := engine.targetRepository.HasChunk(reuseContext, chunkReference.Identifier)
|
|
if existenceError != nil {
|
|
return repository.ManifestEntry{}, fmt.Errorf("der block %s des objekts %q konnte nicht geprüft werden: %w",
|
|
chunkReference.Identifier, backupSource.Path, existenceError)
|
|
}
|
|
|
|
if !chunkExists {
|
|
return repository.ManifestEntry{}, fmt.Errorf("%w (objekt %q, block %s)",
|
|
ErrReusedChunkMissing, backupSource.Path, chunkReference.Identifier)
|
|
}
|
|
|
|
totalLogicalBytes += chunkReference.LogicalLength
|
|
|
|
progressReporter.recordReusedChunk(chunkReference.LogicalLength)
|
|
}
|
|
|
|
return repository.ManifestEntry{
|
|
Path: backupSource.Path,
|
|
EntryType: backupSource.EntryType,
|
|
SizeBytes: totalLogicalBytes,
|
|
ModifiedAt: backupSource.ModifiedAt,
|
|
Mode: backupSource.Mode,
|
|
LinkTarget: backupSource.LinkTarget,
|
|
Chunks: backupSource.ReusedChunks,
|
|
ContentHash: backupSource.ContentHash,
|
|
}, nil
|
|
}
|
|
|
|
// encryptionAlgorithmAttribute benennt das Verschlüsselungsverfahren im Manifest.
|
|
//
|
|
// Der Datenschlüssel selbst steht nicht hier, sondern im Repository: er gilt
|
|
// für alle seine Backups und ermöglicht damit die Deduplizierung.
|
|
const encryptionAlgorithmAttribute = "syncova.encryption_algorithm"
|
|
|
|
// mergeAttributes fügt Attribute zusammen, ohne die Vorlage zu verändern.
|
|
func mergeAttributes(existingAttributes map[string]string, additionalAttributes map[string]string) map[string]string {
|
|
mergedAttributes := make(map[string]string, len(existingAttributes)+len(additionalAttributes))
|
|
|
|
for attributeName, attributeValue := range existingAttributes {
|
|
mergedAttributes[attributeName] = attributeValue
|
|
}
|
|
|
|
for attributeName, attributeValue := range additionalAttributes {
|
|
mergedAttributes[attributeName] = attributeValue
|
|
}
|
|
|
|
return mergedAttributes
|
|
}
|