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>
310 lines
11 KiB
Go
310 lines
11 KiB
Go
package backupformat
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"encoding/binary"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
)
|
|
|
|
// Section ist ein gelesener Abschnitt eines Containers.
|
|
type Section struct {
|
|
// Type ist die Art des Abschnitts.
|
|
Type SectionType
|
|
// Flags beschreiben Eigenschaften des Abschnitts.
|
|
Flags SectionFlags
|
|
// Content ist der geprüfte Inhalt.
|
|
Content []byte
|
|
// Digest ist die Prüfsumme aus dem Abschnittskopf.
|
|
Digest string
|
|
}
|
|
|
|
// Reader liest einen Backup Container.
|
|
//
|
|
// Gelesen wird sequenziell und prüfend: jeder Abschnitt wird gegen seine
|
|
// Prüfsumme gehalten, bevor sein Inhalt weitergereicht wird. Ein beschädigter
|
|
// Container darf niemals unbemerkt in eine Wiederherstellung fließen
|
|
// (PROMPT.md §14).
|
|
type Reader struct {
|
|
// inputReader ist die Quelle des Containers.
|
|
inputReader io.Reader
|
|
// header ist der gelesene Container-Header.
|
|
header ContainerHeader
|
|
// formatVersion ist die Formatversion des Containers.
|
|
formatVersion uint16
|
|
// contentDigest führt die Gesamtprüfsumme mit.
|
|
contentDigest interface {
|
|
io.Writer
|
|
Sum([]byte) []byte
|
|
}
|
|
// sectionsRead zählt die gelesenen Abschnitte.
|
|
sectionsRead int
|
|
// bytesRead zählt die gelesenen Byte ohne den Footer.
|
|
bytesRead int64
|
|
// manifestDigest ist die Prüfsumme des Manifestabschnitts.
|
|
manifestDigest string
|
|
// footer ist der gelesene Footer; er liegt erst am Ende vor.
|
|
footer *ContainerFooter
|
|
}
|
|
|
|
// NewReader öffnet einen Container und liest dessen Header.
|
|
//
|
|
// Version und Kennung werden sofort geprüft: ein fremdes oder unlesbares
|
|
// Format wird abgelehnt, bevor irgendein Inhalt gedeutet wird.
|
|
func NewReader(inputReader io.Reader) (*Reader, error) {
|
|
containerReader := &Reader{
|
|
inputReader: inputReader,
|
|
contentDigest: sha256.New(),
|
|
}
|
|
|
|
if readError := containerReader.readContainerHeader(); readError != nil {
|
|
return nil, readError
|
|
}
|
|
|
|
return containerReader, nil
|
|
}
|
|
|
|
// readContainerHeader liest Kennung, Version und Header.
|
|
func (containerReader *Reader) readContainerHeader() error {
|
|
magicBuffer := make([]byte, magicLength)
|
|
if readError := containerReader.readFullAndDigest(magicBuffer); readError != nil {
|
|
// Zu wenige Daten für die Kennung bedeuten: das ist kein Container.
|
|
return ErrNotAContainer
|
|
}
|
|
|
|
if !bytes.Equal(magicBuffer, containerMagic[:]) {
|
|
return ErrNotAContainer
|
|
}
|
|
|
|
versionBuffer := make([]byte, versionLength)
|
|
if readError := containerReader.readFullAndDigest(versionBuffer); readError != nil {
|
|
return ErrContainerTruncated
|
|
}
|
|
|
|
containerReader.formatVersion = binary.BigEndian.Uint16(versionBuffer)
|
|
|
|
if !IsVersionReadable(containerReader.formatVersion) {
|
|
return describeVersionMismatch(containerReader.formatVersion)
|
|
}
|
|
|
|
headerLengthBuffer := make([]byte, headerLengthFieldLength)
|
|
if readError := containerReader.readFullAndDigest(headerLengthBuffer); readError != nil {
|
|
return ErrContainerTruncated
|
|
}
|
|
|
|
headerLength := binary.BigEndian.Uint32(headerLengthBuffer)
|
|
|
|
// Eine verfälschte Längenangabe darf keine unbegrenzte Speicheranforderung auslösen.
|
|
if headerLength > maximumHeaderLength {
|
|
return fmt.Errorf("%w: der header ist mit %d byte unplausibel groß", ErrNotAContainer, headerLength)
|
|
}
|
|
|
|
headerBuffer := make([]byte, headerLength)
|
|
if readError := containerReader.readFullAndDigest(headerBuffer); readError != nil {
|
|
return ErrContainerTruncated
|
|
}
|
|
|
|
if unmarshalError := json.Unmarshal(headerBuffer, &containerReader.header); unmarshalError != nil {
|
|
return fmt.Errorf("%w: der header ist unlesbar", ErrNotAContainer)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Header liefert den Container-Header.
|
|
func (containerReader *Reader) Header() ContainerHeader {
|
|
return containerReader.header
|
|
}
|
|
|
|
// FormatVersion liefert die Formatversion des Containers.
|
|
func (containerReader *Reader) FormatVersion() uint16 {
|
|
return containerReader.formatVersion
|
|
}
|
|
|
|
// NextSection liest den nächsten Abschnitt.
|
|
//
|
|
// Am Ende der Abschnittskette wird io.EOF geliefert; der Footer ist dann
|
|
// gelesen und über Footer() abrufbar.
|
|
func (containerReader *Reader) NextSection() (*Section, error) {
|
|
sectionHeaderBuffer := make([]byte, sectionHeaderLength)
|
|
|
|
bytesRead, readError := io.ReadFull(containerReader.inputReader, sectionHeaderBuffer)
|
|
|
|
// Ein Abschnittskopf, der wie ein Footer beginnt, beendet die Kette.
|
|
if bytesRead >= magicLength && bytes.Equal(sectionHeaderBuffer[:magicLength], footerMagic[:]) {
|
|
return nil, containerReader.readFooter(sectionHeaderBuffer[:bytesRead])
|
|
}
|
|
|
|
if readError != nil {
|
|
if errors.Is(readError, io.EOF) || errors.Is(readError, io.ErrUnexpectedEOF) {
|
|
// Die Daten enden, ohne dass ein Footer kam.
|
|
return nil, ErrContainerTruncated
|
|
}
|
|
|
|
return nil, fmt.Errorf("der container konnte nicht gelesen werden: %w", readError)
|
|
}
|
|
|
|
// Der Kopf gehört zur Gesamtprüfsumme.
|
|
_, _ = containerReader.contentDigest.Write(sectionHeaderBuffer)
|
|
containerReader.bytesRead += int64(len(sectionHeaderBuffer))
|
|
|
|
sectionType := SectionType(sectionHeaderBuffer[0])
|
|
sectionFlags := SectionFlags(sectionHeaderBuffer[1])
|
|
sectionLength := binary.BigEndian.Uint64(sectionHeaderBuffer[2:10])
|
|
expectedDigest := sectionHeaderBuffer[10 : 10+digestLength]
|
|
|
|
if sectionLength > maximumSectionLength {
|
|
return nil, fmt.Errorf("%w: %d byte", ErrSectionTooLarge, sectionLength)
|
|
}
|
|
|
|
sectionContent := make([]byte, sectionLength)
|
|
if _, contentError := io.ReadFull(containerReader.inputReader, sectionContent); contentError != nil {
|
|
return nil, ErrContainerTruncated
|
|
}
|
|
|
|
containerReader.bytesRead += int64(sectionLength)
|
|
|
|
actualDigest := sha256.Sum256(sectionContent)
|
|
|
|
// Ein als Datenstrom geschriebener Abschnitt trägt keine Prüfsumme im Kopf:
|
|
// sie stand dort noch nicht fest. Seine Unversehrtheit sichern die
|
|
// Prüfsummen der einzelnen Chunks im Verzeichnis und die Gesamtprüfsumme
|
|
// im Footer, die beide weiter unten geprüft werden.
|
|
if !sectionFlags.Has(FlagDeferredDigest) {
|
|
// Der Vergleich läuft in konstanter Zeit: eine Prüfsumme ist ein
|
|
// Sicherheitsmerkmal, kein bloßer Vergleichswert.
|
|
if subtle.ConstantTimeCompare(actualDigest[:], expectedDigest) != 1 {
|
|
return nil, fmt.Errorf("%w: %s (erwartet %s, tatsächlich %s)",
|
|
ErrSectionCorrupted, sectionType,
|
|
hex.EncodeToString(expectedDigest), hex.EncodeToString(actualDigest[:]))
|
|
}
|
|
}
|
|
|
|
_, _ = containerReader.contentDigest.Write(sectionContent)
|
|
|
|
// Ein unbekannter Abschnitt darf nur übersprungen werden, wenn er nicht als
|
|
// erforderlich gekennzeichnet ist. Andernfalls fehlte etwas Wesentliches,
|
|
// und ein Weiterarbeiten täuschte Vollständigkeit vor (PROMPT.md §140).
|
|
if !isKnownSectionType(sectionType) && sectionFlags.Has(FlagRequired) {
|
|
return nil, fmt.Errorf("%w: Abschnittstyp %d. Bitte Syncova aktualisieren",
|
|
ErrUnknownRequiredSection, uint8(sectionType))
|
|
}
|
|
|
|
containerReader.sectionsRead++
|
|
|
|
if sectionType == SectionManifest {
|
|
containerReader.manifestDigest = hex.EncodeToString(actualDigest[:])
|
|
}
|
|
|
|
return &Section{
|
|
Type: sectionType,
|
|
Flags: sectionFlags,
|
|
Content: sectionContent,
|
|
Digest: hex.EncodeToString(actualDigest[:]),
|
|
}, nil
|
|
}
|
|
|
|
// readFooter liest den Footer und prüft den Abschlussvermerk.
|
|
func (containerReader *Reader) readFooter(alreadyRead []byte) error {
|
|
// Der Kennungsteil wurde bereits gelesen; es fehlen Längenfeld und Inhalt.
|
|
remainingHeader := alreadyRead[magicLength:]
|
|
|
|
footerLengthBuffer := make([]byte, headerLengthFieldLength)
|
|
copiedBytes := copy(footerLengthBuffer, remainingHeader)
|
|
|
|
if copiedBytes < headerLengthFieldLength {
|
|
if _, readError := io.ReadFull(containerReader.inputReader, footerLengthBuffer[copiedBytes:]); readError != nil {
|
|
return ErrContainerTruncated
|
|
}
|
|
}
|
|
|
|
footerLength := binary.BigEndian.Uint32(footerLengthBuffer)
|
|
if footerLength > maximumHeaderLength {
|
|
return fmt.Errorf("%w: der footer ist unplausibel groß", ErrIncompleteBackup)
|
|
}
|
|
|
|
footerBuffer := make([]byte, footerLength)
|
|
|
|
// Teile des Footers können bereits im Puffer des Abschnittskopfs liegen.
|
|
alreadyBuffered := remainingHeader[min(len(remainingHeader), headerLengthFieldLength):]
|
|
copiedFooterBytes := copy(footerBuffer, alreadyBuffered)
|
|
|
|
if copiedFooterBytes < int(footerLength) {
|
|
if _, readError := io.ReadFull(containerReader.inputReader, footerBuffer[copiedFooterBytes:]); readError != nil {
|
|
return ErrContainerTruncated
|
|
}
|
|
}
|
|
|
|
var containerFooter ContainerFooter
|
|
if unmarshalError := json.Unmarshal(footerBuffer, &containerFooter); unmarshalError != nil {
|
|
return fmt.Errorf("%w: der footer ist unlesbar", ErrIncompleteBackup)
|
|
}
|
|
|
|
// Ohne ausdrücklichen Abschlussvermerk gilt der Container als unvollständig.
|
|
if !containerFooter.Complete {
|
|
return ErrIncompleteBackup
|
|
}
|
|
|
|
// Die Gesamtprüfsumme deckt Header und alle Abschnitte ab. Sie deckt auch
|
|
// den Austausch eines vollständigen Abschnitts samt seiner Prüfsumme auf.
|
|
actualContentHash := hex.EncodeToString(containerReader.contentDigest.Sum(nil))
|
|
if subtle.ConstantTimeCompare([]byte(actualContentHash), []byte(containerFooter.ContentHash)) != 1 {
|
|
return fmt.Errorf("%w (erwartet %s, tatsächlich %s)",
|
|
ErrIntegrityMismatch, containerFooter.ContentHash, actualContentHash)
|
|
}
|
|
|
|
// Eine abweichende Abschnittszahl deckt einen entfernten Abschnitt auf.
|
|
if containerFooter.SectionCount != containerReader.sectionsRead {
|
|
return fmt.Errorf("%w: der footer nennt %d abschnitte, gelesen wurden %d",
|
|
ErrIntegrityMismatch, containerFooter.SectionCount, containerReader.sectionsRead)
|
|
}
|
|
|
|
if containerFooter.ManifestHash != containerReader.manifestDigest {
|
|
return fmt.Errorf("%w: die prüfsumme des manifests stimmt nicht", ErrIntegrityMismatch)
|
|
}
|
|
|
|
containerReader.footer = &containerFooter
|
|
|
|
return io.EOF
|
|
}
|
|
|
|
// Footer liefert den Footer, sofern der Container vollständig gelesen wurde.
|
|
//
|
|
// Vorher ist er nil: der Abschlussvermerk steht erst am Ende fest.
|
|
func (containerReader *Reader) Footer() *ContainerFooter {
|
|
return containerReader.footer
|
|
}
|
|
|
|
// IsComplete meldet, ob der Container einen gültigen Abschlussvermerk trägt.
|
|
func (containerReader *Reader) IsComplete() bool {
|
|
return containerReader.footer != nil && containerReader.footer.Complete
|
|
}
|
|
|
|
// readFullAndDigest liest genau die angeforderte Menge und führt die Prüfsumme mit.
|
|
func (containerReader *Reader) readFullAndDigest(targetBuffer []byte) error {
|
|
if _, readError := io.ReadFull(containerReader.inputReader, targetBuffer); readError != nil {
|
|
return readError
|
|
}
|
|
|
|
_, _ = containerReader.contentDigest.Write(targetBuffer)
|
|
containerReader.bytesRead += int64(len(targetBuffer))
|
|
|
|
return nil
|
|
}
|
|
|
|
// isKnownSectionType meldet, ob diese Programmversion einen Abschnittstyp kennt.
|
|
func isKnownSectionType(sectionType SectionType) bool {
|
|
switch sectionType {
|
|
case SectionManifest, SectionChunkIndex, SectionBlockMap,
|
|
SectionDataChunks, SectionSourceMetadata, SectionIntegrityInfo:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|