syncova-backup/packages/backupformat/format.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

217 lines
9.7 KiB
Go

// Package backupformat implementiert den versionierten Syncova Backup Container.
//
// Der Container ist die portable Form eines Backups: eine einzelne, in sich
// geschlossene Datei, die alles enthält, was zur Wiederherstellung nötig ist
// (PROMPT.md §13, SYNCOVA_ARCHITECTURE.md §8). Er dient der Übertragung an ein
// zweites Repository, der Archivierung und dem Austausch zwischen Installationen.
//
// Aufbau:
//
// +--------------------------------------------------+
// | Magic "SYNCOVA1" (8 Byte) |
// | Formatversion (uint16) |
// | Headerlänge (uint32) + Header (JSON) |
// +--------------------------------------------------+
// | Abschnitt: Typ, Flags, Länge, Prüfsumme, Inhalt |
// | ... beliebig viele Abschnitte ... |
// +--------------------------------------------------+
// | Footer: Magic, Manifest-Hash, Gesamt-Prüfsumme, |
// | Abschlussvermerk |
// +--------------------------------------------------+
//
// Zwei Eigenschaften prägen den Entwurf:
//
// - Der Footer steht am Ende. Ein abgeschnittener Container hat keinen und
// wird dadurch zuverlässig als unvollständig erkannt.
// - Jeder Abschnitt trägt seine Länge. Eine spätere Programmversion kann
// Abschnitte ergänzen, die eine ältere überspringt — sofern sie nicht als
// erforderlich gekennzeichnet sind.
package backupformat
import (
"errors"
"fmt"
)
// containerMagic kennzeichnet den Beginn eines Syncova-Containers.
//
// Die Ziffer am Ende gehört zur Kennung und nicht zur Version: sie verhindert,
// dass ein Container einer künftigen, grundlegend anderen Formatfamilie
// versehentlich als lesbar gilt.
var containerMagic = [8]byte{'S', 'Y', 'N', 'C', 'O', 'V', 'A', '1'}
// footerMagic kennzeichnet den Beginn des Footers.
//
// Eine eigene Kennung erlaubt es, den Footer auch dann zu finden, wenn die
// Abschnittskette beschädigt ist — eine Notfallanalyse bleibt damit möglich.
var footerMagic = [8]byte{'S', 'Y', 'N', 'F', 'O', 'O', 'T', '1'}
// Versionen des Containerformats.
const (
// FormatVersion ist die Version, die diese Programmversion schreibt.
FormatVersion uint16 = 1
// MinimumReadableVersion ist die älteste noch lesbare Version.
//
// Ältere Container werden abgelehnt statt fehlerhaft gedeutet: ein
// falsch interpretiertes Backup ist schlimmer als ein nicht gelesenes.
MinimumReadableVersion uint16 = 1
// MaximumReadableVersion ist die neueste lesbare Version.
//
// Ein neuerer Container wird nicht angetastet, weil diese Programmversion
// seine Regeln nicht kennt (PROMPT.md §13).
MaximumReadableVersion uint16 = 1
)
// SectionType benennt die Art eines Abschnitts.
//
// Die Werte sind Teil des Formatvertrags und dürfen niemals neu belegt werden:
// ein bestehender Container würde sonst falsch gedeutet.
type SectionType uint8
const (
// SectionManifest enthält das Manifest des Backups als JSON.
SectionManifest SectionType = 1
// SectionChunkIndex enthält das Verzeichnis aller Chunks mit Länge und Prüfsumme.
SectionChunkIndex SectionType = 2
// SectionBlockMap ordnet logische Bereiche der Quelle den Chunks zu.
SectionBlockMap SectionType = 3
// SectionDataChunks enthält die eigentlichen Datenblöcke.
SectionDataChunks SectionType = 4
// SectionSourceMetadata enthält Angaben zum gesicherten System.
SectionSourceMetadata SectionType = 5
// SectionIntegrityInfo enthält ergänzende Integritätsangaben.
SectionIntegrityInfo SectionType = 6
)
// String liefert die sprechende Bezeichnung eines Abschnittstyps.
//
// Sie erscheint in Fehlermeldungen, damit ein Administrator ohne Formattabelle
// versteht, welcher Teil betroffen ist.
func (sectionType SectionType) String() string {
switch sectionType {
case SectionManifest:
return "Manifest"
case SectionChunkIndex:
return "Chunk-Verzeichnis"
case SectionBlockMap:
return "Blockzuordnung"
case SectionDataChunks:
return "Datenblöcke"
case SectionSourceMetadata:
return "Quellenangaben"
case SectionIntegrityInfo:
return "Integritätsangaben"
default:
return fmt.Sprintf("unbekannter Abschnitt (%d)", uint8(sectionType))
}
}
// SectionFlags beschreiben Eigenschaften eines Abschnitts.
type SectionFlags uint8
const (
// FlagRequired kennzeichnet einen Abschnitt, ohne den das Backup unbrauchbar ist.
//
// Das ist der Schlüssel zur Erweiterbarkeit: eine spätere Programmversion
// darf neue Abschnitte hinzufügen. Kennt eine ältere Version einen
// optionalen Abschnitt nicht, überspringt sie ihn. Einen als erforderlich
// gekennzeichneten dagegen darf sie nicht übergehen — sie verweigert die
// Verarbeitung, statt ein unvollständiges Backup vorzutäuschen.
FlagRequired SectionFlags = 1 << 0
// FlagCompressed meldet einen komprimierten Abschnittsinhalt.
FlagCompressed SectionFlags = 1 << 1
// FlagEncrypted meldet einen verschlüsselten Abschnittsinhalt.
FlagEncrypted SectionFlags = 1 << 2
// FlagDeferredDigest meldet einen Abschnitt ohne Prüfsumme im Kopf.
//
// Ein Abschnitt, der als Datenstrom entsteht, kennt seine Prüfsumme erst,
// wenn er vollständig geschrieben ist — der Kopf steht zu diesem Zeitpunkt
// aber längst in der Ausgabe. Ihn nachträglich zu überschreiben verlangte
// eine rückspulbare Senke und schlösse damit Netzwerk- und Pipe-Ziele aus.
//
// Die Unversehrtheit solcher Abschnitte ist doppelt gesichert: über die
// Prüfsumme jedes einzelnen Chunks im Chunk-Verzeichnis und über die
// Gesamtprüfsumme im Footer. Es entsteht dadurch keine Prüflücke.
FlagDeferredDigest SectionFlags = 1 << 3
)
// Has meldet, ob ein Flag gesetzt ist.
func (sectionFlags SectionFlags) Has(testedFlag SectionFlags) bool {
return sectionFlags&testedFlag != 0
}
// Feste Längen des Formats in Byte.
const (
// magicLength ist die Länge der Kennung.
magicLength = 8
// versionLength ist die Länge der Versionsangabe.
versionLength = 2
// headerLengthFieldLength ist die Länge des Längenfelds des Headers.
headerLengthFieldLength = 4
// digestLength ist die Länge einer SHA-256-Prüfsumme.
digestLength = 32
// sectionHeaderLength ist die Länge eines Abschnittskopfs:
// Typ (1) + Flags (1) + Länge (8) + Prüfsumme (32).
sectionHeaderLength = 1 + 1 + 8 + digestLength
)
// maximumHeaderLength begrenzt die Größe des Headers.
//
// Ohne Obergrenze könnte eine manipulierte Längenangabe den Arbeitsspeicher
// erschöpfen, noch bevor irgendeine Prüfung greift.
const maximumHeaderLength = 4 * 1024 * 1024
// maximumSectionLength begrenzt die Größe eines einzelnen Abschnitts.
//
// Der Wert ist großzügig gewählt, verhindert aber, dass eine verfälschte
// Längenangabe zu einer unbegrenzten Speicheranforderung führt.
const maximumSectionLength = 64 * 1024 * 1024 * 1024
// Fehler des Containerformats.
var (
// ErrNotAContainer meldet Daten, die kein Syncova-Container sind.
ErrNotAContainer = errors.New("die daten sind kein syncova backup container")
// ErrUnsupportedVersion meldet eine nicht lesbare Formatversion.
ErrUnsupportedVersion = errors.New("die formatversion des containers wird von dieser programmversion nicht unterstützt")
// ErrContainerTruncated meldet einen abgeschnittenen Container.
//
// Das ist der häufigste Fall eines abgebrochenen Übertragungsvorgangs und
// muss zuverlässig erkannt werden.
ErrContainerTruncated = errors.New("der container ist unvollständig: er endet vor dem abschlussvermerk")
// ErrSectionCorrupted meldet einen Abschnitt, dessen Inhalt nicht zu seiner Prüfsumme passt.
ErrSectionCorrupted = errors.New("ein abschnitt des containers ist beschädigt")
// ErrUnknownRequiredSection meldet einen unbekannten, aber erforderlichen Abschnitt.
ErrUnknownRequiredSection = errors.New("der container enthält einen erforderlichen abschnitt, den diese programmversion nicht kennt")
// ErrMissingSection meldet einen fehlenden Pflichtabschnitt.
ErrMissingSection = errors.New("dem container fehlt ein pflichtabschnitt")
// ErrIncompleteBackup meldet einen Container ohne gültigen Abschlussvermerk.
ErrIncompleteBackup = errors.New("der container trägt keinen gültigen abschlussvermerk und beschreibt kein vollständiges backup")
// ErrIntegrityMismatch meldet eine nicht passende Gesamtprüfsumme.
ErrIntegrityMismatch = errors.New("die prüfsumme des containers stimmt nicht mit seinem inhalt überein")
// ErrChunkNotInContainer meldet einen im Manifest benannten, aber nicht enthaltenen Chunk.
ErrChunkNotInContainer = errors.New("ein vom manifest benötigter chunk fehlt im container")
// ErrSectionTooLarge meldet eine unplausible Längenangabe.
ErrSectionTooLarge = errors.New("die längenangabe eines abschnitts ist unplausibel groß")
// ErrWriterClosed meldet die Verwendung eines bereits abgeschlossenen Schreibers.
ErrWriterClosed = errors.New("der container wurde bereits abgeschlossen")
)
// IsVersionReadable meldet, ob eine Formatversion gelesen werden kann.
func IsVersionReadable(formatVersion uint16) bool {
return formatVersion >= MinimumReadableVersion && formatVersion <= MaximumReadableVersion
}
// describeVersionMismatch erklärt eine nicht lesbare Version verständlich.
//
// Der Unterschied zwischen "zu alt" und "zu neu" ist für den Administrator
// entscheidend: im einen Fall hilft ein Konverter, im anderen ein Update.
func describeVersionMismatch(formatVersion uint16) error {
if formatVersion > MaximumReadableVersion {
return fmt.Errorf("%w: der container hat Version %d, diese Programmversion liest bis Version %d. "+
"Bitte Syncova aktualisieren", ErrUnsupportedVersion, formatVersion, MaximumReadableVersion)
}
return fmt.Errorf("%w: der container hat Version %d, diese Programmversion liest ab Version %d",
ErrUnsupportedVersion, formatVersion, MinimumReadableVersion)
}