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>
294 lines
11 KiB
Go
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
|
|
}
|