syncova-backup/packages/backupexecutor/proxmox_backup.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

369 lines
14 KiB
Go

package backupexecutor
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"strings"
"time"
"github.com/syncova/syncova/packages/backupengine"
"github.com/syncova/syncova/packages/hypervisor"
"github.com/syncova/syncova/packages/jobs"
"github.com/syncova/syncova/packages/providers"
"github.com/syncova/syncova/packages/repository"
"github.com/syncova/syncova/packages/scheduler"
)
// guestArchiveEntryPath ist der Pfad des Plattenabbilds im Manifest.
//
// Fest und nicht aus dem Dateinamen des vzdump-Archivs abgeleitet: Der trägt
// einen Zeitstempel, und ein Manifestpfad, der sich bei jedem Lauf ändert,
// machte jede Deduplizierung über Backupgrenzen hinweg unmöglich — die Engine
// vergleicht Objekte über ihren Pfad.
const guestArchiveEntryPath = "guest/disk-image.vma"
// guestConfigurationEntryPath ist der Pfad der Gastkonfiguration im Manifest.
const guestConfigurationEntryPath = "guest/configuration.json"
// backupProxmoxGuest sichert einen Gast einer Virtualisierungsumgebung.
//
// Der Weg unterscheidet sich grundlegend von einer Dateisystemquelle: Es gibt
// keine Dateien zum Durchlaufen, sondern **einen** Datenstrom — das von vzdump
// erzeugte Archiv. Er wandert unverändert durch dieselbe Pipeline aus
// Chunking, Deduplizierung, Kompression und Verschlüsselung.
//
// Der Gewinn der inhaltsabhängigen Blockfindung ist hier besonders groß: Zwei
// Sicherungen derselben Maschine unterscheiden sich nur in den tatsächlich
// geänderten Bereichen, und die Deduplizierung findet sie auch dann wieder,
// wenn sich das Archiv an einer Stelle verschoben hat.
func (executor *Executor) backupProxmoxGuest(backupContext context.Context,
backupRequest sourceBackupRequest) (jobs.ExecutionResult, error) {
if executor.options.HypervisorStore == nil {
return jobs.ExecutionResult{}, &jobs.ExecutionError{
Code: "HYPERVISOR_NOT_CONFIGURED",
Message: "Für Proxmox-Quellen ist keine Virtualisierungsverwaltung eingerichtet. " +
"Ohne Schlüsselmaterial lassen sich keine Zugangsdaten ablegen.",
FailureClass: scheduler.FailureConfiguration,
}
}
if backupRequest.Source.ClusterID == nil {
return jobs.ExecutionResult{}, &jobs.ExecutionError{
Code: "CLUSTER_NOT_ASSIGNED",
Message: fmt.Sprintf("Der Quelle %q ist kein Virtualisierungsverbund zugewiesen.",
backupRequest.Source.SourceID),
FailureClass: scheduler.FailureConfiguration,
}
}
if backupRequest.Engine == nil {
return jobs.ExecutionResult{}, errors.New("fuer die sicherung eines gasts wird eine geoeffnete backup engine gebraucht")
}
connectedProvider, openError := executor.options.HypervisorStore.OpenProvider(backupContext,
*backupRequest.Source.ClusterID, backupRequest.Logger)
if openError != nil {
return jobs.ExecutionResult{}, &jobs.ExecutionError{
Code: "HYPERVISOR_UNREACHABLE",
Message: "Der Virtualisierungsverbund ist nicht erreichbar: " + openError.Error(),
FailureClass: scheduler.FailureNetwork,
Cause: openError,
}
}
defer func() {
if closeError := connectedProvider.Close(); closeError != nil {
backupRequest.Logger.Warn("die verbindung zum verbund liess sich nicht schliessen",
slog.String("grund", closeError.Error()))
}
}()
guestIdentifier := backupRequest.Source.SourceID
// Die Konfiguration wird **vor** dem Archiv gelesen.
//
// Sie ist das, was eine Wiederherstellung braucht, um die Maschine in ihrer
// Gestalt aufzubauen — falsche Netzkarte, fehlende serielle Schnittstelle,
// anderes BIOS ergäben eine bootfähige, aber unbrauchbare VM. Sie danach zu
// lesen hieße, sie nach einem stundenlangen vzdump zu holen; scheiterte sie
// dann, wäre die ganze Sicherung umsonst gewesen.
guestMetadata, metadataError := connectedProvider.Provider.GetVMMetaData(backupContext, guestIdentifier)
if metadataError != nil {
return jobs.ExecutionResult{}, &jobs.ExecutionError{
Code: "GUEST_METADATA_UNAVAILABLE",
Message: "Die Konfiguration des Gasts ließ sich nicht lesen: " + metadataError.Error(),
FailureClass: scheduler.FailureSource,
Cause: metadataError,
}
}
guestDisks, diskError := connectedProvider.Provider.GetVMDisks(backupContext, guestIdentifier)
if diskError != nil {
return jobs.ExecutionResult{}, &jobs.ExecutionError{
Code: "GUEST_DISKS_UNAVAILABLE",
Message: "Die Platten des Gasts ließen sich nicht ermitteln: " + diskError.Error(),
FailureClass: scheduler.FailureSource,
Cause: diskError,
}
}
// Eine von der Sicherung ausgenommene Platte ist keine Nebensache: Die
// Wiederherstellung liefert dann eine unvollständige Maschine, und wer es
// nicht weiß, hält sie für vollständig. Sie wird als übergangenes Objekt
// ausgewiesen und macht den Lauf damit zum Teilfehler.
var skippedDiskReasons []string
for _, singleDisk := range guestDisks {
if singleDisk.ExcludedFromBackup {
skippedDiskReasons = append(skippedDiskReasons, fmt.Sprintf(
"%s: die platte ist in proxmox von der sicherung ausgenommen (backup=0)",
singleDisk.Identifier))
}
}
chainIdentifier, chainError := executor.store.EnsureChain(backupContext,
backupRequest.Source.SourceReference(), backupRequest.RepositoryRecord.ID)
if chainError != nil {
return jobs.ExecutionResult{}, chainError
}
parentBackupID, parentBackupInRepository, parentError := executor.store.FindLatestBackup(backupContext, chainIdentifier)
if parentError != nil {
return jobs.ExecutionResult{}, parentError
}
backupIdentifier := buildBackupIdentifier(backupRequest.RunID, backupRequest.Source)
startTime := time.Now().UTC()
backupRequest.Logger.Info("gast wird gesichert",
slog.String("verbund", connectedProvider.Cluster.Name),
slog.String("gast", guestIdentifier),
slog.String("backup_id", backupIdentifier),
slog.Int("platten", len(guestDisks)),
slog.Int("ausgenommen", len(skippedDiskReasons)))
archiveStream, openArchiveError := connectedProvider.Provider.OpenDisk(backupContext,
providers.DiskReadRequest{GuestID: guestIdentifier})
if openArchiveError != nil {
return jobs.ExecutionResult{}, &jobs.ExecutionError{
Code: "GUEST_ARCHIVE_UNAVAILABLE",
Message: "Das Sicherungsarchiv des Gasts ließ sich nicht lesen: " + openArchiveError.Error(),
FailureClass: classifyProviderFailure(openArchiveError),
Cause: openArchiveError,
}
}
// Der Datenstrom wird in jedem Fall geschlossen — dabei räumt der Provider
// das Archiv auf dem Knoten ab. Ohne diesen Schritt füllte jede Sicherung
// den Proxmox-Speicher mit einer zweiten, unverwalteten Kopie derselben
// Daten, für die keine Aufbewahrungsregel gilt.
defer func() {
if closeError := archiveStream.Close(); closeError != nil {
backupRequest.Logger.Warn("das sicherungsarchiv liess sich nicht schliessen",
slog.String("grund", closeError.Error()))
}
}()
encodedConfiguration, encodeError := json.MarshalIndent(guestConfigurationDocument{
GuestID: guestIdentifier,
ClusterName: connectedProvider.Cluster.Name,
ArchiveVolumeName: archiveFileNameFor(backupIdentifier),
Metadata: guestMetadata,
Disks: guestDisks,
CapturedAt: startTime,
}, "", " ")
if encodeError != nil {
return jobs.ExecutionResult{}, fmt.Errorf("die gastkonfiguration liess sich nicht ablegen: %w", encodeError)
}
backupType := repository.BackupTypeFull
if parentBackupInRepository != "" {
// Ein „inkrementelles" Backup ist hier eines dem Namen nach: vzdump
// liest immer die ganze Maschine, weil Proxmox geänderte Blöcke über
// die REST-API nicht herausgibt. Gespart wird ausschließlich Platz —
// durch die Deduplizierung —, keine Lesezeit. Das ist genau umgekehrt
// zur Dateisystemquelle und steht so in docs/proxmox.md.
backupType = repository.BackupTypeIncremental
}
backupOptions := backupengine.BackupOptions{
BackupID: backupIdentifier,
ChainID: chainIdentifier.String(),
ParentBackupID: parentBackupInRepository,
BackupType: backupType,
Source: repository.SourceInformation{
SourceType: string(backupRequest.Source.SourceType),
SourceID: guestIdentifier,
SourceName: backupRequest.Source.SourceName,
Hostname: guestNameFromMetadata(guestMetadata, backupRequest.Source.SourceName),
Attributes: map[string]string{
"cluster": connectedProvider.Cluster.Name,
"cluster_id": backupRequest.Source.ClusterID.String(),
"guest_id": guestIdentifier,
"firmware_type": guestMetadata.FirmwareType,
"disk_count": fmt.Sprintf("%d", len(guestDisks)),
"excluded_disks": fmt.Sprintf("%d", len(skippedDiskReasons)),
"archive_transport": string(connectedProvider.Cluster.ArchiveTransport),
},
},
CompressionLevel: executor.options.CompressionLevel,
EncryptionEnabled: executor.options.SecretStore != nil,
CreatedByVersion: executor.options.CreatedByVersion,
BandwidthLimiter: backupRequest.Limiter,
}
backupSources := []backupengine.BackupSource{
{
Path: guestConfigurationEntryPath,
EntryType: "file",
SizeBytes: int64(len(encodedConfiguration)),
ModifiedAt: startTime,
Mode: "0600",
Reader: bytes.NewReader(encodedConfiguration),
},
{
// Die Größe bleibt unbekannt: vzdump nennt sie erst, wenn es fertig
// ist, und ein geschätzter Wert im Manifest wäre eine Zahl, der
// niemand ansieht, dass sie geraten ist.
Path: guestArchiveEntryPath,
EntryType: "disk",
ModifiedAt: startTime,
Mode: "0600",
Reader: archiveStream,
},
}
backupResult, backupError := backupRequest.Engine.Backup(backupContext, backupOptions, backupSources)
if backupError != nil {
return jobs.ExecutionResult{}, backupError
}
executionResult := jobs.ExecutionResult{
BytesProcessed: backupResult.Progress.BytesProcessed,
BytesWritten: backupResult.Progress.BytesWritten,
FilesProcessed: int64(backupResult.EntryCount),
FilesSkipped: int64(len(skippedDiskReasons)),
SkipReasons: skippedDiskReasons,
}
executor.recordBackup(backupContext, recordRequest{
BackupRequest: backupRequest,
BackupIdentifier: backupIdentifier,
ChainIdentifier: chainIdentifier,
ParentBackupID: parentBackupID,
IsIncremental: backupType == repository.BackupTypeIncremental,
Result: executionResult,
StartedAt: startTime,
// Von den sechs Signalen der Phase 16 tragen hier nur die
// blockbezogenen etwas bei: Es gibt keine Dateien zu zählen, sondern
// ein Archiv. Der Anteil nicht verkleinerbarer Blöcke ist dafür das
// aussagekräftigste Signal überhaupt — verschlüsselt jemand **im**
// Gast, schlägt genau das aus.
Signals: ransomwareSignals{
IncompressibleChunks: backupResult.Progress.ChunksIncompressible,
NewChunkCount: backupResult.Progress.ChunksWritten,
},
})
backupRequest.Logger.Info("gast gesichert",
slog.String("gast", guestIdentifier),
slog.Int64("bytes_gelesen", executionResult.BytesProcessed),
slog.Int64("bytes_abgelegt", executionResult.BytesWritten),
slog.Int64("uebergangen", executionResult.FilesSkipped))
return executionResult, nil
}
// guestConfigurationDocument ist die im Backup abgelegte Gastbeschreibung.
//
// Sie liegt als eigenes Objekt im Manifest und nicht nur in dessen Attributen:
// Ein Manifest ist eine Liste von Objekten mit Blockverweisen, kein Ablageort
// für beliebige Dokumente. Und sie muss eine Wiederherstellung überstehen, die
// ohne Control Plane auskommt — das Repository ist selbstbeschreibend.
type guestConfigurationDocument struct {
// GuestID ist die Kennung des Gasts beim Provider.
GuestID string `json:"guest_id"`
// ClusterName ist der Verbund, aus dem er stammt.
ClusterName string `json:"cluster_name"`
// ArchiveVolumeName ist der Dateiname, unter dem das Archiv zurückgeschrieben wird.
ArchiveVolumeName string `json:"archive_volume_name"`
// Metadata ist die Konfiguration des Gasts.
Metadata *providers.GuestMetadata `json:"metadata"`
// Disks sind die erkannten Platten samt Ausnahmen.
Disks []providers.Disk `json:"disks"`
// CapturedAt ist der Zeitpunkt der Aufnahme.
CapturedAt time.Time `json:"captured_at"`
}
// archiveFileNameFor bildet den Dateinamen des zurückzuschreibenden Archivs.
//
// Er trägt die Backup-Kennung, damit auf dem Proxmox-Knoten erkennbar bleibt,
// welche Wiederherstellung das Archiv abgelegt hat — und damit zwei
// gleichzeitige Wiederherstellungen sich nicht dieselbe Datei überschreiben.
func archiveFileNameFor(backupIdentifier string) string {
sanitizedIdentifier := strings.Map(func(currentRune rune) rune {
switch {
case currentRune >= 'a' && currentRune <= 'z',
currentRune >= 'A' && currentRune <= 'Z',
currentRune >= '0' && currentRune <= '9',
currentRune == '-', currentRune == '_':
return currentRune
default:
return '-'
}
}, backupIdentifier)
return "syncova-restore-" + sanitizedIdentifier + ".vma"
}
// guestNameFromMetadata liefert den Namen des Gasts.
func guestNameFromMetadata(guestMetadata *providers.GuestMetadata, fallbackName string) string {
if guestMetadata == nil {
return fallbackName
}
if hostName, hasName := guestMetadata.RawConfiguration["name"]; hasName && hostName != "" {
return hostName
}
// Container tragen ihren Namen unter einem anderen Schlüssel.
if hostName, hasName := guestMetadata.RawConfiguration["hostname"]; hasName && hostName != "" {
return hostName
}
return fallbackName
}
// classifyProviderFailure ordnet einen Providerfehler einer Fehlerklasse zu.
//
// Die Einordnung entscheidet über die Wiederholung. Ein fehlender Zugriffsweg
// auf die Archive behebt sich nicht durch Warten — ihn als vorübergehend zu
// führen ergäbe eine Sicherung, die jede Nacht dreimal scheitert und dabei
// jedes Mal ein vzdump über die ganze Maschine laufen lässt.
func classifyProviderFailure(providerError error) scheduler.FailureClass {
if errors.Is(providerError, hypervisorTransportMissing) {
return scheduler.FailureConfiguration
}
if errors.Is(providerError, providers.ErrGuestNotFound) {
return scheduler.FailureSource
}
if errors.Is(providerError, providers.ErrNotSupported) {
return scheduler.FailureConfiguration
}
return scheduler.FailureTransient
}
// hypervisorTransportMissing verweist auf den fehlenden Zugriffsweg.
//
// Über eine Variable statt eines direkten Imports, damit dieses Paket nicht
// vom Proxmox-Provider abhängt — die Schichtung verlangt, dass die
// Ausführungsschleife providerneutral bleibt.
var hypervisorTransportMissing = hypervisor.ErrArchiveTransportMissing