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>
428 lines
15 KiB
Go
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
|
|
}
|