feat: add proxmox client
This commit is contained in:
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
## Unreleased
|
## 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.
|
- 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.
|
- AES-GCM-Krypto-Layer fuer verschluesselte Cluster-Tokens mit Master-Key-Konfiguration angelegt.
|
||||||
- Autorisierungs-Middleware fuer deklarative Rollen-/Permission-Pruefung pro Route angelegt.
|
- Autorisierungs-Middleware fuer deklarative Rollen-/Permission-Pruefung pro Route angelegt.
|
||||||
|
|||||||
@@ -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.
|
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=<token_id>=<secret>` Header an Proxmox gesendet.
|
||||||
|
|
||||||
Lokale Dienste:
|
Lokale Dienste:
|
||||||
|
|
||||||
- Supabase API Gateway: `http://localhost:8000`
|
- Supabase API Gateway: `http://localhost:8000`
|
||||||
|
|||||||
@@ -111,6 +111,11 @@ Arbeitsliste auf Basis von `proxmox-console-entwicklungsplan.md`. Die Entwurfsda
|
|||||||
- [x] `UpsertCluster` verschluesselt Token-Secrets vor dem Speichern
|
- [x] `UpsertCluster` verschluesselt Token-Secrets vor dem Speichern
|
||||||
- [x] SQL-Storage fuer `public.clusters` angelegt
|
- [x] SQL-Storage fuer `public.clusters` angelegt
|
||||||
- [x] Unit-Test stellt sicher, dass gespeicherte Tokens keinen Klartext enthalten
|
- [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=<token_id>=<secret>` 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
|
## 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: 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: 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: 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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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[:])
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user