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

294 lines
11 KiB
Go

package repository
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"time"
)
// ManifestVersion ist die Version des Manifestformats.
const ManifestVersion = 1
// BackupType ist die Art eines Backups (PROMPT.md §7).
type BackupType string
const (
// BackupTypeFull ist eine vollständige Sicherung.
BackupTypeFull BackupType = "full"
// BackupTypeIncremental enthält nur die Änderungen seit dem Elternbackup.
BackupTypeIncremental BackupType = "incremental"
// BackupTypeSyntheticFull entsteht durch Zusammenführen bestehender Backups.
BackupTypeSyntheticFull BackupType = "synthetic_full"
)
// ConsistencyLevel beschreibt die Konsistenz der gesicherten Daten (PROMPT.md §147).
//
// Die Angabe muss im Backup sichtbar sein: sie entscheidet darüber, ob eine
// Anwendung nach der Wiederherstellung sauber startet.
type ConsistencyLevel string
const (
// ConsistencyCrash entspricht dem Zustand nach einem Stromausfall.
ConsistencyCrash ConsistencyLevel = "crash_consistent"
// ConsistencyApplication bedeutet, dass die Anwendung ihre Daten vorher stillgelegt hat.
ConsistencyApplication ConsistencyLevel = "application_consistent"
// ConsistencyVerified bedeutet, dass eine Wiederherstellung erfolgreich geprüft wurde.
ConsistencyVerified ConsistencyLevel = "verified"
)
// SourceInformation beschreibt die Herkunft eines Backups.
//
// Die Angaben liegen bewusst im Manifest und nicht nur in der Datenbank: nach
// einem Verlust des Control Servers muss erkennbar bleiben, wovon ein Backup
// stammt (PROMPT.md §47).
type SourceInformation struct {
// SourceType benennt die Art der Quelle, z. B. proxmox_vm oder filesystem.
SourceType string `json:"source_type"`
// SourceID ist die Kennung der Quelle in der Control Plane.
SourceID string `json:"source_id"`
// SourceName ist der sprechende Name der Quelle.
SourceName string `json:"source_name"`
// Hostname ist der Rechnername der Quelle, sofern bekannt.
Hostname string `json:"hostname,omitempty"`
// OperatingSystem beschreibt das Betriebssystem der Quelle.
OperatingSystem string `json:"operating_system,omitempty"`
// Attributes trägt weitere quellenspezifische Angaben.
Attributes map[string]string `json:"attributes,omitempty"`
}
// ManifestEntry beschreibt ein gesichertes Objekt, etwa eine Datei oder Disk.
type ManifestEntry struct {
// Path ist der Pfad des Objekts in der Quelle.
Path string `json:"path"`
// EntryType benennt die Art des Objekts (file, directory, disk, symlink).
EntryType string `json:"type"`
// SizeBytes ist die ursprüngliche Größe in Byte.
SizeBytes int64 `json:"size_bytes"`
// ModifiedAt ist der Änderungszeitpunkt in UTC.
ModifiedAt time.Time `json:"modified_at,omitempty"`
// Mode sind die Dateirechte in oktaler Schreibweise.
Mode string `json:"mode,omitempty"`
// LinkTarget ist das Ziel eines symbolischen Verweises.
LinkTarget string `json:"link_target,omitempty"`
// Chunks sind die Datenblöcke des Objekts in ihrer Reihenfolge.
Chunks []ChunkReference `json:"chunks,omitempty"`
// ContentHash ist die Prüfsumme des Gesamtinhalts.
//
// Sie erlaubt es, die Wiederherstellung eines Objekts zu prüfen, ohne alle
// Chunk-Prüfsummen einzeln nachzurechnen.
ContentHash string `json:"content_hash,omitempty"`
}
// Manifest beschreibt ein vollständiges Backup.
//
// Es ist die maßgebliche Beschreibung eines Backups. PostgreSQL hält davon nur
// eine Kopie zur schnellen Abfrage; verbindlich ist das Manifest im Repository
// (PROMPT.md §2.4).
type Manifest struct {
// ManifestVersion ist die Version des Manifestformats.
ManifestVersion int `json:"manifest_version"`
// BackupID ist die Kennung dieses Backups.
BackupID string `json:"backup_id"`
// ChainID verbindet ein Backup mit seiner Kette aus Voll- und Zusatzsicherungen.
ChainID string `json:"chain_id"`
// ParentBackupID benennt das Elternbackup einer Zusatzsicherung.
ParentBackupID string `json:"parent_backup_id,omitempty"`
// BackupType ist die Art des Backups.
BackupType BackupType `json:"backup_type"`
// ConsistencyLevel beschreibt die Konsistenz der Daten.
ConsistencyLevel ConsistencyLevel `json:"consistency_level"`
// Source beschreibt die Herkunft der Daten.
Source SourceInformation `json:"source"`
// Entries sind die gesicherten Objekte.
Entries []ManifestEntry `json:"entries"`
// StartedAt ist der Beginn des Backups in UTC.
StartedAt time.Time `json:"started_at"`
// CompletedAt ist der Abschluss des Backups in UTC.
CompletedAt time.Time `json:"completed_at"`
// Statistics sind die Kennzahlen des Laufs.
Statistics SessionStatistics `json:"statistics"`
// EncryptionKeyVersion benennt den zur Entschlüsselung nötigen Schlüssel.
//
// Das Feld bleibt bis Phase 4 leer, ist aber Teil des Formats: eine spätere
// Ergänzung wäre ein Formatbruch.
EncryptionKeyVersion string `json:"encryption_key_version,omitempty"`
// CompressionAlgorithm benennt das verwendete Kompressionsverfahren.
CompressionAlgorithm string `json:"compression_algorithm,omitempty"`
// ImmutableUntil ist das Ende der Aufbewahrungspflicht in UTC.
ImmutableUntil *time.Time `json:"immutable_until,omitempty"`
// SelfContainedRestore meldet ein Manifest, das ohne seine Kette ausreicht.
//
// Bei Syncova ist das Manifest einer Zusatzsicherung **vollständig**:
// unveränderte Objekte tragen die Blockverweise des Elternbackups. Ein
// Restore liest deshalb genau ein Manifest, und das Löschen eines alten
// Backups kann ein neueres nicht beschädigen.
//
// Das Feld hält diese Zusicherung im Manifest selbst fest, statt sie
// vorauszusetzen. Der Grund ist die Aufbewahrung: Ohne die Angabe müsste sie
// jedes Elternbackup einer Kette behalten — und räumte damit **nie** auf.
// Ein fehlendes Feld (ältere Manifeste, fremde Repositories) bedeutet
// „unbekannt" und schützt die Kette weiterhin.
SelfContainedRestore bool `json:"self_contained_restore,omitempty"`
// CreatedByVersion ist die Programmversion, die das Backup erzeugt hat.
CreatedByVersion string `json:"created_by_version"`
// ContentHash ist die Prüfsumme über alle Felder außer diesem und dem Abschlussvermerk.
//
// Er ist der Abschlussvermerk des Manifests: fehlt er oder passt er nicht,
// gilt das Backup als unvollständig (SYNCOVA_ARCHITECTURE.md §10).
ContentHash string `json:"content_hash"`
// Complete ist der ausdrückliche Abschlussvermerk.
Complete bool `json:"complete"`
}
// TotalChunkCount zählt alle Chunk-Verweise des Manifests.
func (manifest *Manifest) TotalChunkCount() int64 {
var chunkCount int64
for _, manifestEntry := range manifest.Entries {
chunkCount += int64(len(manifestEntry.Chunks))
}
return chunkCount
}
// UniqueChunkIdentifiers liefert die Menge aller im Manifest benannten Chunks.
//
// Mehrfach verwendete Chunks erscheinen nur einmal — genau das ist der Zweck
// der Deduplizierung.
func (manifest *Manifest) UniqueChunkIdentifiers() map[string]struct{} {
uniqueIdentifiers := make(map[string]struct{})
for _, manifestEntry := range manifest.Entries {
for _, chunkReference := range manifestEntry.Chunks {
uniqueIdentifiers[chunkReference.Identifier] = struct{}{}
}
}
return uniqueIdentifiers
}
// UniqueChunkReferences liefert je Chunk einen Verweis samt Prüfsumme der
// abgelegten Form.
//
// Der Unterschied zu UniqueChunkIdentifiers ist für die Integritätsprüfung
// entscheidend: Bei einem transformierten Block beschreibt die Kennung den
// Klartext, nicht die abgelegten Bytes. Ohne StoredDigest liesse sich ein
// verschlüsselter Block nicht prüfen — jeder Vergleich gegen die Kennung
// schlüge fehl und meldete einen Fehlalarm.
func (manifest *Manifest) UniqueChunkReferences() map[string]ChunkReference {
uniqueReferences := make(map[string]ChunkReference)
for _, manifestEntry := range manifest.Entries {
for _, chunkReference := range manifestEntry.Chunks {
if _, alreadySeen := uniqueReferences[chunkReference.Identifier]; alreadySeen {
continue
}
uniqueReferences[chunkReference.Identifier] = chunkReference
}
}
return uniqueReferences
}
// IsRetentionLocked meldet, ob das Backup noch unter Aufbewahrungsschutz steht.
func (manifest *Manifest) IsRetentionLocked(referenceTime time.Time) bool {
if manifest.ImmutableUntil == nil {
return false
}
return referenceTime.Before(*manifest.ImmutableUntil)
}
// computeManifestHash bildet die Prüfsumme eines Manifests.
//
// ContentHash und Complete bleiben ausgespart: sie werden erst aus dem Ergebnis
// gesetzt und dürfen es deshalb nicht beeinflussen.
func computeManifestHash(manifest *Manifest) (string, error) {
// Die Kopie erhält die auszusparenden Felder in ihrem Nullwert.
manifestForHashing := *manifest
manifestForHashing.ContentHash = ""
manifestForHashing.Complete = false
// json.Marshal sortiert Struktur-Felder in Deklarationsreihenfolge und
// Map-Schlüssel alphabetisch. Die Ausgabe ist damit reproduzierbar, was
// Voraussetzung für einen stabilen Hash ist.
encodedManifest, marshalError := json.Marshal(manifestForHashing)
if marshalError != nil {
return "", fmt.Errorf("das manifest konnte nicht für die prüfsumme serialisiert werden: %w", marshalError)
}
manifestDigest := sha256.Sum256(encodedManifest)
return hex.EncodeToString(manifestDigest[:]), nil
}
// sealManifest versieht ein Manifest mit Prüfsumme und Abschlussvermerk.
//
// Erst danach gilt ein Backup als abgeschlossen.
func sealManifest(manifest *Manifest) error {
manifestHash, hashError := computeManifestHash(manifest)
if hashError != nil {
return hashError
}
manifest.ContentHash = manifestHash
manifest.Complete = true
return nil
}
// VerifyManifest prüft Prüfsumme und Abschlussvermerk eines Manifests.
//
// Ein Manifest ohne gültigen Abschluss beschreibt kein verwendbares Backup.
// Es als erfolgreich zu behandeln wäre der schwerste denkbare Fehler dieses
// Produkts (PROMPT.md §140).
func VerifyManifest(manifest *Manifest) error {
if manifest.ManifestVersion > ManifestVersion {
return fmt.Errorf("%w: das manifest hat Version %d, unterstützt wird bis Version %d",
ErrUnsupportedFormat, manifest.ManifestVersion, ManifestVersion)
}
if !manifest.Complete {
return ErrBackupIncomplete
}
if manifest.ContentHash == "" {
return fmt.Errorf("%w: dem manifest fehlt die prüfsumme", ErrBackupIncomplete)
}
expectedHash, hashError := computeManifestHash(manifest)
if hashError != nil {
return hashError
}
if expectedHash != manifest.ContentHash {
return fmt.Errorf("%w: die prüfsumme des manifests stimmt nicht", ErrManifestCorrupted)
}
return nil
}
// encodeManifest serialisiert ein Manifest.
func encodeManifest(manifest *Manifest) ([]byte, error) {
// Eingerückt, damit ein Administrator das Manifest bei einer Notfallanalyse
// von Hand lesen kann.
encodedManifest, marshalError := json.MarshalIndent(manifest, "", " ")
if marshalError != nil {
return nil, fmt.Errorf("das manifest konnte nicht erzeugt werden: %w", marshalError)
}
return append(encodedManifest, '\n'), nil
}
// decodeManifest liest ein Manifest.
func decodeManifest(rawManifest []byte) (*Manifest, error) {
var manifest Manifest
if unmarshalError := json.Unmarshal(rawManifest, &manifest); unmarshalError != nil {
return nil, fmt.Errorf("%w: das manifest ist unlesbar", ErrManifestCorrupted)
}
return &manifest, nil
}