From 376f6249b24a61759d6b9b1a5a394f0f545fbc32 Mon Sep 17 00:00:00 2001 From: Philipp Date: Thu, 11 Jun 2026 09:18:16 +0200 Subject: [PATCH] feat: add proxmox client --- CHANGELOG.md | 1 + README.md | 2 + TODO.md | 6 + backend/internal/proxmox/client.go | 204 ++++++++++++++++++++++++ backend/internal/proxmox/client_test.go | 121 ++++++++++++++ 5 files changed, 334 insertions(+) create mode 100644 backend/internal/proxmox/client.go create mode 100644 backend/internal/proxmox/client_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 33b9880..d7c5f53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Proxmox-HTTP-Client mit TLS-Fingerprint-Pinning, Token-Auth, Timeout und Retry angelegt. - Cluster-Repository zum verschluesselten Speichern und entschluesselten Laden von Proxmox-Tokens angelegt. - AES-GCM-Krypto-Layer fuer verschluesselte Cluster-Tokens mit Master-Key-Konfiguration angelegt. - Autorisierungs-Middleware fuer deklarative Rollen-/Permission-Pruefung pro Route angelegt. diff --git a/README.md b/README.md index d7645a3..0473a2a 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,8 @@ Cluster-Tokens werden mit AES-256-GCM verschluesselt. `MASTER_KEY_BASE64` muss e Das Cluster-Repository speichert Proxmox-Token-Secrets nur als Ciphertext in `public.clusters.encrypted_token`. Entschluesselte Tokens bleiben interne Runtime-Daten und werden nicht in API-DTOs dokumentiert. +Der Proxmox-Client nutzt HTTPS mit normaler Zertifikatsvalidierung plus SHA-256-Fingerprint-Pinning gegen `clusters.tls_fingerprint`. Proxmox-Token werden nur als `PVEAPIToken==` Header an Proxmox gesendet. + Lokale Dienste: - Supabase API Gateway: `http://localhost:8000` diff --git a/TODO.md b/TODO.md index 99b0036..fad9ca9 100644 --- a/TODO.md +++ b/TODO.md @@ -111,6 +111,11 @@ Arbeitsliste auf Basis von `proxmox-console-entwicklungsplan.md`. Die Entwurfsda - [x] `UpsertCluster` verschluesselt Token-Secrets vor dem Speichern - [x] SQL-Storage fuer `public.clusters` angelegt - [x] Unit-Test stellt sicher, dass gespeicherte Tokens keinen Klartext enthalten +- [x] E3-T03: Proxmox-HTTP-Client mit TLS-Pinning + - [x] HTTPS-Client mit normaler Zertifikatsvalidierung plus SHA-256-Fingerprint-Pinning angelegt + - [x] `PVEAPIToken==` Auth-Header gesetzt + - [x] `Get`, `Post`, `Put`, `Delete` mit Kontext, Timeout und Retry angelegt + - [x] Mock-Server-Tests fuer passenden und falschen Fingerprint angelegt ## MVP-Backlog @@ -166,3 +171,4 @@ Arbeitsliste auf Basis von `proxmox-console-entwicklungsplan.md`. Die Entwurfsda - 2026-06-11: Autorisierungs-Middleware mit Tests fuer erlaubte, fehlende und verweigerte Permissions angelegt. - 2026-06-11: AES-GCM-Krypto-Layer fuer Cluster-Tokens mit Master-Key-Validierung und Unit-Tests angelegt. - 2026-06-11: Cluster-Repository mit verschluesseltem Token-Speicher und Entschluesselung beim Laden angelegt. +- 2026-06-11: Proxmox-HTTP-Client mit TLS-Fingerprint-Pinning, Token-Auth, Timeout und Retry angelegt. diff --git a/backend/internal/proxmox/client.go b/backend/internal/proxmox/client.go new file mode 100644 index 0000000..5cc19e8 --- /dev/null +++ b/backend/internal/proxmox/client.go @@ -0,0 +1,204 @@ +package proxmox + +import ( + "bytes" + "context" + "crypto/sha256" + "crypto/subtle" + "crypto/tls" + "crypto/x509" + "encoding/hex" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "proxui/backend/internal/cluster" +) + +const defaultTimeout = 15 * time.Second + +type Client struct { + baseURL *url.URL + tokenID string + tokenSecret string + httpClient *http.Client + retries int +} + +type Option func(*clientConfig) + +type clientConfig struct { + timeout time.Duration + retries int + rootCAs *x509.CertPool +} + +func WithTimeout(timeout time.Duration) Option { + return func(cfg *clientConfig) { + cfg.timeout = timeout + } +} + +func WithRetries(retries int) Option { + return func(cfg *clientConfig) { + cfg.retries = retries + } +} + +func WithRootCAs(rootCAs *x509.CertPool) Option { + return func(cfg *clientConfig) { + cfg.rootCAs = rootCAs + } +} + +func NewClient(cluster cluster.Cluster, opts ...Option) (*Client, error) { + baseURL, err := url.Parse(strings.TrimRight(cluster.APIEndpoint, "/")) + if err != nil { + return nil, fmt.Errorf("parse proxmox api endpoint: %w", err) + } + if baseURL.Scheme != "https" || baseURL.Host == "" { + return nil, fmt.Errorf("proxmox api endpoint must be an https url") + } + if strings.TrimSpace(cluster.TokenID) == "" { + return nil, fmt.Errorf("proxmox token id is required") + } + if strings.TrimSpace(cluster.TokenSecret) == "" { + return nil, fmt.Errorf("proxmox token secret is required") + } + + fingerprint, err := normalizeFingerprint(cluster.TLSFingerprint) + if err != nil { + return nil, err + } + + cfg := clientConfig{ + timeout: defaultTimeout, + retries: 2, + } + for _, opt := range opts { + opt(&cfg) + } + if cfg.timeout <= 0 { + return nil, fmt.Errorf("timeout must be greater than 0") + } + if cfg.retries < 0 { + return nil, fmt.Errorf("retries must not be negative") + } + + return &Client{ + baseURL: baseURL, + tokenID: cluster.TokenID, + tokenSecret: cluster.TokenSecret, + httpClient: &http.Client{ + Timeout: cfg.timeout, + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + TLSClientConfig: &tls.Config{ + MinVersion: tls.VersionTLS12, + RootCAs: cfg.rootCAs, + VerifyConnection: func(state tls.ConnectionState) error { + return verifyFingerprint(state, fingerprint) + }, + }, + }, + }, + retries: cfg.retries, + }, nil +} + +func (c *Client) Get(ctx context.Context, path string) (*http.Response, error) { + return c.Do(ctx, http.MethodGet, path, nil) +} + +func (c *Client) Post(ctx context.Context, path string, body []byte) (*http.Response, error) { + return c.Do(ctx, http.MethodPost, path, body) +} + +func (c *Client) Put(ctx context.Context, path string, body []byte) (*http.Response, error) { + return c.Do(ctx, http.MethodPut, path, body) +} + +func (c *Client) Delete(ctx context.Context, path string) (*http.Response, error) { + return c.Do(ctx, http.MethodDelete, path, nil) +} + +func (c *Client) Do(ctx context.Context, method string, path string, body []byte) (*http.Response, error) { + var lastErr error + attempts := c.retries + 1 + for attempt := 0; attempt < attempts; attempt++ { + response, err := c.doOnce(ctx, method, path, body) + if err == nil && response.StatusCode < http.StatusInternalServerError { + return response, nil + } + if err == nil { + _, _ = io.Copy(io.Discard, response.Body) + _ = response.Body.Close() + lastErr = fmt.Errorf("proxmox returned %s", response.Status) + } else { + lastErr = err + } + + if ctx.Err() != nil { + return nil, ctx.Err() + } + } + + return nil, lastErr +} + +func (c *Client) doOnce(ctx context.Context, method string, path string, body []byte) (*http.Response, error) { + requestURL := c.resolvePath(path) + request, err := http.NewRequestWithContext(ctx, method, requestURL, bytes.NewReader(body)) + if err != nil { + return nil, err + } + request.Header.Set("Authorization", fmt.Sprintf("PVEAPIToken=%s=%s", c.tokenID, c.tokenSecret)) + if body != nil { + request.Header.Set("Content-Type", "application/json") + } + + return c.httpClient.Do(request) +} + +func (c *Client) resolvePath(path string) string { + resolved := *c.baseURL + resolved.Path = joinURLPath(c.baseURL.Path, path) + return resolved.String() +} + +func joinURLPath(basePath string, path string) string { + basePath = strings.TrimRight(basePath, "/") + path = strings.TrimLeft(path, "/") + if path == "" { + return basePath + } + return basePath + "/" + path +} + +func normalizeFingerprint(fingerprint string) (string, error) { + normalized := strings.NewReplacer(":", "", " ", "", "-", "").Replace(strings.TrimSpace(fingerprint)) + normalized = strings.ToLower(normalized) + if len(normalized) != sha256.Size*2 { + return "", fmt.Errorf("tls fingerprint must be a sha256 hex digest") + } + if _, err := hex.DecodeString(normalized); err != nil { + return "", fmt.Errorf("tls fingerprint must be hex encoded: %w", err) + } + return normalized, nil +} + +func verifyFingerprint(state tls.ConnectionState, expected string) error { + if len(state.PeerCertificates) == 0 { + return fmt.Errorf("server certificate missing") + } + + sum := sha256.Sum256(state.PeerCertificates[0].Raw) + actual := hex.EncodeToString(sum[:]) + if subtle.ConstantTimeCompare([]byte(actual), []byte(expected)) != 1 { + return fmt.Errorf("server certificate fingerprint mismatch") + } + return nil +} diff --git a/backend/internal/proxmox/client_test.go b/backend/internal/proxmox/client_test.go new file mode 100644 index 0000000..8827733 --- /dev/null +++ b/backend/internal/proxmox/client_test.go @@ -0,0 +1,121 @@ +package proxmox + +import ( + "context" + "crypto/sha256" + "crypto/x509" + "encoding/hex" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "proxui/backend/internal/cluster" +) + +func TestClientAcceptsMatchingFingerprintAndSendsTokenHeader(t *testing.T) { + var authHeader string + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authHeader = r.Header.Get("Authorization") + if r.URL.Path != "/api2/json/version" { + t.Fatalf("path = %q, want /api2/json/version", r.URL.Path) + } + _, _ = w.Write([]byte(`{"data":{"version":"8.2"}}`)) + })) + defer server.Close() + + client := newTestClient(t, server, fingerprintForServer(server)) + response, err := client.Get(context.Background(), "/version") + if err != nil { + t.Fatalf("Get() error = %v", err) + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want %d", response.StatusCode, http.StatusOK) + } + if authHeader != "PVEAPIToken=root@pam!proxui=secret-token" { + t.Fatalf("Authorization = %q", authHeader) + } +} + +func TestClientRejectsMismatchedFingerprint(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + client := newTestClient(t, server, strings.Repeat("0", sha256.Size*2)) + _, err := client.Get(context.Background(), "/version") + if err == nil { + t.Fatal("Get() error = nil, want error") + } + if !strings.Contains(err.Error(), "fingerprint mismatch") { + t.Fatalf("error = %q, want fingerprint mismatch", err) + } +} + +func TestClientRetriesServerErrors(t *testing.T) { + var calls int + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + if calls == 1 { + http.Error(w, "temporary", http.StatusBadGateway) + return + } + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + client := newTestClient(t, server, fingerprintForServer(server)) + response, err := client.Post(context.Background(), "/nodes/pve/status", []byte(`{"command":"start"}`)) + if err != nil { + t.Fatalf("Post() error = %v", err) + } + defer response.Body.Close() + _, _ = io.Copy(io.Discard, response.Body) + + if response.StatusCode != http.StatusNoContent { + t.Fatalf("status = %d, want %d", response.StatusCode, http.StatusNoContent) + } + if calls != 2 { + t.Fatalf("calls = %d, want 2", calls) + } +} + +func TestNewClientRejectsInvalidFingerprint(t *testing.T) { + _, err := NewClient(cluster.Cluster{ + APIEndpoint: "https://pve.example.test:8006/api2/json", + TLSFingerprint: "invalid", + TokenID: "root@pam!proxui", + TokenSecret: "secret-token", + }) + if err == nil { + t.Fatal("NewClient() error = nil, want error") + } +} + +func newTestClient(t *testing.T, server *httptest.Server, fingerprint string) *Client { + t.Helper() + + rootCAs := x509.NewCertPool() + rootCAs.AddCert(server.Certificate()) + + client, err := NewClient(cluster.Cluster{ + APIEndpoint: server.URL + "/api2/json", + TLSFingerprint: fingerprint, + TokenID: "root@pam!proxui", + TokenSecret: "secret-token", + }, WithRootCAs(rootCAs), WithTimeout(time.Second), WithRetries(1)) + if err != nil { + t.Fatalf("NewClient() error = %v", err) + } + return client +} + +func fingerprintForServer(server *httptest.Server) string { + sum := sha256.Sum256(server.Certificate().Raw) + return hex.EncodeToString(sum[:]) +}