syncova-backup/packages/repository/portable.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

360 lines
13 KiB
Go

package repository
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"os"
"github.com/syncova/syncova/packages/backupformat"
)
// ExportBackup schreibt ein Backup als portablen Container.
//
// Der Container ist in sich geschlossen: er enthält Manifest, Chunk-Verzeichnis
// und sämtliche Datenblöcke. Ein Empfänger benötigt weder dieses Repository
// noch eine Datenbank, um ihn zu lesen (PROMPT.md §17: Backup Copy).
func (localRepository *LocalRepository) ExportBackup(exportContext context.Context, backupID string, outputWriter io.Writer, createdByVersion string) (exportedBytes int64, exportError error) {
manifest, manifestError := localRepository.ReadManifest(exportContext, backupID)
if manifestError != nil {
return 0, manifestError
}
// Die Chunks werden in einer festen Reihenfolge abgelegt, damit ihre
// Positionen im Verzeichnis stimmen. Die Reihenfolge des Manifests ist die
// natürliche: sie entspricht dem Lesefluss einer Wiederherstellung.
orderedChunkIdentifiers := collectOrderedChunkIdentifiers(manifest)
// Vor dem Schreiben wird geprüft, ob alle Chunks vorliegen. Ein Container,
// dem mitten im Schreiben ein Block fehlt, wäre nur noch Ausschuss.
chunkIndex, indexError := localRepository.buildChunkIndex(exportContext, orderedChunkIdentifiers)
if indexError != nil {
return 0, indexError
}
containerHeader := backupformat.ContainerHeader{
BackupID: manifest.BackupID,
ChainID: manifest.ChainID,
ParentBackupID: manifest.ParentBackupID,
SourceID: manifest.Source.SourceID,
SourceType: manifest.Source.SourceType,
CreatedAt: manifest.CompletedAt,
CreatedByVersion: createdByVersion,
RepositoryID: localRepository.descriptor.RepositoryID,
}
containerWriter, writerError := backupformat.NewWriter(outputWriter, containerHeader)
if writerError != nil {
return 0, writerError
}
// Das Manifest ist der Kern des Containers und deshalb erforderlich.
if writeError := containerWriter.WriteJSONSection(backupformat.SectionManifest,
backupformat.FlagRequired, manifest); writeError != nil {
return 0, writeError
}
if writeError := containerWriter.WriteJSONSection(backupformat.SectionSourceMetadata,
backupformat.FlagRequired, manifest.Source); writeError != nil {
return 0, writeError
}
if writeError := containerWriter.WriteJSONSection(backupformat.SectionChunkIndex,
backupformat.FlagRequired, chunkIndex); writeError != nil {
return 0, writeError
}
// Der Datenbereich entsteht als Datenstrom: er kann beliebig groß werden
// und darf nicht im Arbeitsspeicher gehalten werden (PROMPT.md §80).
streamWriter, streamError := containerWriter.BeginSectionStream(backupformat.SectionDataChunks,
backupformat.FlagRequired, chunkIndex.TotalStoredBytes())
if streamError != nil {
return 0, streamError
}
for _, indexEntry := range chunkIndex.Entries {
if contextError := exportContext.Err(); contextError != nil {
return 0, contextError
}
// ReadChunk prüft die Unversehrtheit; ein beschädigter Block gelangt
// damit gar nicht erst in den Container.
chunkData, readError := localRepository.ReadChunk(exportContext, indexEntry.Identifier)
if readError != nil {
return 0, fmt.Errorf("der export wurde abgebrochen: %w", readError)
}
if _, writeError := streamWriter.Write(chunkData); writeError != nil {
return 0, writeError
}
}
if finishError := streamWriter.Finish(); finishError != nil {
return 0, finishError
}
if closeError := containerWriter.Close(); closeError != nil {
return 0, closeError
}
localRepository.logger.Info("backup als container exportiert",
slog.String("backup_id", backupID),
slog.Int("chunks", len(chunkIndex.Entries)),
slog.Int64("bytes", containerWriter.BytesWritten()))
return containerWriter.BytesWritten(), nil
}
// collectOrderedChunkIdentifiers sammelt die Chunks eines Manifests ohne Wiederholung.
//
// Ein mehrfach verwendeter Chunk erscheint nur einmal im Container — genau das
// ist der Zweck der Deduplizierung, und sie soll auch im Container erhalten bleiben.
func collectOrderedChunkIdentifiers(manifest *Manifest) []string {
seenIdentifiers := make(map[string]struct{})
orderedIdentifiers := make([]string, 0)
for _, manifestEntry := range manifest.Entries {
for _, chunkReference := range manifestEntry.Chunks {
if _, alreadySeen := seenIdentifiers[chunkReference.Identifier]; alreadySeen {
continue
}
seenIdentifiers[chunkReference.Identifier] = struct{}{}
orderedIdentifiers = append(orderedIdentifiers, chunkReference.Identifier)
}
}
return orderedIdentifiers
}
// buildChunkIndex ermittelt Größe und Position aller Chunks.
func (localRepository *LocalRepository) buildChunkIndex(buildContext context.Context, chunkIdentifiers []string) (backupformat.ChunkIndex, error) {
chunkIndex := backupformat.ChunkIndex{
Entries: make([]backupformat.ChunkIndexEntry, 0, len(chunkIdentifiers)),
}
var currentOffset int64
for _, chunkIdentifier := range chunkIdentifiers {
if contextError := buildContext.Err(); contextError != nil {
return backupformat.ChunkIndex{}, contextError
}
chunkFilePath, pathError := localRepository.chunkPath(chunkIdentifier)
if pathError != nil {
return backupformat.ChunkIndex{}, pathError
}
fileInformation, statError := os.Stat(chunkFilePath)
if statError != nil {
// Ein fehlender Chunk bedeutet: das Backup ist unvollständig. Der
// Export wird abgebrochen, statt einen lückenhaften Container zu
// erzeugen, der sich als vollständig ausgäbe (PROMPT.md §140).
return backupformat.ChunkIndex{}, fmt.Errorf(
"der export ist nicht möglich, das backup ist unvollständig: %w (kennung %s)",
ErrChunkNotFound, chunkIdentifier)
}
chunkLength := fileInformation.Size()
chunkIndex.Entries = append(chunkIndex.Entries, backupformat.ChunkIndexEntry{
Identifier: chunkIdentifier,
ContainerOffset: currentOffset,
StoredLength: chunkLength,
LogicalLength: chunkLength,
})
currentOffset += chunkLength
}
return chunkIndex, nil
}
// ImportResult beschreibt das Ergebnis eines Container-Imports.
type ImportResult struct {
// BackupID ist die Kennung des eingelesenen Backups.
BackupID string `json:"backup_id"`
// ChunksImported ist die Zahl neu übernommener Chunks.
ChunksImported int `json:"chunks_imported"`
// ChunksAlreadyPresent ist die Zahl bereits vorhandener Chunks.
//
// Beim Einlesen mehrerer Container derselben Quelle greift dieselbe
// Deduplizierung wie beim Sichern.
ChunksAlreadyPresent int `json:"chunks_already_present"`
// BytesImported ist die Menge neu geschriebener Daten.
BytesImported int64 `json:"bytes_imported"`
// SourceRepositoryID benennt das Ursprungs-Repository.
SourceRepositoryID string `json:"source_repository_id,omitempty"`
}
// ImportBackup liest einen Container in dieses Repository ein.
//
// Der Container wird vollständig geprüft, bevor das Manifest sichtbar wird:
// Erst wenn alle Abschnitte, Prüfsummen und der Abschlussvermerk stimmen,
// entsteht ein sichtbares Backup. Ein abgebrochener oder verfälschter Container
// hinterlässt allenfalls Chunks, aber niemals ein scheinbar gültiges Backup.
func (localRepository *LocalRepository) ImportBackup(importContext context.Context, inputReader io.Reader) (ImportResult, error) {
if localRepository.lockHandle == nil {
return ImportResult{}, fmt.Errorf("das repository wurde nur lesend geöffnet und kann nichts aufnehmen")
}
containerReader, readerError := backupformat.NewReader(inputReader)
if readerError != nil {
return ImportResult{}, readerError
}
containerHeader := containerReader.Header()
if validationError := validateBackupIdentifier(containerHeader.BackupID); validationError != nil {
return ImportResult{}, validationError
}
// Ein bereits vorhandenes Backup darf nicht überschrieben werden.
if _, statError := os.Stat(localRepository.manifestPath(containerHeader.BackupID)); statError == nil {
return ImportResult{}, fmt.Errorf("zu der kennung %s existiert bereits ein backup", containerHeader.BackupID)
}
importResult := ImportResult{
BackupID: containerHeader.BackupID,
SourceRepositoryID: containerHeader.RepositoryID,
}
var importedManifest *Manifest
var chunkIndex backupformat.ChunkIndex
var hasChunkIndex bool
for {
if contextError := importContext.Err(); contextError != nil {
return ImportResult{}, contextError
}
nextSection, sectionError := containerReader.NextSection()
if errors.Is(sectionError, io.EOF) {
break
}
if sectionError != nil {
return ImportResult{}, sectionError
}
switch nextSection.Type {
case backupformat.SectionManifest:
parsedManifest, decodeError := decodeManifest(nextSection.Content)
if decodeError != nil {
return ImportResult{}, decodeError
}
// Das Manifest wird auf seinen eigenen Abschlussvermerk geprüft -
// unabhängig davon, dass der Container einen trägt.
if verifyError := VerifyManifest(parsedManifest); verifyError != nil {
return ImportResult{}, fmt.Errorf("der container enthält kein gültiges backup: %w", verifyError)
}
importedManifest = parsedManifest
case backupformat.SectionChunkIndex:
if unmarshalError := json.Unmarshal(nextSection.Content, &chunkIndex); unmarshalError != nil {
return ImportResult{}, fmt.Errorf("das chunk-verzeichnis des containers ist unlesbar: %w", unmarshalError)
}
hasChunkIndex = true
case backupformat.SectionDataChunks:
if !hasChunkIndex {
// Ohne Verzeichnis liesse sich der Datenbereich nicht aufteilen.
return ImportResult{}, fmt.Errorf("%w: das chunk-verzeichnis fehlt vor den datenblöcken",
backupformat.ErrMissingSection)
}
if splitError := localRepository.storeChunksFromSection(importContext,
nextSection.Content, chunkIndex, &importResult); splitError != nil {
return ImportResult{}, splitError
}
}
}
// Der Abschlussvermerk des Containers entscheidet über die Gültigkeit.
if !containerReader.IsComplete() {
return ImportResult{}, backupformat.ErrIncompleteBackup
}
if importedManifest == nil {
return ImportResult{}, fmt.Errorf("%w: %s", backupformat.ErrMissingSection, backupformat.SectionManifest)
}
// Erst nach allen Prüfungen wird das Manifest sichtbar. Damit gilt auch
// hier: ein Backup erscheint nur vollständig oder gar nicht.
encodedManifest, encodeError := encodeManifest(importedManifest)
if encodeError != nil {
return ImportResult{}, encodeError
}
manifestPermissions := dataFilePermissions
if localRepository.descriptor.Immutable {
manifestPermissions = immutableFilePermissions
}
if writeError := writeFileAtomically(localRepository.manifestPath(importedManifest.BackupID),
encodedManifest, manifestPermissions); writeError != nil {
return ImportResult{}, fmt.Errorf("das manifest konnte nicht abgelegt werden: %w", writeError)
}
if catalogError := localRepository.appendToCatalog(importedManifest); catalogError != nil {
localRepository.logger.Error("der katalog konnte nach dem import nicht aktualisiert werden",
slog.String("backup_id", importedManifest.BackupID),
slog.String("error", catalogError.Error()))
}
localRepository.logger.Info("container eingelesen",
slog.String("backup_id", importResult.BackupID),
slog.Int("chunks_neu", importResult.ChunksImported),
slog.Int("chunks_vorhanden", importResult.ChunksAlreadyPresent))
return importResult, nil
}
// storeChunksFromSection teilt den Datenbereich anhand des Verzeichnisses auf.
func (localRepository *LocalRepository) storeChunksFromSection(storeContext context.Context, sectionContent []byte, chunkIndex backupformat.ChunkIndex, importResult *ImportResult) error {
for _, indexEntry := range chunkIndex.Entries {
if contextError := storeContext.Err(); contextError != nil {
return contextError
}
endOffset := indexEntry.ContainerOffset + indexEntry.StoredLength
// Eine Positionsangabe außerhalb des Datenbereichs deutet auf einen
// verfälschten Container hin.
if indexEntry.ContainerOffset < 0 || endOffset > int64(len(sectionContent)) {
return fmt.Errorf("das chunk-verzeichnis passt nicht zum datenbereich (kennung %s)",
indexEntry.Identifier)
}
chunkData := sectionContent[indexEntry.ContainerOffset:endOffset]
// Der Inhalt muss zu seiner Kennung passen. Andernfalls wäre entweder
// der Container verfälscht oder das Verzeichnis falsch - in beiden
// Fällen darf nichts übernommen werden.
if computeChunkIdentifier(chunkData) != indexEntry.Identifier {
return fmt.Errorf("%w: ein chunk des containers passt nicht zu seiner kennung (%s)",
ErrChunkCorrupted, indexEntry.Identifier)
}
_, wasNew, writeError := localRepository.writeChunk(storeContext, chunkData)
if writeError != nil {
return writeError
}
if wasNew {
importResult.ChunksImported++
importResult.BytesImported += indexEntry.StoredLength
} else {
importResult.ChunksAlreadyPresent++
}
}
return nil
}