syncova-backup/packages/hypervisor/model.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
12 KiB
Go

// Package hypervisor verwaltet eingerichtete Virtualisierungsumgebungen.
//
// Es ist die Naht zwischen der Control Plane und dem herstellerneutralen
// Provider-Interface: Hier liegen Zugangsdaten, Bestand und der Weg zu den
// Sicherungsarchiven; der Provider selbst kennt weder Datenbank noch
// Verschlüsselung.
//
// Bewusst ein eigenes Paket und nicht Teil von providers/proxmox: Ein
// VMware- oder Hyper-V-Provider bekäme sonst entweder eine zweite
// Datenzugriffsschicht oder eine Abhängigkeit auf Proxmox. Die Tabellen heißen
// aus historischen Gründen `proxmox_clusters` (so steht es in
// SYNCOVA_DATABASE.md §4); das Modell hier ist es nicht.
package hypervisor
import (
"errors"
"fmt"
"net/url"
"strings"
"time"
"github.com/google/uuid"
)
// TransportKind benennt den Weg zu den Sicherungsarchiven.
type TransportKind string
const (
// TransportLocal liest die Archive über das Dateisystem.
//
// Gilt, wenn Syncova auf dem Knoten läuft oder der Sicherungsspeicher auf
// dem Syncova-Server eingehängt ist.
TransportLocal TransportKind = "local"
// TransportSSH liest die Archive über eine SSH-Verbindung zum Knoten.
TransportSSH TransportKind = "ssh"
)
// ClusterStatus ist das Ergebnis der letzten Verbindungsprüfung.
type ClusterStatus string
const (
// StatusUnknown bedeutet: noch nie geprüft.
//
// Ausdrücklich von „erreichbar" getrennt. Ein Verbund, der als erreichbar
// gilt, weil ihn niemand geprüft hat, ist die bequeme und falsche Auskunft.
StatusUnknown ClusterStatus = "unknown"
// StatusReachable bedeutet: Anmeldung und Abruf haben funktioniert.
StatusReachable ClusterStatus = "reachable"
// StatusUnreachable bedeutet: der Verbund antwortet nicht.
StatusUnreachable ClusterStatus = "unreachable"
// StatusUnauthorized bedeutet: er antwortet, lehnt aber die Anmeldung ab.
//
// Getrennt von „nicht erreichbar", weil die Abhilfe eine völlig andere ist:
// Das eine ist ein Netzproblem, das andere ein abgelaufenes oder
// entzogenes Token.
StatusUnauthorized ClusterStatus = "unauthorized"
)
// Cluster ist eine eingerichtete Virtualisierungsumgebung.
type Cluster struct {
// ID ist die öffentliche Kennung.
ID uuid.UUID `json:"id"`
// Name ist die sprechende Bezeichnung.
Name string `json:"name"`
// APIEndpoint ist die Basisadresse der Proxmox-API.
APIEndpoint string `json:"api_endpoint"`
// APITokenID ist die Kennung des API-Tokens.
//
// Kein Geheimnis: Sie steht in jeder Proxmox-Oberfläche und wird zur
// Fehlersuche gebraucht. Der Wert daneben ist das Geheimnis und erscheint
// niemals in einer Antwort.
APITokenID string `json:"api_token_id"`
// TLSFingerprint bindet ein selbstsigniertes Zertifikat.
TLSFingerprint string `json:"tls_fingerprint,omitempty"`
// BackupStorageID ist der Speicher für die vzdump-Archive.
BackupStorageID string `json:"backup_storage_id"`
// ArchiveTransport ist der Weg zu den Archivdateien.
ArchiveTransport TransportKind `json:"archive_transport"`
// ArchiveMountRoots ordnet Speicherkennungen lokalen Pfaden zu.
ArchiveMountRoots map[string]string `json:"archive_mount_roots,omitempty"`
// SSHUsername ist das Anmeldekonto auf den Knoten.
SSHUsername string `json:"ssh_username,omitempty"`
// SSHPort ist der Port; 0 bedeutet 22.
SSHPort int `json:"ssh_port,omitempty"`
// SSHHostFingerprints sind die Wirtsschlüssel je Knoten.
SSHHostFingerprints map[string]string `json:"ssh_host_fingerprints,omitempty"`
// KeepArchiveOnNode lässt das vzdump-Archiv nach der Übernahme liegen.
KeepArchiveOnNode bool `json:"keep_archive_on_node"`
// Status ist das Ergebnis der letzten Prüfung.
Status ClusterStatus `json:"status"`
// LastError ist die Meldung der letzten fehlgeschlagenen Prüfung.
LastError string `json:"last_error,omitempty"`
// LastSeenAt ist der Zeitpunkt der letzten erfolgreichen Verbindung.
LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
// LastDiscoveryAt ist der Zeitpunkt der letzten Bestandsaufnahme.
LastDiscoveryAt *time.Time `json:"last_discovery_at,omitempty"`
// CreatedAt ist der Zeitpunkt der Einrichtung.
CreatedAt time.Time `json:"created_at"`
// UpdatedAt ist der Zeitpunkt der letzten Änderung.
UpdatedAt time.Time `json:"updated_at"`
}
// ClusterCredentials sind die entschlüsselten Geheimnisse eines Verbunds.
//
// Getrennt vom Cluster, damit sie nicht versehentlich in eine API-Antwort
// geraten: Ein Feld, das in derselben Struktur liegt, wird irgendwann
// mitserialisiert (PROMPT.md §140).
type ClusterCredentials struct {
// APITokenSecret ist der Wert des API-Tokens.
APITokenSecret string
// SSHPrivateKeyPEM ist der private Schlüssel für den SSH-Weg.
SSHPrivateKeyPEM []byte
}
// ClusterInput sind die Angaben zum Einrichten oder Ändern eines Verbunds.
type ClusterInput struct {
// Name ist die sprechende Bezeichnung.
Name string `json:"name"`
// APIEndpoint ist die Basisadresse der API.
APIEndpoint string `json:"api_endpoint"`
// APITokenID ist die Kennung des API-Tokens.
APITokenID string `json:"api_token_id"`
// APITokenSecret ist der Wert des API-Tokens.
APITokenSecret string `json:"api_token_secret"`
// TLSFingerprint bindet ein selbstsigniertes Zertifikat.
TLSFingerprint string `json:"tls_fingerprint,omitempty"`
// BackupStorageID ist der Speicher für die vzdump-Archive.
BackupStorageID string `json:"backup_storage_id"`
// ArchiveTransport ist der Weg zu den Archivdateien.
ArchiveTransport TransportKind `json:"archive_transport"`
// ArchiveMountRoots ordnet Speicherkennungen lokalen Pfaden zu.
ArchiveMountRoots map[string]string `json:"archive_mount_roots,omitempty"`
// SSHUsername ist das Anmeldekonto auf den Knoten.
SSHUsername string `json:"ssh_username,omitempty"`
// SSHPort ist der Port; 0 bedeutet 22.
SSHPort int `json:"ssh_port,omitempty"`
// SSHPrivateKeyPEM ist der private Schlüssel im PEM-Format.
SSHPrivateKeyPEM string `json:"ssh_private_key_pem,omitempty"`
// SSHHostFingerprints sind die Wirtsschlüssel je Knoten.
SSHHostFingerprints map[string]string `json:"ssh_host_fingerprints,omitempty"`
// KeepArchiveOnNode lässt das vzdump-Archiv nach der Übernahme liegen.
KeepArchiveOnNode bool `json:"keep_archive_on_node,omitempty"`
}
// Validate prüft die Angaben, bevor etwas gespeichert wird.
//
// Die Prüfung ist streng, weil der Fehler sonst erst um zwei Uhr nachts
// auffällt: Ein Verbund ohne Zugriffsweg auf die Archive lässt sich einrichten,
// meldet „erreichbar" und liefert bei der ersten Sicherung kein einziges Byte.
func (input ClusterInput) Validate() error {
if strings.TrimSpace(input.Name) == "" {
return errors.New("der verbund braucht einen namen")
}
parsedEndpoint, parseError := url.Parse(strings.TrimSpace(input.APIEndpoint))
if parseError != nil || parsedEndpoint.Host == "" {
return fmt.Errorf("die api-adresse %q ist keine gueltige url", input.APIEndpoint)
}
// Proxmox spricht ausschließlich HTTPS. Ein http:// hier wäre ein
// Tippfehler mit der Folge, dass das API-Token im Klartext über das Netz
// ginge.
if parsedEndpoint.Scheme != "https" {
return errors.New("die api-adresse muss mit https:// beginnen; das api-token ginge sonst im klartext ueber das netz")
}
if strings.TrimSpace(input.APITokenID) == "" || strings.TrimSpace(input.APITokenSecret) == "" {
return errors.New("der verbund braucht kennung und wert eines api-tokens")
}
if strings.TrimSpace(input.BackupStorageID) == "" {
return errors.New("es muss ein speicher fuer die sicherungsarchive angegeben werden")
}
switch input.ArchiveTransport {
case TransportLocal:
if len(input.ArchiveMountRoots) == 0 {
return errors.New("der lokale zugriffsweg braucht mindestens eine zuordnung " +
"von proxmox-speicher zu lokalem pfad")
}
for storageIdentifier, mountPath := range input.ArchiveMountRoots {
if strings.TrimSpace(storageIdentifier) == "" || !strings.HasPrefix(mountPath, "/") {
return fmt.Errorf("die zuordnung %q -> %q ist unvollstaendig; der pfad muss absolut sein",
storageIdentifier, mountPath)
}
}
case TransportSSH:
if strings.TrimSpace(input.SSHUsername) == "" {
return errors.New("der ssh-zugriffsweg braucht ein anmeldekonto")
}
if strings.TrimSpace(input.SSHPrivateKeyPEM) == "" {
return errors.New("der ssh-zugriffsweg braucht einen privaten schluessel")
}
// Ohne hinterlegte Wirtsschlüssel liesse sich ein Zwischenangriff nicht
// erkennen — der Angreifer lieferte dann das Archiv, das Syncova für
// ein Backup hält.
if len(input.SSHHostFingerprints) == 0 {
return errors.New("es muss mindestens ein fingerabdruck eines wirtsschluessels " +
"hinterlegt werden; ohne ihn liesse sich ein zwischenangriff nicht erkennen")
}
if input.SSHPort < 0 || input.SSHPort > 65535 {
return fmt.Errorf("der ssh-port %d liegt ausserhalb des gueltigen bereichs", input.SSHPort)
}
default:
return fmt.Errorf("der zugriffsweg %q ist unbekannt; zulaessig sind 'local' und 'ssh'",
input.ArchiveTransport)
}
return nil
}
// Host ist ein Knoten eines Verbunds.
type Host struct {
// ID ist die öffentliche Kennung.
ID uuid.UUID `json:"id"`
// ClusterID ist der Verbund.
ClusterID uuid.UUID `json:"cluster_id"`
// NodeName ist der Name des Knotens bei Proxmox.
NodeName string `json:"node_name"`
// Status ist der zuletzt gemeldete Zustand.
Status string `json:"status"`
// CPUCount ist die Zahl logischer Prozessoren.
CPUCount int `json:"cpu_count,omitempty"`
// MemoryBytes ist der Gesamtarbeitsspeicher.
MemoryBytes int64 `json:"memory_bytes,omitempty"`
// LastSeenAt ist der Zeitpunkt der letzten Aufnahme.
LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
}
// VirtualMachine ist ein aufgenommener Gast.
type VirtualMachine struct {
// ID ist die öffentliche Kennung.
ID uuid.UUID `json:"id"`
// ClusterID ist der Verbund.
ClusterID uuid.UUID `json:"cluster_id"`
// HostID ist der Knoten, sofern bekannt.
HostID *uuid.UUID `json:"host_id,omitempty"`
// ProviderVMID ist die Kennung beim Provider, etwa "qemu/100".
ProviderVMID string `json:"provider_vm_id"`
// Name ist die sprechende Bezeichnung.
Name string `json:"name"`
// GuestKind unterscheidet virtuelle Maschine und Container.
GuestKind string `json:"guest_kind"`
// Status ist der Betriebszustand.
Status string `json:"status,omitempty"`
// CPUCount ist die Zahl zugewiesener Prozessoren.
CPUCount int `json:"cpu_count,omitempty"`
// MemoryBytes ist der zugewiesene Arbeitsspeicher.
MemoryBytes int64 `json:"memory_bytes,omitempty"`
// NodeName ist der Knoten, auf dem der Gast liegt.
NodeName string `json:"node_name,omitempty"`
// DiskCount ist die Zahl gesicherter Platten.
DiskCount int `json:"disk_count"`
// ExcludedDiskCount ist die Zahl von der Sicherung ausgenommener Platten.
//
// Ausdrücklich ausgewiesen: Eine Wiederherstellung liefert dann eine
// unvollständige Maschine, und wer es nicht weiß, hält sie für vollständig.
ExcludedDiskCount int `json:"excluded_disk_count"`
// GuestAgentRunning meldet, ob der Gastdienst antwortet.
//
// Ein Zeiger, weil „nicht geprüft" etwas anderes ist als „läuft nicht".
GuestAgentRunning *bool `json:"guest_agent_running,omitempty"`
// LastDiscoveredAt ist der Zeitpunkt der letzten Aufnahme.
LastDiscoveredAt time.Time `json:"last_discovered_at"`
// MissingSince ist gesetzt, wenn der Gast bei der letzten Aufnahme fehlte.
MissingSince *time.Time `json:"missing_since,omitempty"`
}
// DiscoveryResult fasst eine Bestandsaufnahme zusammen.
type DiscoveryResult struct {
// ClusterID ist der aufgenommene Verbund.
ClusterID uuid.UUID `json:"cluster_id"`
// HostsFound ist die Zahl gefundener Knoten.
HostsFound int `json:"hosts_found"`
// GuestsFound ist die Zahl gefundener Gäste.
GuestsFound int `json:"guests_found"`
// GuestsMissing ist die Zahl zuvor bekannter, jetzt fehlender Gäste.
//
// Sie werden nicht gelöscht: Ein Gast könnte abgeschaltet, verschoben oder
// gelöscht worden sein, und gelöschte Zeilen nähmen die Zuordnung zu
// vorhandenen Backups mit.
GuestsMissing int `json:"guests_missing"`
// Warnings sind Hinweise, die den Lauf nicht verhindert haben.
Warnings []string `json:"warnings,omitempty"`
// CompletedAt ist der Abschlusszeitpunkt.
CompletedAt time.Time `json:"completed_at"`
}