// 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<= 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 }