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

428 lines
15 KiB
Go

// Package proxmox setzt die Provider-Schnittstelle für Proxmox VE um.
//
// Die Umsetzung spricht ausschließlich die offizielle REST-API unter
// /api2/json (SYNCOVA_ARCHITECTURE.md §12: „Use official Proxmox APIs where
// possible"). Wo die API etwas nicht hergibt, wird das gemeldet und nicht
// umgangen.
package proxmox
import (
"bytes"
"context"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
"github.com/syncova/syncova/packages/platform/logging"
)
// apiBasePath ist der Wurzelpfad der Proxmox-API.
const apiBasePath = "/api2/json"
// ClientOptions steuern den Zugang zu einem Proxmox-Endpunkt.
type ClientOptions struct {
// BaseURL ist die Adresse des Endpunkts, etwa https://pve.example:8006.
BaseURL string
// APITokenID ist die Kennung des API-Tokens, etwa "syncova@pve!backup".
//
// Bewusst kein Benutzername mit Passwort: ein Ticket läuft nach zwei
// Stunden ab und verlangt zusätzlich einen CSRF-Wert für ändernde Aufrufe.
// Ein API-Token ist dauerhaft gültig, einzeln widerrufbar und lässt sich
// mit eigenen Rechten versehen — genau das, was ein Dienstkonto braucht.
APITokenID string
// APITokenSecret ist das Geheimnis des Tokens.
//
// Es erscheint niemals in Protokollen, Fehlermeldungen oder API-Antworten
// (PROMPT.md §141).
APITokenSecret string
// TLSFingerprintSHA256 bindet die Verbindung an ein bestimmtes Zertifikat.
//
// Proxmox liefert ab Werk ein selbstsigniertes Zertifikat. Die übliche
// Antwort darauf ist InsecureSkipVerify — und damit jede Sicherheit gegen
// einen Mittelsmann aufzugeben. Ein festgelegter Fingerabdruck bindet die
// Verbindung an genau dieses Zertifikat und ist damit sogar strenger als
// eine gewöhnliche Prüfung gegen eine Zertifizierungsstelle.
TLSFingerprintSHA256 string
// InsecureSkipTLSVerify schaltet die Zertifikatsprüfung ab.
//
// Ausschließlich für Laborumgebungen. Der Client protokolliert die
// Verwendung bei jedem Verbindungsaufbau als Warnung — wer sie abschaltet,
// soll es in den Protokollen wiederfinden.
InsecureSkipTLSVerify bool
// RequestTimeout begrenzt einen einzelnen Aufruf; 0 wählt den Standard.
RequestTimeout time.Duration
// MaximumRetries ist die Zahl der Wiederholungen bei vorübergehenden Fehlern.
MaximumRetries int
}
// defaultRequestTimeout ist die Zeitgrenze eines einzelnen Aufrufs.
//
// Sie gilt nicht für Aufgaben: die laufen asynchron und werden über ihre
// Kennung verfolgt.
const defaultRequestTimeout = 30 * time.Second
// defaultMaximumRetries ist die Standardzahl der Wiederholungen.
const defaultMaximumRetries = 3
// Client spricht die Proxmox-REST-API.
type Client struct {
// baseURL ist der Wurzelpfad ohne abschließenden Schrägstrich.
baseURL string
// authorizationHeader ist der fertige Anmeldekopf.
//
// Er wird einmal gebildet und nie zerlegt; damit gibt es keine Stelle, an
// der das Geheimnis versehentlich einzeln ausgegeben würde.
authorizationHeader string
// httpClient führt die Aufrufe aus.
httpClient *http.Client
// maximumRetries ist die Zahl der Wiederholungen.
maximumRetries int
// logger protokolliert den Verlauf.
logger *slog.Logger
}
// ErrMissingCredentials meldet fehlende Anmeldedaten.
var ErrMissingCredentials = errors.New("es wurden keine vollständigen anmeldedaten für proxmox angegeben")
// ErrTLSFingerprintMismatch meldet ein nicht erwartetes Zertifikat.
var ErrTLSFingerprintMismatch = errors.New("das zertifikat des servers weicht vom hinterlegten fingerabdruck ab")
// NewClient erzeugt einen Proxmox-Client.
func NewClient(clientOptions ClientOptions, baseLogger *slog.Logger) (*Client, error) {
trimmedBaseURL := strings.TrimSuffix(strings.TrimSpace(clientOptions.BaseURL), "/")
if trimmedBaseURL == "" {
return nil, errors.New("es wurde keine adresse für den proxmox-endpunkt angegeben")
}
parsedURL, parseError := url.Parse(trimmedBaseURL)
if parseError != nil || parsedURL.Host == "" {
return nil, fmt.Errorf("die adresse %q ist keine gültige url", clientOptions.BaseURL)
}
// Unverschlüsselt gäbe es das Token im Klartext auf der Leitung.
if parsedURL.Scheme != "https" {
return nil, fmt.Errorf("der proxmox-endpunkt muss über https erreichbar sein, angegeben war %q", parsedURL.Scheme)
}
if strings.TrimSpace(clientOptions.APITokenID) == "" || clientOptions.APITokenSecret == "" {
return nil, ErrMissingCredentials
}
transportSettings, transportError := buildTransport(clientOptions)
if transportError != nil {
return nil, transportError
}
requestTimeout := clientOptions.RequestTimeout
if requestTimeout <= 0 {
requestTimeout = defaultRequestTimeout
}
maximumRetries := clientOptions.MaximumRetries
if maximumRetries <= 0 {
maximumRetries = defaultMaximumRetries
}
clientLogger := logging.WithComponent(baseLogger, "proxmox-client")
if clientOptions.InsecureSkipTLSVerify {
clientLogger.Warn("die zertifikatsprüfung ist abgeschaltet; die verbindung ist nicht gegen einen mittelsmann geschützt",
slog.String("endpunkt", trimmedBaseURL))
}
return &Client{
baseURL: trimmedBaseURL,
// Format nach Proxmox-Vorgabe: PVEAPIToken=USER@REALM!TOKENID=SECRET
authorizationHeader: fmt.Sprintf("PVEAPIToken=%s=%s", clientOptions.APITokenID, clientOptions.APITokenSecret),
httpClient: &http.Client{
Transport: transportSettings,
Timeout: requestTimeout,
},
maximumRetries: maximumRetries,
logger: clientLogger,
}, nil
}
// buildTransport richtet die TLS-Einstellungen ein.
func buildTransport(clientOptions ClientOptions) (*http.Transport, error) {
tlsConfiguration := &tls.Config{MinVersion: tls.VersionTLS12}
expectedFingerprint := normalizeFingerprint(clientOptions.TLSFingerprintSHA256)
switch {
case expectedFingerprint != "":
// Die gewöhnliche Prüfung wird abgeschaltet und durch die eigene
// ersetzt. Das ist kein Nachlassen: das Zertifikat muss exakt das
// erwartete sein, nicht bloß von irgendeiner Stelle unterschrieben.
tlsConfiguration.InsecureSkipVerify = true
tlsConfiguration.VerifyPeerCertificate = buildFingerprintVerifier(expectedFingerprint)
case clientOptions.InsecureSkipTLSVerify:
tlsConfiguration.InsecureSkipVerify = true
default:
// Prüfung gegen die Zertifikatsspeicher des Systems.
}
return &http.Transport{
TLSClientConfig: tlsConfiguration,
MaxIdleConns: 16,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 15 * time.Second,
}, nil
}
// buildFingerprintVerifier prüft das Serverzertifikat gegen einen Fingerabdruck.
func buildFingerprintVerifier(expectedFingerprint string) func([][]byte, [][]*x509.Certificate) error {
return func(rawCertificates [][]byte, _ [][]*x509.Certificate) error {
if len(rawCertificates) == 0 {
return errors.New("der server hat kein zertifikat vorgelegt")
}
// Geprüft wird das Blattzertifikat — dasjenige, das Proxmox anzeigt.
presentedDigest := sha256.Sum256(rawCertificates[0])
presentedFingerprint := hex.EncodeToString(presentedDigest[:])
if presentedFingerprint != expectedFingerprint {
return fmt.Errorf("%w (erwartet %s, vorgelegt %s)",
ErrTLSFingerprintMismatch, expectedFingerprint, presentedFingerprint)
}
return nil
}
}
// normalizeFingerprint bringt einen Fingerabdruck auf eine Vergleichsform.
//
// Proxmox zeigt Fingerabdrücke mit Doppelpunkten und in Großbuchstaben an. Wer
// sie von dort kopiert, soll sie nicht erst umschreiben müssen.
func normalizeFingerprint(fingerprintText string) string {
return strings.ToLower(strings.ReplaceAll(strings.TrimSpace(fingerprintText), ":", ""))
}
// apiEnvelope ist die Antworthülle der Proxmox-API.
type apiEnvelope struct {
// Data ist die Nutzlast; ihre Gestalt hängt vom Endpunkt ab.
Data json.RawMessage `json:"data"`
// Errors sind feldbezogene Fehler bei ändernden Aufrufen.
Errors map[string]string `json:"errors,omitempty"`
}
// APIError beschreibt einen von Proxmox gemeldeten Fehler.
type APIError struct {
// StatusCode ist der HTTP-Status.
StatusCode int
// Message ist die Meldung der Plattform.
Message string
// Path ist der aufgerufene Pfad.
Path string
// FieldErrors sind feldbezogene Meldungen.
FieldErrors map[string]string
}
// Error erfüllt die Fehlerschnittstelle.
func (apiError *APIError) Error() string {
if len(apiError.FieldErrors) > 0 {
return fmt.Sprintf("proxmox meldete %d für %s: %s (%v)",
apiError.StatusCode, apiError.Path, apiError.Message, apiError.FieldErrors)
}
return fmt.Sprintf("proxmox meldete %d für %s: %s", apiError.StatusCode, apiError.Path, apiError.Message)
}
// IsTransient meldet, ob ein erneuter Versuch sinnvoll ist.
//
// Die Unterscheidung ist keine Feinheit: Ein 401 wiederholt sich beliebig oft
// mit demselben Ergebnis, während ein 503 beim nächsten Versuch verschwunden
// sein kann. Ohne sie würde entweder zu früh aufgegeben oder sinnlos gewartet
// (PROMPT.md §139: jeder Fehler wird klassifiziert).
func (apiError *APIError) IsTransient() bool {
switch apiError.StatusCode {
case http.StatusTooManyRequests,
http.StatusInternalServerError,
http.StatusBadGateway,
http.StatusServiceUnavailable,
http.StatusGatewayTimeout:
return true
default:
return false
}
}
// IsNotFound meldet einen nicht vorhandenen Gegenstand.
func (apiError *APIError) IsNotFound() bool {
return apiError.StatusCode == http.StatusNotFound
}
// IsAuthenticationFailure meldet ein abgelehntes oder unzureichendes Token.
func (apiError *APIError) IsAuthenticationFailure() bool {
return apiError.StatusCode == http.StatusUnauthorized || apiError.StatusCode == http.StatusForbidden
}
// get ruft einen lesenden Endpunkt auf.
func (client *Client) get(requestContext context.Context, apiPath string, resultTarget any) error {
return client.callAPI(requestContext, http.MethodGet, apiPath, nil, resultTarget)
}
// post ruft einen anlegenden Endpunkt auf.
func (client *Client) post(requestContext context.Context, apiPath string, formValues url.Values, resultTarget any) error {
return client.callAPI(requestContext, http.MethodPost, apiPath, formValues, resultTarget)
}
// put ruft einen ändernden Endpunkt auf.
func (client *Client) put(requestContext context.Context, apiPath string, formValues url.Values, resultTarget any) error {
return client.callAPI(requestContext, http.MethodPut, apiPath, formValues, resultTarget)
}
// delete ruft einen löschenden Endpunkt auf.
func (client *Client) delete(requestContext context.Context, apiPath string, resultTarget any) error {
return client.callAPI(requestContext, http.MethodDelete, apiPath, nil, resultTarget)
}
// callAPI führt einen Aufruf samt Wiederholungen aus.
func (client *Client) callAPI(requestContext context.Context, httpMethod string, apiPath string, formValues url.Values, resultTarget any) error {
var lastError error
for attemptNumber := 0; attemptNumber <= client.maximumRetries; attemptNumber++ {
if attemptNumber > 0 {
// Begrenztes exponentielles Warten: 1 s, 2 s, 4 s …
waitDuration := time.Duration(1<<uint(attemptNumber-1)) * time.Second
select {
case <-requestContext.Done():
return requestContext.Err()
case <-time.After(waitDuration):
}
client.logger.Debug("aufruf wird wiederholt",
slog.String("pfad", apiPath),
slog.Int("versuch", attemptNumber+1))
}
attemptError := client.performSingleCall(requestContext, httpMethod, apiPath, formValues, resultTarget)
if attemptError == nil {
return nil
}
lastError = attemptError
// Ein Abbruch von außen ist kein Fehler der Gegenseite.
if requestContext.Err() != nil {
return requestContext.Err()
}
var apiError *APIError
if errors.As(attemptError, &apiError) && !apiError.IsTransient() {
return attemptError
}
}
return fmt.Errorf("der aufruf %s scheiterte auch nach %d versuchen: %w",
apiPath, client.maximumRetries+1, lastError)
}
// performSingleCall führt genau einen Aufruf aus.
func (client *Client) performSingleCall(requestContext context.Context, httpMethod string, apiPath string, formValues url.Values, resultTarget any) error {
fullURL := client.baseURL + apiBasePath + apiPath
var requestBody io.Reader
if formValues != nil {
requestBody = strings.NewReader(formValues.Encode())
}
httpRequest, requestError := http.NewRequestWithContext(requestContext, httpMethod, fullURL, requestBody)
if requestError != nil {
return fmt.Errorf("der aufruf an %s konnte nicht gebildet werden: %w", apiPath, requestError)
}
httpRequest.Header.Set("Authorization", client.authorizationHeader)
httpRequest.Header.Set("Accept", "application/json")
if formValues != nil {
httpRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
httpResponse, responseError := client.httpClient.Do(httpRequest)
if responseError != nil {
// Die Fehlermeldung von net/http enthält die vollständige URL, aber
// niemals den Anmeldekopf — das Token bleibt draußen.
return fmt.Errorf("der proxmox-endpunkt war nicht erreichbar: %w", responseError)
}
defer func() {
// Der Rest wird gelesen, damit die Verbindung wiederverwendbar bleibt.
_, _ = io.Copy(io.Discard, io.LimitReader(httpResponse.Body, 4096))
_ = httpResponse.Body.Close()
}()
// Die Grenze schützt vor einer Gegenstelle, die endlos antwortet.
const maximumResponseBytes = 32 << 20
responseBody, readError := io.ReadAll(io.LimitReader(httpResponse.Body, maximumResponseBytes))
if readError != nil {
return fmt.Errorf("die antwort von %s konnte nicht gelesen werden: %w", apiPath, readError)
}
if httpResponse.StatusCode < 200 || httpResponse.StatusCode >= 300 {
return buildAPIError(httpResponse, apiPath, responseBody)
}
if resultTarget == nil {
return nil
}
var responseEnvelope apiEnvelope
if unmarshalError := json.Unmarshal(responseBody, &responseEnvelope); unmarshalError != nil {
return fmt.Errorf("die antwort von %s war kein gültiges json: %w", apiPath, unmarshalError)
}
// Ein "data": null ist eine gültige Antwort auf Aufrufe ohne Rückgabewert.
if len(responseEnvelope.Data) == 0 || bytes.Equal(responseEnvelope.Data, []byte("null")) {
return nil
}
if decodeError := json.Unmarshal(responseEnvelope.Data, resultTarget); decodeError != nil {
return fmt.Errorf("die nutzlast von %s hatte eine unerwartete gestalt: %w", apiPath, decodeError)
}
return nil
}
// buildAPIError deutet eine Fehlerantwort.
func buildAPIError(httpResponse *http.Response, apiPath string, responseBody []byte) error {
apiError := &APIError{
StatusCode: httpResponse.StatusCode,
Path: apiPath,
Message: strings.TrimSpace(httpResponse.Status),
}
// Proxmox liefert bei Eingabefehlern die betroffenen Felder mit. Sie sind
// die eigentliche Auskunft — der Status allein sagt nur „400".
var errorEnvelope struct {
Errors map[string]string `json:"errors"`
Message string `json:"message"`
}
if json.Unmarshal(responseBody, &errorEnvelope) == nil {
if errorEnvelope.Message != "" {
apiError.Message = strings.TrimSpace(errorEnvelope.Message)
}
if len(errorEnvelope.Errors) > 0 {
apiError.FieldErrors = errorEnvelope.Errors
}
}
return apiError
}