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>
283 lines
9.7 KiB
Go
283 lines
9.7 KiB
Go
// Kommando syncova-proxmox prüft die Anbindung an einen Proxmox-VE-Verbund.
|
|
//
|
|
// Es ist das Werkzeug, mit dem sich der Provider gegen eine echte Umgebung
|
|
// belegen lässt. Auf dem Entwicklungsrechner steht keine zur Verfügung; ohne
|
|
// dieses Kommando bliebe die Anbindung allein gegen einen Nachbau geprüft.
|
|
//
|
|
// `discover` verändert nichts: alle Aufrufe sind lesend. Das Sichern gehört in
|
|
// den Auftrag und nicht in ein Werkzeug — die Wiederherstellung eines Gasts
|
|
// dagegen steht hier, weil sie im Ernstfall gebraucht wird, wenn die
|
|
// Weboberfläche womöglich gerade nicht läuft.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"os"
|
|
"os/signal"
|
|
"strings"
|
|
"syscall"
|
|
"text/tabwriter"
|
|
|
|
"github.com/syncova/syncova/packages/platform/logging"
|
|
"github.com/syncova/syncova/packages/providers"
|
|
"github.com/syncova/syncova/packages/providers/proxmox"
|
|
)
|
|
|
|
// serviceName benennt den Dienst in den Protokollen.
|
|
const serviceName = "syncova-proxmox"
|
|
|
|
// tokenSecretVariable ist die Umgebungsvariable mit dem Tokengeheimnis.
|
|
//
|
|
// Das Geheimnis kommt niemals als Aufrufparameter: Aufrufparameter stehen in
|
|
// der Prozessliste und in der Shell-Historie (PROMPT.md §141).
|
|
const tokenSecretVariable = "SYNCOVA_PROXMOX_TOKEN_SECRET"
|
|
|
|
// buildVersion wird beim Bauen gesetzt.
|
|
var buildVersion = "dev"
|
|
|
|
func main() {
|
|
if len(os.Args) < 2 {
|
|
fmt.Fprintln(os.Stderr, usageText())
|
|
os.Exit(2)
|
|
}
|
|
|
|
var commandError error
|
|
|
|
switch os.Args[1] {
|
|
case "discover":
|
|
commandError = runDiscoverCommand(os.Args[2:])
|
|
case "clusters":
|
|
commandError = runListClustersCommand(os.Args[2:])
|
|
case "restore-guest":
|
|
commandError = runRestoreGuestCommand(os.Args[2:])
|
|
case "version", "--version", "-version":
|
|
fmt.Printf("syncova-proxmox %s\n", buildVersion)
|
|
case "help", "-h", "--help":
|
|
fmt.Println(usageText())
|
|
default:
|
|
fmt.Fprintf(os.Stderr, "Unbekanntes Kommando: %s\n\n%s\n", os.Args[1], usageText())
|
|
os.Exit(2)
|
|
}
|
|
|
|
if commandError != nil {
|
|
fmt.Fprintf(os.Stderr, "syncova-proxmox: %v\n", commandError)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
// usageText beschreibt die Verwendung.
|
|
func usageText() string {
|
|
return `Verwendung:
|
|
syncova-proxmox discover --url <https://pve:8006> --token <user@realm!name>
|
|
Erfasst Verbund, Knoten, Gäste und Platten
|
|
syncova-proxmox clusters Zeigt die eingerichteten Verbünde
|
|
syncova-proxmox restore-guest --cluster <id> --repository <pfad> --backup <id>
|
|
Stellt einen gesicherten Gast wieder her
|
|
syncova-proxmox version Zeigt die Version
|
|
|
|
Das Tokengeheimnis kommt aus ` + tokenSecretVariable + ` — niemals als Aufrufparameter.
|
|
|
|
Ein API-Token mit Leserechten genügt für die Erfassung:
|
|
pveum user add syncova@pve
|
|
pveum aclmod / --user syncova@pve --role PVEAuditor
|
|
pveum user token add syncova@pve backup --privsep 0
|
|
|
|
Die Kommandos clusters und restore-guest brauchen die Control Plane: Die
|
|
Zugangsdaten der Verbünde liegen verschlüsselt in der Datenbank. Sie lesen
|
|
dieselben Umgebungsvariablen wie der Dienst (SYNCOVA_DATABASE_*,
|
|
SYNCOVA_ENCRYPTION_KEYS).
|
|
|
|
Proxmox liefert ab Werk ein selbstsigniertes Zertifikat. Statt die Prüfung
|
|
abzuschalten, hinterlegen Sie seinen Fingerabdruck mit --fingerprint; er steht
|
|
in der Weboberfläche unter Certificates.`
|
|
}
|
|
|
|
// runDiscoverCommand erfasst einen Proxmox-Verbund.
|
|
func runDiscoverCommand(commandArguments []string) error {
|
|
commandFlags := flag.NewFlagSet("discover", flag.ContinueOnError)
|
|
endpointURL := commandFlags.String("url", "", "Adresse des Proxmox-Endpunkts")
|
|
apiTokenID := commandFlags.String("token", "", "Kennung des API-Tokens, etwa syncova@pve!backup")
|
|
certificateFingerprint := commandFlags.String("fingerprint", "", "SHA-256-Fingerabdruck des Serverzertifikats")
|
|
skipTLSVerification := commandFlags.Bool("insecure", false, "Zertifikatsprüfung abschalten (nur für Labore)")
|
|
showDisks := commandFlags.Bool("disks", false, "Auch die Platten jedes Gasts auflisten")
|
|
|
|
if parseError := commandFlags.Parse(commandArguments); parseError != nil {
|
|
return parseError
|
|
}
|
|
|
|
if *endpointURL == "" || *apiTokenID == "" {
|
|
return errors.New("--url und --token sind erforderlich")
|
|
}
|
|
|
|
tokenSecret := strings.TrimSpace(os.Getenv(tokenSecretVariable))
|
|
if tokenSecret == "" {
|
|
return fmt.Errorf("das tokengeheimnis muss in %s stehen.\n"+
|
|
"Es wird bewusst nicht als Aufrufparameter angenommen: Parameter stehen in der Prozessliste", tokenSecretVariable)
|
|
}
|
|
|
|
if *certificateFingerprint == "" && !*skipTLSVerification {
|
|
fmt.Fprintln(os.Stderr,
|
|
"Hinweis: Ohne --fingerprint wird gegen die Zertifikatsspeicher des Systems geprüft.\n"+
|
|
" Das schlägt bei einem selbstsignierten Proxmox-Zertifikat fehl.")
|
|
}
|
|
|
|
commandLogger := logging.New(os.Stderr, logging.Options{
|
|
ServiceName: serviceName, Level: "warn", Format: "text",
|
|
})
|
|
|
|
proxmoxProvider, providerError := proxmox.NewProvider(proxmox.ProviderOptions{
|
|
ClientOptions: proxmox.ClientOptions{
|
|
BaseURL: *endpointURL,
|
|
APITokenID: *apiTokenID,
|
|
APITokenSecret: tokenSecret,
|
|
TLSFingerprintSHA256: *certificateFingerprint,
|
|
InsecureSkipTLSVerify: *skipTLSVerification,
|
|
},
|
|
}, commandLogger)
|
|
if providerError != nil {
|
|
return providerError
|
|
}
|
|
|
|
discoveryContext, stopSignalListener := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
|
defer stopSignalListener()
|
|
|
|
defer func() { _ = proxmoxProvider.Disconnect() }()
|
|
|
|
if connectError := proxmoxProvider.Connect(discoveryContext); connectError != nil {
|
|
return connectError
|
|
}
|
|
|
|
fmt.Printf("Verbunden mit %s\n\n", *endpointURL)
|
|
|
|
return printDiscovery(discoveryContext, proxmoxProvider, *showDisks)
|
|
}
|
|
|
|
// printDiscovery gibt die Erfassung aus.
|
|
func printDiscovery(discoveryContext context.Context, proxmoxProvider *proxmox.Provider, showDisks bool) error {
|
|
discoveredClusters, clusterError := proxmoxProvider.ListClusters(discoveryContext)
|
|
if clusterError != nil {
|
|
return clusterError
|
|
}
|
|
|
|
for _, discoveredCluster := range discoveredClusters {
|
|
quorumText := "beschlussfähig"
|
|
if !discoveredCluster.Quorate {
|
|
// Ein Verbund ohne Quorum nimmt keine ändernden Aufrufe an. Das
|
|
// jetzt zu wissen erspart eine Reihe unverständlicher Fehler.
|
|
quorumText = "NICHT beschlussfähig — ändernde Aufrufe werden abgelehnt"
|
|
}
|
|
|
|
fmt.Printf("Verbund: %s (%d Knoten, %s)\n", discoveredCluster.Name, discoveredCluster.HostCount, quorumText)
|
|
}
|
|
|
|
discoveredHosts, hostError := proxmoxProvider.ListHosts(discoveryContext, "")
|
|
if hostError != nil {
|
|
return hostError
|
|
}
|
|
|
|
fmt.Println("\nKnoten:")
|
|
hostWriter := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
|
fmt.Fprintln(hostWriter, " NAME\tZUSTAND\tCPUS\tSPEICHER")
|
|
|
|
for _, discoveredHost := range discoveredHosts {
|
|
hostState := "erreichbar"
|
|
if !discoveredHost.Online {
|
|
hostState = "NICHT ERREICHBAR"
|
|
}
|
|
|
|
fmt.Fprintf(hostWriter, " %s\t%s\t%d\t%s\n",
|
|
discoveredHost.Name, hostState, discoveredHost.CPUCount, formatBytes(discoveredHost.MemoryBytes))
|
|
}
|
|
_ = hostWriter.Flush()
|
|
|
|
discoveredGuests, guestError := proxmoxProvider.ListVMs(discoveryContext, "")
|
|
if guestError != nil {
|
|
return guestError
|
|
}
|
|
|
|
fmt.Printf("\nGäste (%d):\n", len(discoveredGuests))
|
|
guestWriter := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
|
fmt.Fprintln(guestWriter, " KENNUNG\tNAME\tART\tKNOTEN\tZUSTAND\tETIKETTEN")
|
|
|
|
for _, discoveredGuest := range discoveredGuests {
|
|
guestKind := "VM"
|
|
if discoveredGuest.GuestType == providers.GuestTypeContainer {
|
|
guestKind = "Container"
|
|
}
|
|
|
|
fmt.Fprintf(guestWriter, " %s\t%s\t%s\t%s\t%s\t%s\n",
|
|
discoveredGuest.Identifier, discoveredGuest.Name, guestKind,
|
|
discoveredGuest.HostID, discoveredGuest.PowerState, strings.Join(discoveredGuest.Tags, ", "))
|
|
}
|
|
_ = guestWriter.Flush()
|
|
|
|
if !showDisks {
|
|
return nil
|
|
}
|
|
|
|
return printDisks(discoveryContext, proxmoxProvider, discoveredGuests)
|
|
}
|
|
|
|
// printDisks gibt die Platten aller Gäste aus.
|
|
func printDisks(diskContext context.Context, proxmoxProvider *proxmox.Provider, discoveredGuests []providers.Guest) error {
|
|
fmt.Println("\nPlatten:")
|
|
|
|
var excludedDiskCount int
|
|
|
|
for _, discoveredGuest := range discoveredGuests {
|
|
guestDisks, diskError := proxmoxProvider.GetVMDisks(diskContext, discoveredGuest.Identifier)
|
|
if diskError != nil {
|
|
// Ein einzelner unlesbarer Gast darf die Übersicht nicht
|
|
// verhindern — verschwiegen wird er trotzdem nicht.
|
|
fmt.Printf(" %s (%s): NICHT LESBAR — %v\n", discoveredGuest.Identifier, discoveredGuest.Name, diskError)
|
|
continue
|
|
}
|
|
|
|
fmt.Printf(" %s (%s):\n", discoveredGuest.Identifier, discoveredGuest.Name)
|
|
|
|
for _, guestDisk := range guestDisks {
|
|
exclusionNote := ""
|
|
if guestDisk.ExcludedFromBackup {
|
|
exclusionNote = " ← backup=0: NICHT im Backup enthalten"
|
|
excludedDiskCount++
|
|
}
|
|
|
|
fmt.Printf(" %-10s %-14s %8s %s%s\n",
|
|
guestDisk.Identifier, guestDisk.StorageID, formatBytes(guestDisk.SizeBytes),
|
|
guestDisk.Format, exclusionNote)
|
|
}
|
|
}
|
|
|
|
if excludedDiskCount > 0 {
|
|
// Diese Warnung ist der eigentliche Wert der Plattenübersicht: Wer sie
|
|
// nicht kennt, hält eine unvollständige Maschine für vollständig.
|
|
fmt.Printf("\nAchtung: %d Platte(n) sind in Proxmox mit backup=0 von der Sicherung ausgenommen.\n"+
|
|
"Eine Wiederherstellung ergibt dann eine unvollständige Maschine.\n", excludedDiskCount)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// byteUnitSuffixes sind die Einheiten der Größenausgabe.
|
|
var byteUnitSuffixes = []string{"B", "KiB", "MiB", "GiB", "TiB", "PiB"}
|
|
|
|
// formatBytes gibt eine Bytezahl lesbar aus.
|
|
func formatBytes(byteCount int64) string {
|
|
if byteCount < 1024 {
|
|
return fmt.Sprintf("%d B", byteCount)
|
|
}
|
|
|
|
scaledValue := float64(byteCount)
|
|
unitIndex := 0
|
|
|
|
for scaledValue >= 1024 && unitIndex < len(byteUnitSuffixes)-1 {
|
|
scaledValue /= 1024
|
|
unitIndex++
|
|
}
|
|
|
|
return fmt.Sprintf("%.1f %s", scaledValue, byteUnitSuffixes[unitIndex])
|
|
}
|