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>
332 lines
12 KiB
Go
332 lines
12 KiB
Go
package backupformat
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/binary"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"hash"
|
|
"io"
|
|
"time"
|
|
)
|
|
|
|
// Writer schreibt einen Backup Container.
|
|
//
|
|
// Der Schreibvorgang läuft als Datenstrom: der Container entsteht Abschnitt für
|
|
// Abschnitt, ohne dass das Backup jemals vollständig im Arbeitsspeicher liegt
|
|
// (PROMPT.md §80). Erst der abschließende Footer macht ihn gültig.
|
|
type Writer struct {
|
|
// outputWriter ist die Senke des Containers.
|
|
outputWriter io.Writer
|
|
// contentDigest berechnet die Gesamtprüfsumme über Header und Abschnitte mit.
|
|
contentDigest hash.Hash
|
|
// bytesWritten zählt die geschriebenen Byte ohne den Footer.
|
|
bytesWritten int64
|
|
// sectionCount zählt die geschriebenen Abschnitte.
|
|
sectionCount int
|
|
// manifestHash ist die Prüfsumme des Manifestabschnitts.
|
|
manifestHash string
|
|
// writtenSectionTypes hält fest, welche Abschnitte bereits vorliegen.
|
|
writtenSectionTypes map[SectionType]bool
|
|
// isClosed meldet, ob der Container bereits abgeschlossen ist.
|
|
isClosed bool
|
|
// timeSource liefert die aktuelle Zeit und ist in Tests ersetzbar.
|
|
timeSource func() time.Time
|
|
}
|
|
|
|
// NewWriter beginnt einen Container und schreibt dessen Header.
|
|
//
|
|
// Der Header wird sofort ausgegeben, damit ein Leser schon während des
|
|
// Schreibens Version und Kennung erkennen kann.
|
|
func NewWriter(outputWriter io.Writer, containerHeader ContainerHeader) (*Writer, error) {
|
|
containerWriter := &Writer{
|
|
outputWriter: outputWriter,
|
|
contentDigest: sha256.New(),
|
|
writtenSectionTypes: make(map[SectionType]bool),
|
|
timeSource: time.Now,
|
|
}
|
|
|
|
if writeError := containerWriter.writeContainerHeader(containerHeader); writeError != nil {
|
|
return nil, writeError
|
|
}
|
|
|
|
return containerWriter, nil
|
|
}
|
|
|
|
// writeContainerHeader schreibt Kennung, Version und Header.
|
|
func (containerWriter *Writer) writeContainerHeader(containerHeader ContainerHeader) error {
|
|
encodedHeader, marshalError := json.Marshal(containerHeader)
|
|
if marshalError != nil {
|
|
return fmt.Errorf("der container-header konnte nicht erzeugt werden: %w", marshalError)
|
|
}
|
|
|
|
if len(encodedHeader) > maximumHeaderLength {
|
|
return fmt.Errorf("der container-header ist mit %d byte zu groß", len(encodedHeader))
|
|
}
|
|
|
|
// Kennung
|
|
if writeError := containerWriter.writeAndDigest(containerMagic[:]); writeError != nil {
|
|
return writeError
|
|
}
|
|
|
|
// Version, durchgehend als Big Endian - die Byte-Reihenfolge ist Teil des
|
|
// Formats und darf nicht von der Rechnerarchitektur abhängen.
|
|
versionBytes := make([]byte, versionLength)
|
|
binary.BigEndian.PutUint16(versionBytes, FormatVersion)
|
|
if writeError := containerWriter.writeAndDigest(versionBytes); writeError != nil {
|
|
return writeError
|
|
}
|
|
|
|
// Headerlänge
|
|
headerLengthBytes := make([]byte, headerLengthFieldLength)
|
|
binary.BigEndian.PutUint32(headerLengthBytes, uint32(len(encodedHeader)))
|
|
if writeError := containerWriter.writeAndDigest(headerLengthBytes); writeError != nil {
|
|
return writeError
|
|
}
|
|
|
|
return containerWriter.writeAndDigest(encodedHeader)
|
|
}
|
|
|
|
// WriteSection schreibt einen Abschnitt mit vorliegendem Inhalt.
|
|
//
|
|
// Für den Datenbereich, der beliebig groß werden kann, steht stattdessen
|
|
// WriteSectionStream bereit.
|
|
func (containerWriter *Writer) WriteSection(sectionType SectionType, sectionFlags SectionFlags, sectionContent []byte) error {
|
|
if containerWriter.isClosed {
|
|
return ErrWriterClosed
|
|
}
|
|
|
|
sectionDigest := sha256.Sum256(sectionContent)
|
|
|
|
if writeError := containerWriter.writeSectionHeader(sectionType, sectionFlags,
|
|
int64(len(sectionContent)), sectionDigest[:]); writeError != nil {
|
|
return writeError
|
|
}
|
|
|
|
if writeError := containerWriter.writeAndDigest(sectionContent); writeError != nil {
|
|
return writeError
|
|
}
|
|
|
|
// Die Prüfsumme des Manifests wandert in den Footer: sie ist die Kennung
|
|
// des Backup-Inhalts und wird beim Lesen gegengeprüft.
|
|
if sectionType == SectionManifest {
|
|
containerWriter.manifestHash = hex.EncodeToString(sectionDigest[:])
|
|
}
|
|
|
|
containerWriter.sectionCount++
|
|
containerWriter.writtenSectionTypes[sectionType] = true
|
|
|
|
return nil
|
|
}
|
|
|
|
// WriteJSONSection schreibt einen Abschnitt mit JSON-Inhalt.
|
|
func (containerWriter *Writer) WriteJSONSection(sectionType SectionType, sectionFlags SectionFlags, sectionValue any) error {
|
|
encodedValue, marshalError := json.Marshal(sectionValue)
|
|
if marshalError != nil {
|
|
return fmt.Errorf("der abschnitt %s konnte nicht erzeugt werden: %w", sectionType, marshalError)
|
|
}
|
|
|
|
return containerWriter.WriteSection(sectionType, sectionFlags, encodedValue)
|
|
}
|
|
|
|
// SectionStreamWriter nimmt den Inhalt eines Abschnitts als Datenstrom auf.
|
|
type SectionStreamWriter struct {
|
|
// containerWriter ist der zugehörige Container.
|
|
containerWriter *Writer
|
|
// sectionDigest berechnet die Prüfsumme des Abschnitts mit.
|
|
sectionDigest hash.Hash
|
|
// declaredLength ist die angekündigte Länge des Abschnitts.
|
|
declaredLength int64
|
|
// writtenLength ist die bisher geschriebene Länge.
|
|
writtenLength int64
|
|
// sectionType ist die Art des Abschnitts.
|
|
sectionType SectionType
|
|
// isFinished meldet, ob der Abschnitt bereits abgeschlossen wurde.
|
|
isFinished bool
|
|
}
|
|
|
|
// BeginSectionStream beginnt einen Abschnitt, dessen Inhalt als Datenstrom folgt.
|
|
//
|
|
// Die Länge muss vorab feststehen, weil sie im Abschnittskopf steht — nur so
|
|
// kann ein Leser einen unbekannten Abschnitt überspringen, ohne ihn zu deuten.
|
|
// Beim Export aus einem Repository ist sie aus dem Chunk-Verzeichnis bekannt.
|
|
//
|
|
// Der Abschnitt trägt FlagDeferredDigest: seine Prüfsumme steht zum Zeitpunkt
|
|
// des Kopfes noch nicht fest. Der Weg arbeitet deshalb auf jeder Senke und
|
|
// setzt kein Rückspulen voraus (PROMPT.md §80).
|
|
func (containerWriter *Writer) BeginSectionStream(sectionType SectionType, sectionFlags SectionFlags, declaredLength int64) (*SectionStreamWriter, error) {
|
|
if containerWriter.isClosed {
|
|
return nil, ErrWriterClosed
|
|
}
|
|
|
|
if declaredLength < 0 || declaredLength > maximumSectionLength {
|
|
return nil, fmt.Errorf("%w: %d byte", ErrSectionTooLarge, declaredLength)
|
|
}
|
|
|
|
// Die Prüfsumme im Kopf bleibt leer und wird beim Lesen nicht herangezogen.
|
|
emptyDigest := make([]byte, digestLength)
|
|
|
|
if writeError := containerWriter.writeSectionHeader(sectionType,
|
|
sectionFlags|FlagDeferredDigest, declaredLength, emptyDigest); writeError != nil {
|
|
return nil, writeError
|
|
}
|
|
|
|
return &SectionStreamWriter{
|
|
containerWriter: containerWriter,
|
|
sectionDigest: sha256.New(),
|
|
declaredLength: declaredLength,
|
|
sectionType: sectionType,
|
|
}, nil
|
|
}
|
|
|
|
// Write nimmt Daten des Abschnitts auf.
|
|
func (streamWriter *SectionStreamWriter) Write(sectionData []byte) (int, error) {
|
|
if streamWriter.isFinished {
|
|
return 0, fmt.Errorf("der abschnitt %s wurde bereits abgeschlossen", streamWriter.sectionType)
|
|
}
|
|
|
|
// Mehr als angekündigt zu schreiben würde den Container unlesbar machen:
|
|
// der folgende Abschnittskopf läge dann an der falschen Stelle.
|
|
if streamWriter.writtenLength+int64(len(sectionData)) > streamWriter.declaredLength {
|
|
return 0, fmt.Errorf("der abschnitt %s würde seine angekündigte länge von %d byte überschreiten",
|
|
streamWriter.sectionType, streamWriter.declaredLength)
|
|
}
|
|
|
|
bytesWritten, writeError := streamWriter.containerWriter.outputWriter.Write(sectionData)
|
|
if bytesWritten > 0 {
|
|
// Der Fehler von Write kann bei einem Hash nicht auftreten.
|
|
_, _ = streamWriter.sectionDigest.Write(sectionData[:bytesWritten])
|
|
_, _ = streamWriter.containerWriter.contentDigest.Write(sectionData[:bytesWritten])
|
|
|
|
streamWriter.writtenLength += int64(bytesWritten)
|
|
streamWriter.containerWriter.bytesWritten += int64(bytesWritten)
|
|
}
|
|
|
|
if writeError != nil {
|
|
return bytesWritten, fmt.Errorf("der abschnitt %s konnte nicht geschrieben werden: %w",
|
|
streamWriter.sectionType, writeError)
|
|
}
|
|
|
|
return bytesWritten, nil
|
|
}
|
|
|
|
// Finish schließt den Abschnitt ab.
|
|
//
|
|
// Die gebildete Prüfsumme wandert nicht in den bereits geschriebenen Kopf,
|
|
// sondern wird vom Aufrufer im Chunk-Verzeichnis abgelegt. Sie ist über
|
|
// Digest() abrufbar.
|
|
func (streamWriter *SectionStreamWriter) Finish() error {
|
|
if streamWriter.isFinished {
|
|
return nil
|
|
}
|
|
|
|
// Weniger als angekündigt zu schreiben ergäbe einen Abschnitt, dessen
|
|
// Längenangabe nicht zum Inhalt passt — der folgende Abschnittskopf läge
|
|
// dann an der falschen Stelle und der Container wäre unlesbar.
|
|
if streamWriter.writtenLength != streamWriter.declaredLength {
|
|
return fmt.Errorf("der abschnitt %s wurde mit %d von %d angekündigten byte geschrieben",
|
|
streamWriter.sectionType, streamWriter.writtenLength, streamWriter.declaredLength)
|
|
}
|
|
|
|
streamWriter.containerWriter.sectionCount++
|
|
streamWriter.containerWriter.writtenSectionTypes[streamWriter.sectionType] = true
|
|
streamWriter.isFinished = true
|
|
|
|
return nil
|
|
}
|
|
|
|
// Digest liefert die Prüfsumme des geschriebenen Abschnittsinhalts.
|
|
func (streamWriter *SectionStreamWriter) Digest() string {
|
|
return hex.EncodeToString(streamWriter.sectionDigest.Sum(nil))
|
|
}
|
|
|
|
// writeSectionHeader schreibt den Kopf eines Abschnitts.
|
|
func (containerWriter *Writer) writeSectionHeader(sectionType SectionType, sectionFlags SectionFlags, sectionLength int64, sectionDigest []byte) error {
|
|
if sectionLength < 0 || sectionLength > maximumSectionLength {
|
|
return fmt.Errorf("%w: %d byte", ErrSectionTooLarge, sectionLength)
|
|
}
|
|
|
|
headerBuffer := make([]byte, 0, sectionHeaderLength)
|
|
headerBuffer = append(headerBuffer, byte(sectionType), byte(sectionFlags))
|
|
|
|
lengthBytes := make([]byte, 8)
|
|
binary.BigEndian.PutUint64(lengthBytes, uint64(sectionLength))
|
|
headerBuffer = append(headerBuffer, lengthBytes...)
|
|
headerBuffer = append(headerBuffer, sectionDigest...)
|
|
|
|
return containerWriter.writeAndDigest(headerBuffer)
|
|
}
|
|
|
|
// writeAndDigest schreibt Daten und führt die Gesamtprüfsumme mit.
|
|
func (containerWriter *Writer) writeAndDigest(outputData []byte) error {
|
|
bytesWritten, writeError := containerWriter.outputWriter.Write(outputData)
|
|
|
|
if bytesWritten > 0 {
|
|
_, _ = containerWriter.contentDigest.Write(outputData[:bytesWritten])
|
|
containerWriter.bytesWritten += int64(bytesWritten)
|
|
}
|
|
|
|
if writeError != nil {
|
|
return fmt.Errorf("der container konnte nicht geschrieben werden: %w", writeError)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Close schreibt den Footer und schließt den Container ab.
|
|
//
|
|
// Erst danach ist der Container gültig. Ein Abbruch vor diesem Aufruf
|
|
// hinterlässt eine Datei ohne Abschlussvermerk, die beim Lesen zuverlässig als
|
|
// unvollständig erkannt wird.
|
|
func (containerWriter *Writer) Close() error {
|
|
if containerWriter.isClosed {
|
|
return ErrWriterClosed
|
|
}
|
|
|
|
// Ein Container ohne Manifest beschreibt kein Backup. Ihn abzuschließen
|
|
// hiesse, ein leeres Gebilde als gültig auszugeben (PROMPT.md §138).
|
|
if !containerWriter.writtenSectionTypes[SectionManifest] {
|
|
return fmt.Errorf("%w: es fehlt der Abschnitt %s", ErrMissingSection, SectionManifest)
|
|
}
|
|
|
|
containerFooter := ContainerFooter{
|
|
ManifestHash: containerWriter.manifestHash,
|
|
ContentHash: hex.EncodeToString(containerWriter.contentDigest.Sum(nil)),
|
|
SectionCount: containerWriter.sectionCount,
|
|
TotalBytes: containerWriter.bytesWritten,
|
|
CompletedAt: containerWriter.timeSource().UTC(),
|
|
Complete: true,
|
|
}
|
|
|
|
encodedFooter, marshalError := json.Marshal(containerFooter)
|
|
if marshalError != nil {
|
|
return fmt.Errorf("der container-footer konnte nicht erzeugt werden: %w", marshalError)
|
|
}
|
|
|
|
// Der Footer wird nicht in die Gesamtprüfsumme aufgenommen: er enthält sie.
|
|
if _, writeError := containerWriter.outputWriter.Write(footerMagic[:]); writeError != nil {
|
|
return fmt.Errorf("der container-footer konnte nicht geschrieben werden: %w", writeError)
|
|
}
|
|
|
|
footerLengthBytes := make([]byte, headerLengthFieldLength)
|
|
binary.BigEndian.PutUint32(footerLengthBytes, uint32(len(encodedFooter)))
|
|
|
|
if _, writeError := containerWriter.outputWriter.Write(footerLengthBytes); writeError != nil {
|
|
return fmt.Errorf("der container-footer konnte nicht geschrieben werden: %w", writeError)
|
|
}
|
|
|
|
if _, writeError := containerWriter.outputWriter.Write(encodedFooter); writeError != nil {
|
|
return fmt.Errorf("der container-footer konnte nicht geschrieben werden: %w", writeError)
|
|
}
|
|
|
|
containerWriter.isClosed = true
|
|
|
|
return nil
|
|
}
|
|
|
|
// BytesWritten liefert die bisher geschriebene Menge ohne den Footer.
|
|
func (containerWriter *Writer) BytesWritten() int64 {
|
|
return containerWriter.bytesWritten
|
|
}
|