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>
299 lines
10 KiB
Go
299 lines
10 KiB
Go
// Package backupengine implementiert die Backup-Pipeline von Syncova.
|
||
//
|
||
// Der Ablauf folgt SYNCOVA_IMPLEMENTATION_PLAN.md Phase 4:
|
||
//
|
||
// Lesen → Chunking → Hash → Deduplizierung → Kompression → Verschlüsselung
|
||
// → Schreiben → Manifest → Commit
|
||
//
|
||
// Sämtliche Stufen arbeiten als Datenstrom mit begrenzten Puffern. Ein Backup
|
||
// wird niemals vollständig in den Arbeitsspeicher geladen (PROMPT.md §80).
|
||
package backupengine
|
||
|
||
import (
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
)
|
||
|
||
// Chunking-Parameter.
|
||
//
|
||
// Die Werte bestimmen das Verhältnis zwischen Deduplizierungsgüte und Verwaltungsaufwand:
|
||
// kleinere Blöcke finden mehr Übereinstimmungen, erzeugen aber mehr Einträge in
|
||
// Manifest und Chunk-Ablage.
|
||
const (
|
||
// MinimumChunkSize ist die kleinste zulässige Blockgröße.
|
||
//
|
||
// Ohne Untergrenze könnte eine ungünstige Datenfolge tausende Kleinstblöcke
|
||
// erzeugen, deren Verwaltungsaufwand den Nutzen aufzehrt.
|
||
MinimumChunkSize = 256 * 1024
|
||
// TargetChunkSize ist die angestrebte durchschnittliche Blockgröße.
|
||
TargetChunkSize = 1024 * 1024
|
||
// MaximumChunkSize ist die größte zulässige Blockgröße.
|
||
//
|
||
// Die Obergrenze begrenzt zugleich den Speicherbedarf je Arbeiter: mehr als
|
||
// diese Menge liegt nie gleichzeitig für einen Block im Speicher.
|
||
MaximumChunkSize = 4 * 1024 * 1024
|
||
)
|
||
|
||
// chunkBoundaryMask bestimmt, wie häufig eine Blockgrenze entsteht.
|
||
//
|
||
// Der Rolling Hash liefert einen gleichverteilten Wert; eine Grenze entsteht,
|
||
// wenn seine unteren Bits null sind. Bei 20 gesetzten Bits ergibt das im Mittel
|
||
// alle 2^20 Byte (1 MiB) eine Grenze — die angestrebte Zielgröße.
|
||
const chunkBoundaryMask uint64 = (1 << 20) - 1
|
||
|
||
// gearTable ist die Substitutionstabelle des Rolling Hash.
|
||
//
|
||
// Das Verfahren (Gear Hashing) ordnet jedem Bytewert einen Zufallswert zu und
|
||
// führt den Hash über ein Schieberegister fort. Es ist deutlich schneller als
|
||
// ein Rabin-Fingerprint und für die Blockfindung gleichwertig — es dient allein
|
||
// der Grenzbestimmung, nicht der Sicherheit. Die Chunk-Kennung entsteht
|
||
// weiterhin aus SHA-256 über den Blockinhalt.
|
||
var gearTable = buildGearTable()
|
||
|
||
// buildGearTable erzeugt die Substitutionstabelle deterministisch.
|
||
//
|
||
// Die Tabelle muss über alle Installationen hinweg identisch sein: andernfalls
|
||
// fänden zwei Systeme unterschiedliche Blockgrenzen und könnten ihre Backups
|
||
// nicht gegenseitig deduplizieren.
|
||
func buildGearTable() [256]uint64 {
|
||
var substitutionTable [256]uint64
|
||
|
||
// Ein einfacher, festgelegter Generator (splitmix64) liefert reproduzierbare
|
||
// Werte ohne Abhängigkeit von einer Zufallsquelle.
|
||
var generatorState uint64 = 0x9E3779B97F4A7C15
|
||
|
||
for tableIndex := range substitutionTable {
|
||
generatorState += 0x9E3779B97F4A7C15
|
||
|
||
mixedValue := generatorState
|
||
mixedValue = (mixedValue ^ (mixedValue >> 30)) * 0xBF58476D1CE4E5B9
|
||
mixedValue = (mixedValue ^ (mixedValue >> 27)) * 0x94D049BB133111EB
|
||
mixedValue = mixedValue ^ (mixedValue >> 31)
|
||
|
||
substitutionTable[tableIndex] = mixedValue
|
||
}
|
||
|
||
return substitutionTable
|
||
}
|
||
|
||
// Chunk ist ein von der Quelle gelesener Datenblock.
|
||
type Chunk struct {
|
||
// Sequence ist die laufende Nummer des Blocks im Datenstrom.
|
||
//
|
||
// Sie erhält die Reihenfolge, obwohl die Blöcke parallel verarbeitet werden.
|
||
Sequence int64
|
||
// Offset ist die Position des Blocks im ursprünglichen Datenstrom.
|
||
Offset int64
|
||
// Data ist der Blockinhalt.
|
||
Data []byte
|
||
}
|
||
|
||
// Chunker zerlegt einen Datenstrom in inhaltsabhängige Blöcke.
|
||
//
|
||
// Die Blockgrenzen ergeben sich aus dem Inhalt, nicht aus festen Abständen.
|
||
// Das ist der entscheidende Unterschied: wird mitten in einer Datei etwas
|
||
// eingefügt, verschiebt eine feste Aufteilung alle folgenden Blöcke und macht
|
||
// die Deduplizierung wirkungslos. Inhaltsabhängige Grenzen wandern mit dem
|
||
// Inhalt mit, sodass nur die tatsächlich geänderten Blöcke neu sind
|
||
// (PROMPT.md §9, §10).
|
||
type Chunker struct {
|
||
// sourceReader ist die Datenquelle.
|
||
sourceReader io.Reader
|
||
// readBuffer nimmt die von der Quelle gelesenen Daten auf.
|
||
//
|
||
// Er ist genau so groß wie ein größtmöglicher Block: mehr muss nie
|
||
// gleichzeitig im Speicher liegen.
|
||
readBuffer []byte
|
||
// bufferedLength ist die Menge gültiger Daten im Puffer.
|
||
bufferedLength int
|
||
// currentOffset ist die Position im Datenstrom.
|
||
currentOffset int64
|
||
// sequenceNumber ist die laufende Nummer des nächsten Blocks.
|
||
sequenceNumber int64
|
||
// reachedEndOfStream meldet, ob die Quelle erschöpft ist.
|
||
reachedEndOfStream bool
|
||
// pendingChunkLength ist die Länge des zuletzt gelieferten Blocks.
|
||
//
|
||
// Er bleibt im Puffer stehen, bis der nächste Aufruf ihn räumt.
|
||
pendingChunkLength int
|
||
// minimumSize ist die kleinste Blockgröße.
|
||
minimumSize int
|
||
// maximumSize ist die größte Blockgröße.
|
||
maximumSize int
|
||
// boundaryMask bestimmt die durchschnittliche Blockgröße.
|
||
boundaryMask uint64
|
||
}
|
||
|
||
// ChunkerOptions steuern die Blockfindung.
|
||
type ChunkerOptions struct {
|
||
// MinimumSize ist die kleinste Blockgröße; 0 verwendet den Standardwert.
|
||
MinimumSize int
|
||
// MaximumSize ist die größte Blockgröße; 0 verwendet den Standardwert.
|
||
MaximumSize int
|
||
// BoundaryMask bestimmt die durchschnittliche Blockgröße; 0 verwendet den Standardwert.
|
||
BoundaryMask uint64
|
||
// ExpectedSize ist die bekannte Größe der Quelle; 0 bedeutet unbekannt.
|
||
//
|
||
// Sie bestimmt allein die Größe des Lesepuffers, nicht die Blockfindung.
|
||
// Ohne diese Angabe legt der Chunker den Puffer stets in Höchstblockgröße
|
||
// an — vier Megabyte, auch für eine Datei von sechzehn Kilobyte.
|
||
//
|
||
// Bei vielen kleinen Dateien ist das der beherrschende Aufwand: In der
|
||
// Messung der Phase 20 forderte ein Lauf über 4000 Dateien mit zusammen
|
||
// 62,5 MiB ganze 16 GiB Speicher an — genau 4000 × 4 MiB — und erreichte
|
||
// 104 Dateien je Sekunde bei 1447 Speicherbereinigungen.
|
||
ExpectedSize int64
|
||
}
|
||
|
||
// NewChunker erzeugt einen Chunker über einer Datenquelle.
|
||
func NewChunker(sourceReader io.Reader, chunkerOptions ChunkerOptions) *Chunker {
|
||
minimumSize := chunkerOptions.MinimumSize
|
||
if minimumSize <= 0 {
|
||
minimumSize = MinimumChunkSize
|
||
}
|
||
|
||
maximumSize := chunkerOptions.MaximumSize
|
||
if maximumSize <= 0 {
|
||
maximumSize = MaximumChunkSize
|
||
}
|
||
|
||
// Eine Höchstgröße unterhalb der Mindestgröße wäre widersprüchlich.
|
||
if maximumSize < minimumSize {
|
||
maximumSize = minimumSize
|
||
}
|
||
|
||
boundaryMask := chunkerOptions.BoundaryMask
|
||
if boundaryMask == 0 {
|
||
boundaryMask = chunkBoundaryMask
|
||
}
|
||
|
||
return &Chunker{
|
||
sourceReader: sourceReader,
|
||
readBuffer: make([]byte, resolveBufferSize(maximumSize, chunkerOptions.ExpectedSize)),
|
||
minimumSize: minimumSize,
|
||
maximumSize: maximumSize,
|
||
boundaryMask: boundaryMask,
|
||
}
|
||
}
|
||
|
||
// Next liefert den nächsten Block.
|
||
//
|
||
// Am Ende des Datenstroms wird io.EOF geliefert. Der zurückgegebene Block
|
||
// verweist auf einen wiederverwendeten Puffer und bleibt genau bis zum nächsten
|
||
// Next-Aufruf gültig; der Aufrufer muss ihn vorher kopieren.
|
||
func (chunker *Chunker) Next() (Chunk, error) {
|
||
// Der zuvor gelieferte Block wird erst jetzt aus dem Puffer geräumt.
|
||
//
|
||
// Würde das bereits am Ende des vorigen Aufrufs geschehen, überschriebe das
|
||
// Nachrücken der Restdaten genau den Bereich, auf den der ausgelieferte
|
||
// Block noch zeigt — der Aufrufer erhielte stillschweigend verfälschte Daten.
|
||
chunker.consumePendingChunk()
|
||
|
||
// Der Puffer wird aufgefüllt, solange die Quelle liefert.
|
||
if fillError := chunker.fillBuffer(); fillError != nil {
|
||
return Chunk{}, fillError
|
||
}
|
||
|
||
if chunker.bufferedLength == 0 {
|
||
return Chunk{}, io.EOF
|
||
}
|
||
|
||
boundaryPosition := chunker.findBoundary()
|
||
|
||
producedChunk := Chunk{
|
||
Sequence: chunker.sequenceNumber,
|
||
Offset: chunker.currentOffset,
|
||
Data: chunker.readBuffer[:boundaryPosition],
|
||
}
|
||
|
||
// Das Nachrücken wird bis zum nächsten Aufruf zurückgestellt.
|
||
chunker.pendingChunkLength = boundaryPosition
|
||
|
||
chunker.currentOffset += int64(boundaryPosition)
|
||
chunker.sequenceNumber++
|
||
|
||
return producedChunk, nil
|
||
}
|
||
|
||
// consumePendingChunk räumt den zuletzt gelieferten Block aus dem Puffer.
|
||
func (chunker *Chunker) consumePendingChunk() {
|
||
if chunker.pendingChunkLength == 0 {
|
||
return
|
||
}
|
||
|
||
remainingLength := chunker.bufferedLength - chunker.pendingChunkLength
|
||
copy(chunker.readBuffer, chunker.readBuffer[chunker.pendingChunkLength:chunker.bufferedLength])
|
||
|
||
chunker.bufferedLength = remainingLength
|
||
chunker.pendingChunkLength = 0
|
||
}
|
||
|
||
// fillBuffer liest von der Quelle nach, bis der Puffer voll oder die Quelle erschöpft ist.
|
||
func (chunker *Chunker) fillBuffer() error {
|
||
for !chunker.reachedEndOfStream && chunker.bufferedLength < len(chunker.readBuffer) {
|
||
bytesRead, readError := chunker.sourceReader.Read(chunker.readBuffer[chunker.bufferedLength:])
|
||
chunker.bufferedLength += bytesRead
|
||
|
||
if readError != nil {
|
||
if errors.Is(readError, io.EOF) {
|
||
chunker.reachedEndOfStream = true
|
||
break
|
||
}
|
||
|
||
return fmt.Errorf("die quelle konnte nicht gelesen werden: %w", readError)
|
||
}
|
||
|
||
// Ein Reader darf 0 Byte ohne Fehler liefern; ein erneuter Versuch ist zulässig.
|
||
if bytesRead == 0 {
|
||
continue
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// findBoundary bestimmt das Ende des nächsten Blocks.
|
||
func (chunker *Chunker) findBoundary() int {
|
||
// Reicht der Vorrat nicht für einen Mindestblock, bildet der Rest den Block.
|
||
if chunker.bufferedLength <= chunker.minimumSize {
|
||
return chunker.bufferedLength
|
||
}
|
||
|
||
searchLimit := min(chunker.bufferedLength, chunker.maximumSize)
|
||
|
||
// Die Suche beginnt erst nach der Mindestgröße: davor wird bewusst keine
|
||
// Grenze gesetzt, damit keine Kleinstblöcke entstehen.
|
||
var rollingHash uint64
|
||
|
||
for scanPosition := chunker.minimumSize; scanPosition < searchLimit; scanPosition++ {
|
||
// Gear Hashing: Schieberegister plus Substitutionswert des Bytes.
|
||
rollingHash = (rollingHash << 1) + gearTable[chunker.readBuffer[scanPosition]]
|
||
|
||
if rollingHash&chunker.boundaryMask == 0 {
|
||
return scanPosition + 1
|
||
}
|
||
}
|
||
|
||
// Ohne gefundene Grenze greift die Höchstgröße. Ohne sie könnte eine
|
||
// gleichförmige Datenfolge einen unbegrenzt großen Block erzeugen.
|
||
return searchLimit
|
||
}
|
||
|
||
// resolveBufferSize bestimmt die Größe des Lesepuffers.
|
||
//
|
||
// Ist die Quellgröße bekannt und kleiner als die Höchstblockgröße, genügt ein
|
||
// Puffer in Quellgröße: Mehr kann ohnehin nicht gelesen werden.
|
||
//
|
||
// Wächst die Quelle wider Erwarten während des Lesens, entstehen kleinere
|
||
// Blöcke als möglich — die Sicherung bleibt richtig, nur dedupliziert sie
|
||
// etwas schlechter. Das ist der Preis dafür, nicht für jede kleine Datei vier
|
||
// Megabyte anzufordern.
|
||
func resolveBufferSize(maximumSize int, expectedSize int64) int {
|
||
if expectedSize <= 0 || expectedSize >= int64(maximumSize) {
|
||
return maximumSize
|
||
}
|
||
|
||
return int(expectedSize)
|
||
}
|