syncova-backup/apps/api/cmd/syncova-proxmox/main.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

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])
}