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

611 lines
19 KiB
Go

package proxmox
import (
"context"
"fmt"
"net/url"
"regexp"
"sort"
"strconv"
"strings"
"github.com/syncova/syncova/packages/providers"
)
// clusterResource ist ein Eintrag aus /cluster/resources.
//
// Der Endpunkt liefert Knoten, Gäste und Speicher in einem Aufruf. Ihn zu
// nutzen statt jeden Knoten einzeln zu befragen ist bei größeren Verbünden der
// Unterschied zwischen einem Aufruf und hunderten.
type clusterResource struct {
// ResourceType ist "node", "qemu", "lxc", "storage" oder "pool".
ResourceType string `json:"type"`
// ID ist die zusammengesetzte Kennung, etwa "qemu/100".
ID string `json:"id"`
// Node ist der Knotenname.
Node string `json:"node"`
// VMID ist die Gastkennung.
VMID int `json:"vmid"`
// Name ist die Bezeichnung.
Name string `json:"name"`
// Status ist der Zustand.
Status string `json:"status"`
// MaxCPU ist die Zahl der Prozessoren.
MaxCPU float64 `json:"maxcpu"`
// MaxMemory ist der Arbeitsspeicher in Byte.
MaxMemory int64 `json:"maxmem"`
// Tags sind die Etiketten, durch Semikolon getrennt.
Tags string `json:"tags"`
// Template meldet eine Vorlage statt eines Gasts.
Template int `json:"template"`
// Uptime ist die Laufzeit in Sekunden.
Uptime int64 `json:"uptime"`
}
// ListClusters ermittelt den Verbund.
//
// Proxmox kennt genau einen Verbund je Endpunkt; ein Einzelknoten ohne Verbund
// wird als Verbund mit einem Wirt gemeldet. Die Schnittstelle gibt trotzdem
// eine Liste zurück, weil VMware mehrere kennt — eine Sonderform hier
// erzwänge später eine Änderung an der Schnittstelle.
func (provider *Provider) ListClusters(listContext context.Context) ([]providers.Cluster, error) {
if provider.client == nil {
return nil, providers.ErrNotConnected
}
var statusEntries []struct {
Type string `json:"type"`
ID string `json:"id"`
Name string `json:"name"`
Nodes int `json:"nodes"`
Quorate int `json:"quorate"`
Version int `json:"version"`
Online int `json:"online"`
}
if statusError := provider.client.get(listContext, "/cluster/status", &statusEntries); statusError != nil {
return nil, fmt.Errorf("der verbundstatus war nicht abrufbar: %w", statusError)
}
discoveredClusters := make([]providers.Cluster, 0, 1)
var onlineNodeCount int
for _, statusEntry := range statusEntries {
if statusEntry.Type == "node" {
onlineNodeCount++
continue
}
if statusEntry.Type != "cluster" {
continue
}
discoveredClusters = append(discoveredClusters, providers.Cluster{
Identifier: statusEntry.Name,
Name: statusEntry.Name,
HostCount: statusEntry.Nodes,
Quorate: statusEntry.Quorate == 1,
})
}
// Ein Einzelknoten meldet keinen Verbundeintrag. Ihn zu übergehen liesse
// die Erfassung leer ausgehen — ausgerechnet im häufigsten kleinen Aufbau.
if len(discoveredClusters) == 0 {
discoveredClusters = append(discoveredClusters, providers.Cluster{
Identifier: "standalone",
Name: "Einzelknoten",
HostCount: onlineNodeCount,
// Ein Einzelknoten ist immer beschlussfähig: es gibt niemanden,
// mit dem er sich einigen müsste.
Quorate: true,
})
}
return discoveredClusters, nil
}
// ListHosts ermittelt die Knoten.
func (provider *Provider) ListHosts(listContext context.Context, clusterID string) ([]providers.Host, error) {
if provider.client == nil {
return nil, providers.ErrNotConnected
}
var nodeEntries []struct {
Node string `json:"node"`
Status string `json:"status"`
MaxCPU int `json:"maxcpu"`
MaxMemory int64 `json:"maxmem"`
Uptime int64 `json:"uptime"`
CPUUsage float64 `json:"cpu"`
}
if nodesError := provider.client.get(listContext, "/nodes", &nodeEntries); nodesError != nil {
return nil, fmt.Errorf("die knotenliste war nicht abrufbar: %w", nodesError)
}
discoveredHosts := make([]providers.Host, 0, len(nodeEntries))
for _, nodeEntry := range nodeEntries {
discoveredHosts = append(discoveredHosts, providers.Host{
Identifier: nodeEntry.Node,
Name: nodeEntry.Node,
ClusterID: clusterID,
Online: nodeEntry.Status == "online",
CPUCount: nodeEntry.MaxCPU,
MemoryBytes: nodeEntry.MaxMemory,
})
}
sort.Slice(discoveredHosts, func(firstIndex int, secondIndex int) bool {
return discoveredHosts[firstIndex].Name < discoveredHosts[secondIndex].Name
})
return discoveredHosts, nil
}
// ListVMs ermittelt die Gäste; ein leerer Wirt bedeutet alle Wirte.
func (provider *Provider) ListVMs(listContext context.Context, hostID string) ([]providers.Guest, error) {
if provider.client == nil {
return nil, providers.ErrNotConnected
}
var clusterResources []clusterResource
if resourceError := provider.client.get(listContext, "/cluster/resources?type=vm", &clusterResources); resourceError != nil {
return nil, fmt.Errorf("die gastliste war nicht abrufbar: %w", resourceError)
}
discoveredGuests := make([]providers.Guest, 0, len(clusterResources))
for _, resourceEntry := range clusterResources {
if hostID != "" && resourceEntry.Node != hostID {
continue
}
// Vorlagen sind keine lauffähigen Gäste. Sie mitzusichern erzeugte
// Backups, die niemand wiederherstellen will, und verfälschte jede
// Zählung.
if resourceEntry.Template == 1 {
continue
}
guestType := providers.GuestTypeVirtualMachine
if resourceEntry.ResourceType == "lxc" {
guestType = providers.GuestTypeContainer
}
discoveredGuests = append(discoveredGuests, providers.Guest{
Identifier: formatGuestIdentifier(resourceEntry.ResourceType, resourceEntry.VMID),
Name: resourceEntry.Name,
GuestType: guestType,
HostID: resourceEntry.Node,
PowerState: translatePowerState(resourceEntry.Status),
CPUCount: int(resourceEntry.MaxCPU),
MemoryBytes: resourceEntry.MaxMemory,
Tags: splitTags(resourceEntry.Tags),
})
}
sort.Slice(discoveredGuests, func(firstIndex int, secondIndex int) bool {
return discoveredGuests[firstIndex].Identifier < discoveredGuests[secondIndex].Identifier
})
return discoveredGuests, nil
}
// GetVMInfo liefert die Angaben zu einem Gast.
func (provider *Provider) GetVMInfo(infoContext context.Context, guestID string) (*providers.Guest, error) {
allGuests, listError := provider.ListVMs(infoContext, "")
if listError != nil {
return nil, listError
}
for guestIndex := range allGuests {
if allGuests[guestIndex].Identifier == guestID {
return &allGuests[guestIndex], nil
}
}
return nil, fmt.Errorf("%w: %s", providers.ErrGuestNotFound, guestID)
}
// GetVMMetaData liefert die Konfiguration eines Gasts.
func (provider *Provider) GetVMMetaData(metadataContext context.Context, guestID string) (*providers.GuestMetadata, error) {
guestLocation, locateError := provider.locateGuest(metadataContext, guestID)
if locateError != nil {
return nil, locateError
}
rawConfiguration, configurationError := provider.fetchRawConfiguration(metadataContext, guestLocation)
if configurationError != nil {
return nil, configurationError
}
guestMetadata := &providers.GuestMetadata{
GuestID: guestID,
RawConfiguration: rawConfiguration,
// Der Gastdienst muss ausdrücklich eingeschaltet sein. Ihn anzunehmen
// hiesse, eine anwendungskonsistente Sicherung zu versprechen, die
// nicht möglich ist.
GuestAgentEnabled: strings.HasPrefix(rawConfiguration["agent"], "1"),
}
if biosValue, hasBIOS := rawConfiguration["bios"]; hasBIOS {
guestMetadata.FirmwareType = biosValue
} else if guestLocation.GuestType == providers.GuestTypeVirtualMachine {
// Proxmox lässt "bios" weg, wenn der Standard gilt. Das Feld leer zu
// lassen wäre missverständlich: eine wiederhergestellte Maschine mit
// UEFI statt SeaBIOS startet nicht.
guestMetadata.FirmwareType = "seabios"
}
guestMetadata.NetworkInterfaces = parseNetworkInterfaces(rawConfiguration)
guestMetadata.BootOrder = parseBootOrder(rawConfiguration)
return guestMetadata, nil
}
// diskPropertyPattern erkennt die Plattenschlüssel der Proxmox-Konfiguration.
//
// QEMU: scsi0, virtio3, ide2, sata1 — LXC: rootfs, mp0 … mp255.
var diskPropertyPattern = regexp.MustCompile(`^(scsi|virtio|ide|sata|efidisk|tpmstate|unused|mp)(\d+)$|^rootfs$`)
// nonDiskDrivePattern erkennt Einträge, die zwar an einem Plattenanschluss
// hängen, aber keine Platte sind.
//
// Ein eingelegtes ISO-Abbild steht als "ide2: local:iso/debian.iso,media=cdrom"
// in der Konfiguration. Es mitzusichern lüde ein Installationsmedium ins
// Backup; es beim Wiederherstellen zu erwarten liesse die Maschine scheitern,
// wenn das Abbild fehlt.
var nonDiskDrivePattern = regexp.MustCompile(`media=cdrom|^none,|^cdrom,`)
// GetVMDisks liefert die Platten eines Gasts.
func (provider *Provider) GetVMDisks(diskContext context.Context, guestID string) ([]providers.Disk, error) {
guestLocation, locateError := provider.locateGuest(diskContext, guestID)
if locateError != nil {
return nil, locateError
}
rawConfiguration, configurationError := provider.fetchRawConfiguration(diskContext, guestLocation)
if configurationError != nil {
return nil, configurationError
}
discoveredDisks := make([]providers.Disk, 0, 4)
for propertyName, propertyValue := range rawConfiguration {
if !diskPropertyPattern.MatchString(propertyName) {
continue
}
if nonDiskDrivePattern.MatchString(propertyValue) {
continue
}
discoveredDisks = append(discoveredDisks, parseDiskProperty(propertyName, propertyValue))
}
sort.Slice(discoveredDisks, func(firstIndex int, secondIndex int) bool {
return discoveredDisks[firstIndex].Identifier < discoveredDisks[secondIndex].Identifier
})
return discoveredDisks, nil
}
// parseDiskProperty deutet einen Plattenwert der Proxmox-Konfiguration.
//
// Aufbau: "local-lvm:vm-100-disk-0,size=32G,backup=0,ssd=1"
func parseDiskProperty(propertyName string, propertyValue string) providers.Disk {
valueParts := strings.Split(propertyValue, ",")
parsedDisk := providers.Disk{
Identifier: propertyName,
Volume: valueParts[0],
}
// Der Datenträger steht als "speicher:pfad" am Anfang.
if separatorIndex := strings.Index(valueParts[0], ":"); separatorIndex > 0 {
parsedDisk.StorageID = valueParts[0][:separatorIndex]
}
// Ein "unused"-Eintrag ist eine abgehängte Platte. Sie gehört ins Backup —
// sie enthält Daten —, aber Proxmox bindet sie nicht ein.
if strings.HasPrefix(propertyName, "unused") {
parsedDisk.ReadOnly = true
}
for _, valuePart := range valueParts[1:] {
optionName, optionValue, hasValue := strings.Cut(valuePart, "=")
if !hasValue {
continue
}
switch optionName {
case "size":
parsedDisk.SizeBytes = parseProxmoxSize(optionValue)
case "format":
parsedDisk.Format = optionValue
case "backup":
// "backup=0" nimmt die Platte ausdrücklich von der Sicherung aus.
// Diese Angabe ist der wichtigste Fund dieser Funktion: Wer sie
// übergeht, hält eine unvollständige Maschine für vollständig.
parsedDisk.ExcludedFromBackup = optionValue == "0"
case "ro":
parsedDisk.ReadOnly = optionValue == "1"
}
}
return parsedDisk
}
// sizeSuffixFactors sind die Vielfachen der Proxmox-Größenangaben.
var sizeSuffixFactors = map[string]int64{
"K": 1 << 10,
"M": 1 << 20,
"G": 1 << 30,
"T": 1 << 40,
}
// parseProxmoxSize deutet eine Größenangabe wie "32G".
func parseProxmoxSize(sizeText string) int64 {
trimmedText := strings.TrimSpace(sizeText)
if trimmedText == "" {
return 0
}
suffixCharacter := strings.ToUpper(trimmedText[len(trimmedText)-1:])
suffixFactor, hasSuffix := sizeSuffixFactors[suffixCharacter]
if !hasSuffix {
// Ohne Einheit meint Proxmox Byte.
return parseOptionalInteger(trimmedText)
}
numericPart := strings.TrimSpace(trimmedText[:len(trimmedText)-1])
// Proxmox erlaubt gebrochene Angaben wie "1.5T".
parsedValue, parseError := strconv.ParseFloat(numericPart, 64)
if parseError != nil {
return 0
}
return int64(parsedValue * float64(suffixFactor))
}
// parseNetworkInterfaces liest die Netzwerkkarten aus der Konfiguration.
//
// Aufbau: "net0: virtio=AA:BB:CC:DD:EE:FF,bridge=vmbr0,tag=42"
func parseNetworkInterfaces(rawConfiguration map[string]string) []providers.NetworkInterface {
networkPattern := regexp.MustCompile(`^net(\d+)$`)
parsedInterfaces := make([]providers.NetworkInterface, 0, 2)
for propertyName, propertyValue := range rawConfiguration {
if !networkPattern.MatchString(propertyName) {
continue
}
parsedInterface := providers.NetworkInterface{Identifier: propertyName}
for _, valuePart := range strings.Split(propertyValue, ",") {
optionName, optionValue, hasValue := strings.Cut(valuePart, "=")
if !hasValue {
continue
}
switch optionName {
case "bridge":
parsedInterface.Bridge = optionValue
case "tag":
parsedInterface.VLANTag = int(parseOptionalInteger(optionValue))
case "virtio", "e1000", "rtl8139", "vmxnet3", "e1000e":
// Das Kartenmodell steht als Schlüssel, die MAC-Adresse als
// Wert. Die Adresse muss erhalten bleiben: Lizenzbindungen und
// DHCP-Reservierungen hängen daran.
parsedInterface.Model = optionName
parsedInterface.MACAddress = optionValue
}
}
parsedInterfaces = append(parsedInterfaces, parsedInterface)
}
sort.Slice(parsedInterfaces, func(firstIndex int, secondIndex int) bool {
return parsedInterfaces[firstIndex].Identifier < parsedInterfaces[secondIndex].Identifier
})
return parsedInterfaces
}
// parseBootOrder liest die Startreihenfolge.
//
// Aufbau: "order=scsi0;net0" (neu) oder "cdn" (alt, zeichenweise).
func parseBootOrder(rawConfiguration map[string]string) []string {
bootValue, hasBootValue := rawConfiguration["boot"]
if !hasBootValue {
return nil
}
if orderPart, hasOrder := strings.CutPrefix(bootValue, "order="); hasOrder {
return strings.Split(orderPart, ";")
}
// Die alte Schreibweise nennt Geräteklassen: c=Platte, d=CD, n=Netz.
legacyDeviceNames := map[rune]string{'c': "disk", 'd': "cdrom", 'n': "network", 'a': "floppy"}
bootOrder := make([]string, 0, len(bootValue))
for _, deviceCharacter := range bootValue {
if deviceName, isKnown := legacyDeviceNames[deviceCharacter]; isKnown {
bootOrder = append(bootOrder, deviceName)
}
}
return bootOrder
}
// guestLocation ist ein aufgelöster Gast samt Knoten.
type guestLocation struct {
// NodeName ist der Knoten, auf dem der Gast liegt.
NodeName string
// VMID ist die numerische Kennung.
VMID int
// GuestType ist die Art des Gasts.
GuestType providers.GuestType
// APISegment ist "qemu" oder "lxc" für die Pfadbildung.
APISegment string
}
// locateGuest ermittelt Knoten und Art eines Gasts.
//
// Der Umweg über die Verbundressourcen ist nötig, weil die Proxmox-Pfade den
// Knoten enthalten: /nodes/{knoten}/qemu/{vmid}. Eine Gastkennung allein
// genügt nicht, und der Knoten kann sich durch Migration ändern.
func (provider *Provider) locateGuest(locateContext context.Context, guestID string) (guestLocation, error) {
if provider.client == nil {
return guestLocation{}, providers.ErrNotConnected
}
apiSegment, numericID, parseError := parseGuestIdentifier(guestID)
if parseError != nil {
return guestLocation{}, parseError
}
var clusterResources []clusterResource
if resourceError := provider.client.get(locateContext, "/cluster/resources?type=vm", &clusterResources); resourceError != nil {
return guestLocation{}, fmt.Errorf("der gast %s war nicht auffindbar: %w", guestID, resourceError)
}
for _, resourceEntry := range clusterResources {
if resourceEntry.VMID != numericID || resourceEntry.ResourceType != apiSegment {
continue
}
guestType := providers.GuestTypeVirtualMachine
if resourceEntry.ResourceType == "lxc" {
guestType = providers.GuestTypeContainer
}
return guestLocation{
NodeName: resourceEntry.Node,
VMID: numericID,
GuestType: guestType,
APISegment: apiSegment,
}, nil
}
return guestLocation{}, fmt.Errorf("%w: %s", providers.ErrGuestNotFound, guestID)
}
// fetchRawConfiguration holt die unveränderte Konfiguration eines Gasts.
func (provider *Provider) fetchRawConfiguration(configurationContext context.Context, location guestLocation) (map[string]string, error) {
configurationPath := fmt.Sprintf("/nodes/%s/%s/%d/config",
url.PathEscape(location.NodeName), location.APISegment, location.VMID)
// Die Werte kommen je nach Feld als Zeichenkette, Zahl oder Wahrheitswert.
// Sie werden einheitlich als Text abgelegt: die Konfiguration wird
// wortgetreu mitgesichert, nicht umgedeutet.
var rawValues map[string]any
if configurationError := provider.client.get(configurationContext, configurationPath, &rawValues); configurationError != nil {
return nil, fmt.Errorf("die konfiguration von %d war nicht abrufbar: %w", location.VMID, configurationError)
}
textualConfiguration := make(map[string]string, len(rawValues))
for propertyName, propertyValue := range rawValues {
textualConfiguration[propertyName] = formatConfigurationValue(propertyValue)
}
return textualConfiguration, nil
}
// formatConfigurationValue bringt einen Konfigurationswert in Textform.
func formatConfigurationValue(rawValue any) string {
switch typedValue := rawValue.(type) {
case string:
return typedValue
case bool:
if typedValue {
return "1"
}
return "0"
case float64:
// Ganzzahlen sollen nicht als "100.000000" erscheinen.
if typedValue == float64(int64(typedValue)) {
return strconv.FormatInt(int64(typedValue), 10)
}
return strconv.FormatFloat(typedValue, 'f', -1, 64)
case nil:
return ""
default:
return fmt.Sprintf("%v", typedValue)
}
}
// formatGuestIdentifier bildet die herstellerneutrale Gastkennung.
//
// Die Art gehört hinein, weil QEMU 100 und LXC 100 in Proxmox nebeneinander
// bestehen können. Eine reine Zahl wäre mehrdeutig.
func formatGuestIdentifier(resourceType string, vmID int) string {
return fmt.Sprintf("%s/%d", resourceType, vmID)
}
// parseGuestIdentifier zerlegt eine Gastkennung.
func parseGuestIdentifier(guestID string) (string, int, error) {
apiSegment, numericPart, hasSeparator := strings.Cut(guestID, "/")
if !hasSeparator {
return "", 0, fmt.Errorf("die gastkennung %q muss die form qemu/100 oder lxc/100 haben", guestID)
}
if apiSegment != "qemu" && apiSegment != "lxc" {
return "", 0, fmt.Errorf("die gastart %q ist unbekannt; erwartet werden qemu oder lxc", apiSegment)
}
numericID, parseError := strconv.Atoi(numericPart)
if parseError != nil || numericID <= 0 {
return "", 0, fmt.Errorf("die gastkennung %q enthält keine gültige nummer", guestID)
}
return apiSegment, numericID, nil
}
// translatePowerState bildet den Proxmox-Zustand ab.
func translatePowerState(proxmoxStatus string) providers.PowerState {
switch proxmoxStatus {
case "running":
return providers.PowerStateRunning
case "stopped":
return providers.PowerStateStopped
case "paused", "suspended":
return providers.PowerStatePaused
default:
// Ein unbekannter Zustand gilt nicht als angehalten. Sonst würde eine
// laufende Maschine ohne Rückfrage überschrieben.
return providers.PowerStateUnknown
}
}
// splitTags zerlegt die Etikettenliste.
func splitTags(tagText string) []string {
trimmedText := strings.TrimSpace(tagText)
if trimmedText == "" {
return nil
}
// Proxmox trennt Etiketten mit Semikolon, ältere Fassungen mit Komma.
separatedTags := strings.FieldsFunc(trimmedText, func(currentRune rune) bool {
return currentRune == ';' || currentRune == ','
})
collectedTags := make([]string, 0, len(separatedTags))
for _, singleTag := range separatedTags {
if trimmedTag := strings.TrimSpace(singleTag); trimmedTag != "" {
collectedTags = append(collectedTags, trimmedTag)
}
}
return collectedTags
}