syncova-backup/packages/providers/proxmox/restore.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

501 lines
18 KiB
Go

package proxmox
import (
"context"
"errors"
"fmt"
"log/slog"
"net/url"
"sort"
"strconv"
"strings"
"time"
"github.com/syncova/syncova/packages/providers"
)
// ErrTargetGuestExists meldet eine bereits belegte Zielkennung.
var ErrTargetGuestExists = errors.New("unter der zielkennung besteht bereits ein gast")
// ErrNoFreeGuestIdentifier meldet einen erschöpften Kennungsbereich.
var ErrNoFreeGuestIdentifier = errors.New("es war keine freie gastkennung zu finden")
// minimumUserGuestID ist die kleinste von Proxmox für Gäste zugelassene Kennung.
//
// Die Zahlen darunter sind reserviert; eine Wiederherstellung dorthin wird
// abgelehnt.
const minimumUserGuestID = 100
// maximumUserGuestID ist die größte zulässige Gastkennung.
const maximumUserGuestID = 999999999
// RestoreVM stellt einen Gast wieder her.
//
// Die drei Zielarten aus SYNCOVA_ARCHITECTURE.md §12 unterscheiden sich nur in
// wenigen Feldern, führen aber zu sehr verschiedenen Risiken:
//
// - Ursprungsort: überschreibt die vorhandene Maschine — der gefährlichste
// Fall, deshalb verlangt er ausdrückliche Zustimmung.
// - Anderer Wirt: dieselbe Kennung auf einem anderen Knoten.
// - Neuer Gast: lässt das Original unberührt. Das ist der Weg für einen
// Wiederherstellungstest, ohne den Betrieb anzufassen.
func (provider *Provider) RestoreVM(restoreContext context.Context, restoreRequest providers.RestoreRequest) (*providers.RestoreResult, error) {
if provider.client == nil {
return nil, providers.ErrNotConnected
}
startTime := time.Now()
if strings.TrimSpace(restoreRequest.ArchiveReference) == "" {
return nil, errors.New("es wurde kein wiederherzustellendes archiv angegeben")
}
restorePlan, planError := provider.buildRestorePlan(restoreContext, restoreRequest)
if planError != nil {
return nil, planError
}
reportProgress(restoreRequest.ProgressCallback, "vorbereitung", 0,
fmt.Sprintf("Ziel: %s %d auf Knoten %s", restorePlan.APISegment, restorePlan.TargetVMID, restorePlan.TargetNode))
formValues := url.Values{}
formValues.Set("vmid", strconv.Itoa(restorePlan.TargetVMID))
formValues.Set("archive", restoreRequest.ArchiveReference)
// Der Gast wird nie unaufgefordert gestartet. Eine wiederhergestellte
// Maschine, die sich mit derselben Adresse ins Netz meldet wie das noch
// laufende Original, richtet mehr Schaden an als der Ausfall.
formValues.Set("start", "0")
if restorePlan.OverwriteExisting {
formValues.Set("force", "1")
}
if restoreRequest.TargetStorageID != "" {
formValues.Set("storage", restoreRequest.TargetStorageID)
}
restorePath := fmt.Sprintf("/nodes/%s/%s",
url.PathEscape(restorePlan.TargetNode), restorePlan.APISegment)
provider.logger.Info("wiederherstellung angestoßen",
slog.String("ziel_art", string(restoreRequest.TargetKind)),
slog.Int("ziel_vmid", restorePlan.TargetVMID),
slog.String("ziel_knoten", restorePlan.TargetNode),
slog.Bool("ueberschreibt", restorePlan.OverwriteExisting))
var taskIdentifier TaskIdentifier
if createError := provider.client.post(restoreContext, restorePath, formValues, &taskIdentifier); createError != nil {
return nil, fmt.Errorf("die wiederherstellung konnte nicht angestoßen werden: %w", createError)
}
reportProgress(restoreRequest.ProgressCallback, "wiederherstellung", 10, string(taskIdentifier))
// Keine Zeitgrenze: das Zurückspielen mehrerer Terabyte dauert.
taskStatus, waitError := provider.client.WaitForTask(restoreContext, taskIdentifier, TaskWaitOptions{
PollInterval: provider.options.TaskPollInterval,
ProgressCallback: func(currentStatus TaskStatus) {
reportProgress(restoreRequest.ProgressCallback, "wiederherstellung", 50, currentStatus.Status)
},
})
if waitError != nil {
return nil, waitError
}
restoreResult := &providers.RestoreResult{
GuestID: formatGuestIdentifier(restorePlan.APISegment, restorePlan.TargetVMID),
HostID: restorePlan.TargetNode,
Warnings: restorePlan.Warnings,
}
if taskStatus.HasWarnings() {
restoreResult.Warnings = append(restoreResult.Warnings,
"Proxmox meldete: "+taskStatus.ExitStatus)
}
// Die Konfiguration wird nach dem Zurückspielen der Daten gesetzt. Vorher
// ginge es nicht: Der Gast existiert noch nicht.
if restoreRequest.Metadata != nil {
reportProgress(restoreRequest.ProgressCallback, "konfiguration", 80, "")
configurationWarnings, configurationError := provider.applyMetadata(restoreContext, restorePlan, restoreRequest.Metadata)
if configurationError != nil {
// Die Daten liegen bereits richtig. Ein Fehler hier macht die
// Wiederherstellung unvollständig, nicht wertlos — deshalb wird er
// gemeldet, ohne das Ergebnis zu verwerfen.
return restoreResult, fmt.Errorf("die daten wurden zurückgespielt, die konfiguration jedoch nicht vollständig gesetzt: %w", configurationError)
}
restoreResult.Warnings = append(restoreResult.Warnings, configurationWarnings...)
}
if restoreRequest.StartAfterRestore {
reportProgress(restoreRequest.ProgressCallback, "start", 95, "")
if startError := provider.startGuest(restoreContext, restorePlan); startError != nil {
return restoreResult, fmt.Errorf("der gast wurde wiederhergestellt, ließ sich aber nicht starten: %w", startError)
}
restoreResult.Started = true
}
restoreResult.Duration = time.Since(startTime)
reportProgress(restoreRequest.ProgressCallback, "abgeschlossen", 100, "")
provider.logger.Info("wiederherstellung abgeschlossen",
slog.String("gast", restoreResult.GuestID),
slog.String("knoten", restoreResult.HostID),
slog.Bool("gestartet", restoreResult.Started),
slog.String("dauer", restoreResult.Duration.String()))
return restoreResult, nil
}
// restorePlan ist die aufgelöste Zielbeschreibung einer Wiederherstellung.
type restorePlan struct {
// TargetNode ist der Zielknoten.
TargetNode string
// TargetVMID ist die Zielkennung.
TargetVMID int
// APISegment ist "qemu" oder "lxc".
APISegment string
// OverwriteExisting meldet das Überschreiben eines vorhandenen Gasts.
OverwriteExisting bool
// Warnings sind Hinweise, die den Aufrufer erreichen müssen.
Warnings []string
}
// buildRestorePlan löst die Zielbeschreibung auf und prüft sie.
//
// Hier liegen die Schutzmaßnahmen: Ohne sie überschriebe eine
// Wiederherstellung an den Ursprungsort eine laufende Maschine, und eine
// Wiederherstellung als neuer Gast könnte auf einer belegten Kennung landen.
func (provider *Provider) buildRestorePlan(planContext context.Context, restoreRequest providers.RestoreRequest) (*restorePlan, error) {
apiSegment, sourceVMID, parseError := parseGuestIdentifier(restoreRequest.SourceGuestID)
if parseError != nil {
return nil, parseError
}
builtPlan := &restorePlan{
APISegment: apiSegment,
Warnings: make([]string, 0),
}
existingGuests, listError := provider.ListVMs(planContext, "")
if listError != nil {
return nil, listError
}
occupiedIdentifiers := collectOccupiedIdentifiers(existingGuests)
switch restoreRequest.TargetKind {
case providers.RestoreToOriginalHost, providers.RestoreToAlternateHost:
builtPlan.TargetVMID = sourceVMID
if restoreRequest.TargetGuestID != "" {
_, requestedVMID, requestedError := parseGuestIdentifier(restoreRequest.TargetGuestID)
if requestedError != nil {
return nil, requestedError
}
builtPlan.TargetVMID = requestedVMID
}
targetNode, nodeError := provider.resolveTargetNode(planContext, restoreRequest, sourceVMID, apiSegment)
if nodeError != nil {
return nil, nodeError
}
builtPlan.TargetNode = targetNode
// Der eigentliche Schutz: Eine bestehende Maschine wird nur mit
// ausdrücklicher Zustimmung überschrieben, und niemals im Lauf.
if _, isOccupied := occupiedIdentifiers[builtPlan.TargetVMID]; isOccupied {
if !restoreRequest.OverwriteExisting {
return nil, fmt.Errorf("%w (%d); zum überschreiben ist die ausdrückliche zustimmung nötig",
ErrTargetGuestExists, builtPlan.TargetVMID)
}
if runningError := provider.refuseIfRunning(planContext, builtPlan.TargetNode, apiSegment, builtPlan.TargetVMID); runningError != nil {
return nil, runningError
}
builtPlan.OverwriteExisting = true
builtPlan.Warnings = append(builtPlan.Warnings,
fmt.Sprintf("Der bestehende Gast %d wurde überschrieben.", builtPlan.TargetVMID))
}
case providers.RestoreAsNewGuest:
// Eine neue Maschine erhält eine freie Kennung. Das Original bleibt
// unberührt — das ist der Weg für einen Wiederherstellungstest.
if restoreRequest.TargetGuestID != "" {
_, requestedVMID, requestedError := parseGuestIdentifier(restoreRequest.TargetGuestID)
if requestedError != nil {
return nil, requestedError
}
if _, isOccupied := occupiedIdentifiers[requestedVMID]; isOccupied {
return nil, fmt.Errorf("%w (%d)", ErrTargetGuestExists, requestedVMID)
}
builtPlan.TargetVMID = requestedVMID
} else {
freeIdentifier, identifierError := findFreeGuestIdentifier(occupiedIdentifiers, sourceVMID)
if identifierError != nil {
return nil, identifierError
}
builtPlan.TargetVMID = freeIdentifier
}
targetNode, nodeError := provider.resolveTargetNode(planContext, restoreRequest, sourceVMID, apiSegment)
if nodeError != nil {
return nil, nodeError
}
builtPlan.TargetNode = targetNode
// Die MAC-Adresse ist der Grund für diese Warnung: Zwei Maschinen mit
// derselben Adresse im selben Netz stören einander. Wer als neuen Gast
// wiederherstellt, muss das wissen.
builtPlan.Warnings = append(builtPlan.Warnings,
fmt.Sprintf("Der Gast wurde als neue Maschine %d angelegt. Prüfen Sie die Netzwerkeinstellungen, "+
"bevor Sie ihn starten: er trägt dieselben MAC- und IP-Adressen wie das Original.", builtPlan.TargetVMID))
default:
return nil, fmt.Errorf("die zielart %q ist unbekannt", restoreRequest.TargetKind)
}
if builtPlan.TargetVMID < minimumUserGuestID {
return nil, fmt.Errorf("die kennung %d liegt im reservierten bereich; zulässig sind %d bis %d",
builtPlan.TargetVMID, minimumUserGuestID, maximumUserGuestID)
}
return builtPlan, nil
}
// resolveTargetNode ermittelt den Zielknoten.
func (provider *Provider) resolveTargetNode(nodeContext context.Context, restoreRequest providers.RestoreRequest, sourceVMID int, apiSegment string) (string, error) {
if restoreRequest.TargetHostID != "" {
availableHosts, hostError := provider.ListHosts(nodeContext, "")
if hostError != nil {
return "", hostError
}
for _, availableHost := range availableHosts {
if availableHost.Identifier != restoreRequest.TargetHostID {
continue
}
// Ein Knoten, der nicht antwortet, nimmt keine Wiederherstellung
// an. Das jetzt zu erkennen erspart einen Abbruch nach Stunden.
if !availableHost.Online {
return "", fmt.Errorf("der zielknoten %q ist nicht erreichbar", restoreRequest.TargetHostID)
}
return availableHost.Identifier, nil
}
return "", fmt.Errorf("der zielknoten %q gehört nicht zu diesem verbund", restoreRequest.TargetHostID)
}
// Ohne Angabe wird der Ursprungsknoten gewählt.
sourceLocation, locateError := provider.locateGuest(nodeContext, formatGuestIdentifier(apiSegment, sourceVMID))
if locateError == nil {
return sourceLocation.NodeName, nil
}
// Der Ursprungsgast ist verschwunden — der Normalfall nach einem Verlust.
// Dann wird der erste erreichbare Knoten genommen.
availableHosts, hostError := provider.ListHosts(nodeContext, "")
if hostError != nil {
return "", hostError
}
for _, availableHost := range availableHosts {
if availableHost.Online {
return availableHost.Identifier, nil
}
}
return "", errors.New("es war kein erreichbarer knoten für die wiederherstellung zu finden")
}
// refuseIfRunning verweigert das Überschreiben einer laufenden Maschine.
//
// Proxmox lehnt das ohnehin ab; die Prüfung hier liefert die verständliche
// Begründung, statt den Aufrufer mit einer Meldung aus dem Aufgabenprotokoll
// allein zu lassen.
func (provider *Provider) refuseIfRunning(checkContext context.Context, nodeName string, apiSegment string, targetVMID int) error {
currentStatus, statusError := provider.fetchGuestStatus(checkContext, guestLocation{
NodeName: nodeName,
VMID: targetVMID,
APISegment: apiSegment,
})
if statusError != nil {
return statusError
}
if currentStatus.Status == "running" {
return fmt.Errorf("%w: der gast %d läuft und würde beim wiederherstellen überschrieben; halten Sie ihn zuerst an",
providers.ErrGuestRunning, targetVMID)
}
if currentStatus.Lock != "" {
return fmt.Errorf("der gast %d ist durch den vorgang %q gesperrt; die wiederherstellung würde ihn beschädigen",
targetVMID, currentStatus.Lock)
}
return nil
}
// collectOccupiedIdentifiers sammelt die belegten Gastkennungen.
func collectOccupiedIdentifiers(existingGuests []providers.Guest) map[int]struct{} {
occupiedIdentifiers := make(map[int]struct{}, len(existingGuests))
for _, existingGuest := range existingGuests {
_, numericID, parseError := parseGuestIdentifier(existingGuest.Identifier)
if parseError != nil {
continue
}
// Die Art bleibt unberücksichtigt: Proxmox vergibt Kennungen über QEMU
// und LXC hinweg nur einmal.
occupiedIdentifiers[numericID] = struct{}{}
}
return occupiedIdentifiers
}
// findFreeGuestIdentifier sucht die nächste freie Kennung.
//
// Gesucht wird oberhalb der Ursprungskennung: die wiederhergestellte Maschine
// steht damit in der Übersicht neben ihrem Original und ist als dessen Kopie
// erkennbar.
func findFreeGuestIdentifier(occupiedIdentifiers map[int]struct{}, preferredStart int) (int, error) {
searchStart := preferredStart + 1
if searchStart < minimumUserGuestID {
searchStart = minimumUserGuestID
}
for candidateIdentifier := searchStart; candidateIdentifier <= maximumUserGuestID; candidateIdentifier++ {
if _, isOccupied := occupiedIdentifiers[candidateIdentifier]; !isOccupied {
return candidateIdentifier, nil
}
}
return 0, ErrNoFreeGuestIdentifier
}
// nonRestorableProperties sind Konfigurationsfelder, die nicht gesetzt werden.
//
// Sie beschreiben die Ablage der Platten und werden von der Wiederherstellung
// selbst bestimmt. Würde man sie überschreiben, verwiese die Konfiguration auf
// Datenträger am alten Ort — die Maschine startete nicht.
var nonRestorableProperties = map[string]bool{
"digest": true,
"vmgenid": true,
"meta": true,
"parent": true,
"snaptime": true,
"lock": true,
}
// isDiskProperty meldet ein Feld, das einen Datenträger beschreibt.
func isDiskProperty(propertyName string) bool {
return diskPropertyPattern.MatchString(propertyName)
}
// applyMetadata setzt die gesicherte Konfiguration auf den wiederhergestellten Gast.
//
// Ohne diesen Schritt hätte die Maschine ihre Daten, aber nicht ihre Gestalt:
// falsches Kartenmodell, fehlende serielle Schnittstelle, anderes BIOS. Eine
// bootfähige, aber unbrauchbare VM ist kein gelungener Restore.
func (provider *Provider) applyMetadata(metadataContext context.Context, plan *restorePlan, guestMetadata *providers.GuestMetadata) ([]string, error) {
if len(guestMetadata.RawConfiguration) == 0 {
return nil, nil
}
formValues := url.Values{}
skippedProperties := make([]string, 0)
// Die feste Reihenfolge macht zwei Läufe vergleichbar und Fehler
// nachvollziehbar.
propertyNames := make([]string, 0, len(guestMetadata.RawConfiguration))
for propertyName := range guestMetadata.RawConfiguration {
propertyNames = append(propertyNames, propertyName)
}
sort.Strings(propertyNames)
for _, propertyName := range propertyNames {
if nonRestorableProperties[propertyName] {
continue
}
// Plattenfelder bestimmt die Wiederherstellung selbst.
if isDiskProperty(propertyName) {
skippedProperties = append(skippedProperties, propertyName)
continue
}
propertyValue := guestMetadata.RawConfiguration[propertyName]
if propertyValue == "" {
continue
}
formValues.Set(propertyName, propertyValue)
}
if len(formValues) == 0 {
return nil, nil
}
configurationPath := fmt.Sprintf("/nodes/%s/%s/%d/config",
url.PathEscape(plan.TargetNode), plan.APISegment, plan.TargetVMID)
if configurationError := provider.client.put(metadataContext, configurationPath, formValues, nil); configurationError != nil {
return nil, fmt.Errorf("die konfiguration konnte nicht gesetzt werden: %w", configurationError)
}
warnings := make([]string, 0, 1)
if len(skippedProperties) > 0 {
// Kein stiller Vorgang: Der Anwender erfährt, welche Felder die
// Wiederherstellung selbst bestimmt hat.
warnings = append(warnings, fmt.Sprintf(
"Die Plattenzuordnung stammt aus dem Archiv, nicht aus der gesicherten Konfiguration (%s).",
strings.Join(skippedProperties, ", ")))
}
return warnings, nil
}
// startGuest startet einen wiederhergestellten Gast.
func (provider *Provider) startGuest(startContext context.Context, plan *restorePlan) error {
startPath := fmt.Sprintf("/nodes/%s/%s/%d/status/start",
url.PathEscape(plan.TargetNode), plan.APISegment, plan.TargetVMID)
var taskIdentifier TaskIdentifier
if startError := provider.client.post(startContext, startPath, url.Values{}, &taskIdentifier); startError != nil {
return startError
}
_, waitError := provider.client.WaitForTask(startContext, taskIdentifier, TaskWaitOptions{
Timeout: defaultSnapshotTimeout,
PollInterval: provider.options.TaskPollInterval,
})
return waitError
}
// reportProgress meldet den Fortschritt, sofern ein Empfänger eingerichtet ist.
func reportProgress(progressCallback func(providers.RestoreProgress), stageName string, percentComplete float64, message string) {
if progressCallback == nil {
return
}
progressCallback(providers.RestoreProgress{
Stage: stageName,
PercentComplete: percentComplete,
Message: message,
})
}