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
12 KiB
Go
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"`
|
|
}
|