feat: share proxmox cluster client with worker
This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"forgejo.digital-droplets.de/philschlo/proxui/platform/encryption"
|
||||
)
|
||||
|
||||
type Cluster struct {
|
||||
ID string
|
||||
Name string
|
||||
APIEndpoint string
|
||||
TLSFingerprint string
|
||||
TokenID string
|
||||
TokenSecret string
|
||||
Status string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type StoredCluster struct {
|
||||
ID string
|
||||
Name string
|
||||
APIEndpoint string
|
||||
TLSFingerprint string
|
||||
EncryptedToken []byte
|
||||
TokenID string
|
||||
Status string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type Storage interface {
|
||||
Get(ctx context.Context, id string) (StoredCluster, bool, error)
|
||||
Upsert(ctx context.Context, cluster StoredCluster) (StoredCluster, error)
|
||||
SetStatus(ctx context.Context, id string, status string) (StoredCluster, bool, error)
|
||||
}
|
||||
|
||||
type Repository struct {
|
||||
storage Storage
|
||||
cipher encryption.Cipher
|
||||
}
|
||||
|
||||
func NewRepository(db *sql.DB, cipher encryption.Cipher) Repository {
|
||||
return Repository{
|
||||
storage: SQLStorage{db: db},
|
||||
cipher: cipher,
|
||||
}
|
||||
}
|
||||
|
||||
func NewRepositoryWithStorage(storage Storage, cipher encryption.Cipher) Repository {
|
||||
return Repository{
|
||||
storage: storage,
|
||||
cipher: cipher,
|
||||
}
|
||||
}
|
||||
|
||||
func (r Repository) GetCluster(ctx context.Context, id string) (Cluster, bool, error) {
|
||||
stored, found, err := r.storage.Get(ctx, id)
|
||||
if err != nil || !found {
|
||||
return Cluster{}, found, err
|
||||
}
|
||||
|
||||
tokenSecret, err := r.cipher.Decrypt(stored.EncryptedToken)
|
||||
if err != nil {
|
||||
return Cluster{}, false, err
|
||||
}
|
||||
|
||||
return Cluster{
|
||||
ID: stored.ID,
|
||||
Name: stored.Name,
|
||||
APIEndpoint: stored.APIEndpoint,
|
||||
TLSFingerprint: stored.TLSFingerprint,
|
||||
TokenID: stored.TokenID,
|
||||
TokenSecret: string(tokenSecret),
|
||||
Status: stored.Status,
|
||||
CreatedAt: stored.CreatedAt,
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
func (r Repository) UpsertCluster(ctx context.Context, cluster Cluster) (Cluster, error) {
|
||||
if strings.TrimSpace(cluster.TokenSecret) == "" {
|
||||
return Cluster{}, fmt.Errorf("token secret is required")
|
||||
}
|
||||
|
||||
encryptedToken, err := r.cipher.Encrypt([]byte(cluster.TokenSecret))
|
||||
if err != nil {
|
||||
return Cluster{}, err
|
||||
}
|
||||
|
||||
status := strings.TrimSpace(cluster.Status)
|
||||
if status == "" {
|
||||
status = "active"
|
||||
}
|
||||
|
||||
stored, err := r.storage.Upsert(ctx, StoredCluster{
|
||||
ID: strings.TrimSpace(cluster.ID),
|
||||
Name: cluster.Name,
|
||||
APIEndpoint: cluster.APIEndpoint,
|
||||
TLSFingerprint: cluster.TLSFingerprint,
|
||||
EncryptedToken: encryptedToken,
|
||||
TokenID: cluster.TokenID,
|
||||
Status: status,
|
||||
})
|
||||
if err != nil {
|
||||
return Cluster{}, err
|
||||
}
|
||||
|
||||
return Cluster{
|
||||
ID: stored.ID,
|
||||
Name: stored.Name,
|
||||
APIEndpoint: stored.APIEndpoint,
|
||||
TLSFingerprint: stored.TLSFingerprint,
|
||||
TokenID: stored.TokenID,
|
||||
TokenSecret: cluster.TokenSecret,
|
||||
Status: stored.Status,
|
||||
CreatedAt: stored.CreatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r Repository) SetClusterStatus(ctx context.Context, id string, status string) (Cluster, bool, error) {
|
||||
stored, found, err := r.storage.SetStatus(ctx, strings.TrimSpace(id), strings.TrimSpace(status))
|
||||
if err != nil || !found {
|
||||
return Cluster{}, found, err
|
||||
}
|
||||
|
||||
return Cluster{
|
||||
ID: stored.ID,
|
||||
Name: stored.Name,
|
||||
APIEndpoint: stored.APIEndpoint,
|
||||
TLSFingerprint: stored.TLSFingerprint,
|
||||
TokenID: stored.TokenID,
|
||||
Status: stored.Status,
|
||||
CreatedAt: stored.CreatedAt,
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
type SQLStorage struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func (s SQLStorage) Get(ctx context.Context, id string) (StoredCluster, bool, error) {
|
||||
var cluster StoredCluster
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
select id::text, name, api_endpoint, tls_fingerprint, encrypted_token, token_id, status, created_at
|
||||
from public.clusters
|
||||
where id = $1
|
||||
`, id).Scan(
|
||||
&cluster.ID,
|
||||
&cluster.Name,
|
||||
&cluster.APIEndpoint,
|
||||
&cluster.TLSFingerprint,
|
||||
&cluster.EncryptedToken,
|
||||
&cluster.TokenID,
|
||||
&cluster.Status,
|
||||
&cluster.CreatedAt,
|
||||
)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return StoredCluster{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return StoredCluster{}, false, err
|
||||
}
|
||||
|
||||
return cluster, true, nil
|
||||
}
|
||||
|
||||
func (s SQLStorage) Upsert(ctx context.Context, cluster StoredCluster) (StoredCluster, error) {
|
||||
var stored StoredCluster
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
insert into public.clusters (
|
||||
id,
|
||||
name,
|
||||
api_endpoint,
|
||||
tls_fingerprint,
|
||||
encrypted_token,
|
||||
token_id,
|
||||
status
|
||||
)
|
||||
values (
|
||||
coalesce(nullif($1, '')::uuid, gen_random_uuid()),
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
$5,
|
||||
$6,
|
||||
$7
|
||||
)
|
||||
on conflict (id) do update
|
||||
set name = excluded.name,
|
||||
api_endpoint = excluded.api_endpoint,
|
||||
tls_fingerprint = excluded.tls_fingerprint,
|
||||
encrypted_token = excluded.encrypted_token,
|
||||
token_id = excluded.token_id,
|
||||
status = excluded.status
|
||||
returning id::text, name, api_endpoint, tls_fingerprint, encrypted_token, token_id, status, created_at
|
||||
`,
|
||||
cluster.ID,
|
||||
cluster.Name,
|
||||
cluster.APIEndpoint,
|
||||
cluster.TLSFingerprint,
|
||||
cluster.EncryptedToken,
|
||||
cluster.TokenID,
|
||||
cluster.Status,
|
||||
).Scan(
|
||||
&stored.ID,
|
||||
&stored.Name,
|
||||
&stored.APIEndpoint,
|
||||
&stored.TLSFingerprint,
|
||||
&stored.EncryptedToken,
|
||||
&stored.TokenID,
|
||||
&stored.Status,
|
||||
&stored.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return StoredCluster{}, err
|
||||
}
|
||||
|
||||
return stored, nil
|
||||
}
|
||||
|
||||
func (s SQLStorage) SetStatus(ctx context.Context, id string, status string) (StoredCluster, bool, error) {
|
||||
var stored StoredCluster
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
update public.clusters
|
||||
set status = $2
|
||||
where id = $1
|
||||
returning id::text, name, api_endpoint, tls_fingerprint, encrypted_token, token_id, status, created_at
|
||||
`, id, status).Scan(
|
||||
&stored.ID,
|
||||
&stored.Name,
|
||||
&stored.APIEndpoint,
|
||||
&stored.TLSFingerprint,
|
||||
&stored.EncryptedToken,
|
||||
&stored.TokenID,
|
||||
&stored.Status,
|
||||
&stored.CreatedAt,
|
||||
)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return StoredCluster{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return StoredCluster{}, false, err
|
||||
}
|
||||
|
||||
return stored, true, nil
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"forgejo.digital-droplets.de/philschlo/proxui/platform/encryption"
|
||||
)
|
||||
|
||||
func TestRepositoryEncryptsStoredTokenAndDecryptsOnLoad(t *testing.T) {
|
||||
cipher := testCipher(t, 1)
|
||||
storage := newMemoryStorage()
|
||||
repository := NewRepositoryWithStorage(storage, cipher)
|
||||
|
||||
saved, err := repository.UpsertCluster(context.Background(), Cluster{
|
||||
ID: "cluster-1",
|
||||
Name: "Lab",
|
||||
APIEndpoint: "https://pve.example.test:8006",
|
||||
TLSFingerprint: "AA:BB",
|
||||
TokenID: "root@pam!proxui",
|
||||
TokenSecret: "secret-token",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertCluster() error = %v", err)
|
||||
}
|
||||
|
||||
stored := storage.records[saved.ID]
|
||||
if bytes.Contains(stored.EncryptedToken, []byte("secret-token")) {
|
||||
t.Fatal("stored encrypted token contains plaintext")
|
||||
}
|
||||
|
||||
got, found, err := repository.GetCluster(context.Background(), saved.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetCluster() error = %v", err)
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("GetCluster() found = false, want true")
|
||||
}
|
||||
if got.TokenSecret != "secret-token" {
|
||||
t.Fatalf("TokenSecret = %q, want secret-token", got.TokenSecret)
|
||||
}
|
||||
if got.TokenID != "root@pam!proxui" {
|
||||
t.Fatalf("TokenID = %q, want root@pam!proxui", got.TokenID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepositoryRejectsMissingTokenSecret(t *testing.T) {
|
||||
repository := NewRepositoryWithStorage(newMemoryStorage(), testCipher(t, 1))
|
||||
|
||||
_, err := repository.UpsertCluster(context.Background(), Cluster{
|
||||
ID: "cluster-1",
|
||||
Name: "Lab",
|
||||
APIEndpoint: "https://pve.example.test:8006",
|
||||
TokenID: "root@pam!proxui",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("UpsertCluster() error = nil, want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepositoryReturnsErrorWhenStoredTokenUsesWrongKey(t *testing.T) {
|
||||
storage := newMemoryStorage()
|
||||
writer := NewRepositoryWithStorage(storage, testCipher(t, 1))
|
||||
reader := NewRepositoryWithStorage(storage, testCipher(t, 2))
|
||||
|
||||
saved, err := writer.UpsertCluster(context.Background(), Cluster{
|
||||
ID: "cluster-1",
|
||||
Name: "Lab",
|
||||
APIEndpoint: "https://pve.example.test:8006",
|
||||
TLSFingerprint: "AA:BB",
|
||||
TokenID: "root@pam!proxui",
|
||||
TokenSecret: "secret-token",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertCluster() error = %v", err)
|
||||
}
|
||||
|
||||
if _, _, err := reader.GetCluster(context.Background(), saved.ID); err == nil {
|
||||
t.Fatal("GetCluster() error = nil, want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepositorySetsClusterStatusWithoutDecryptingToken(t *testing.T) {
|
||||
storage := newMemoryStorage()
|
||||
repository := NewRepositoryWithStorage(storage, testCipher(t, 1))
|
||||
|
||||
_, err := repository.UpsertCluster(context.Background(), Cluster{
|
||||
ID: "cluster-1",
|
||||
Name: "Lab",
|
||||
APIEndpoint: "https://pve.example.test:8006",
|
||||
TLSFingerprint: "AA:BB",
|
||||
TokenID: "root@pam!proxui",
|
||||
TokenSecret: "secret-token",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertCluster() error = %v", err)
|
||||
}
|
||||
|
||||
updated, found, err := repository.SetClusterStatus(context.Background(), "cluster-1", "disabled")
|
||||
if err != nil {
|
||||
t.Fatalf("SetClusterStatus() error = %v", err)
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("SetClusterStatus() found = false, want true")
|
||||
}
|
||||
if updated.Status != "disabled" {
|
||||
t.Fatalf("Status = %q, want disabled", updated.Status)
|
||||
}
|
||||
if updated.TokenSecret != "" {
|
||||
t.Fatal("SetClusterStatus() returned token secret")
|
||||
}
|
||||
}
|
||||
|
||||
func testCipher(t *testing.T, value byte) encryption.Cipher {
|
||||
t.Helper()
|
||||
|
||||
cipher, err := encryption.New(bytes.Repeat([]byte{value}, 32))
|
||||
if err != nil {
|
||||
t.Fatalf("encryption.New() error = %v", err)
|
||||
}
|
||||
return cipher
|
||||
}
|
||||
|
||||
type memoryStorage struct {
|
||||
records map[string]StoredCluster
|
||||
nextID int
|
||||
}
|
||||
|
||||
func newMemoryStorage() *memoryStorage {
|
||||
return &memoryStorage{
|
||||
records: make(map[string]StoredCluster),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *memoryStorage) Get(_ context.Context, id string) (StoredCluster, bool, error) {
|
||||
cluster, ok := s.records[id]
|
||||
return cluster, ok, nil
|
||||
}
|
||||
|
||||
func (s *memoryStorage) Upsert(_ context.Context, cluster StoredCluster) (StoredCluster, error) {
|
||||
if cluster.ID == "" {
|
||||
s.nextID++
|
||||
cluster.ID = fmt.Sprintf("cluster-%d", s.nextID)
|
||||
}
|
||||
if cluster.Status == "" {
|
||||
cluster.Status = "active"
|
||||
}
|
||||
if cluster.CreatedAt.IsZero() {
|
||||
cluster.CreatedAt = time.Now()
|
||||
}
|
||||
|
||||
s.records[cluster.ID] = cluster
|
||||
return cluster, nil
|
||||
}
|
||||
|
||||
func (s *memoryStorage) SetStatus(_ context.Context, id string, status string) (StoredCluster, bool, error) {
|
||||
cluster, ok := s.records[id]
|
||||
if !ok {
|
||||
return StoredCluster{}, false, nil
|
||||
}
|
||||
cluster.Status = status
|
||||
s.records[id] = cluster
|
||||
return cluster, true, nil
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package encryption
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
currentVersion byte = 1
|
||||
keySize = 32
|
||||
)
|
||||
|
||||
type Cipher struct {
|
||||
gcm cipher.AEAD
|
||||
}
|
||||
|
||||
func New(masterKey []byte) (Cipher, error) {
|
||||
if len(masterKey) != keySize {
|
||||
return Cipher{}, fmt.Errorf("master key must be %d bytes", keySize)
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(masterKey)
|
||||
if err != nil {
|
||||
return Cipher{}, err
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return Cipher{}, err
|
||||
}
|
||||
|
||||
return Cipher{gcm: gcm}, nil
|
||||
}
|
||||
|
||||
func NewFromBase64(encodedKey string) (Cipher, error) {
|
||||
encodedKey = strings.TrimSpace(encodedKey)
|
||||
if encodedKey == "" {
|
||||
return Cipher{}, fmt.Errorf("MASTER_KEY_BASE64 is required")
|
||||
}
|
||||
|
||||
key, err := base64.StdEncoding.DecodeString(encodedKey)
|
||||
if err != nil {
|
||||
return Cipher{}, fmt.Errorf("MASTER_KEY_BASE64 must be base64 encoded: %w", err)
|
||||
}
|
||||
|
||||
return New(key)
|
||||
}
|
||||
|
||||
func (c Cipher) Encrypt(plaintext []byte) ([]byte, error) {
|
||||
nonce := make([]byte, c.gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ciphertext := make([]byte, 0, 1+len(nonce)+len(plaintext)+c.gcm.Overhead())
|
||||
ciphertext = append(ciphertext, currentVersion)
|
||||
ciphertext = append(ciphertext, nonce...)
|
||||
ciphertext = c.gcm.Seal(ciphertext, nonce, plaintext, nil)
|
||||
return ciphertext, nil
|
||||
}
|
||||
|
||||
func (c Cipher) Decrypt(ciphertext []byte) ([]byte, error) {
|
||||
nonceSize := c.gcm.NonceSize()
|
||||
if len(ciphertext) < 1+nonceSize+c.gcm.Overhead() {
|
||||
return nil, fmt.Errorf("ciphertext is too short")
|
||||
}
|
||||
if ciphertext[0] != currentVersion {
|
||||
return nil, fmt.Errorf("unsupported ciphertext version")
|
||||
}
|
||||
|
||||
nonce := ciphertext[1 : 1+nonceSize]
|
||||
encrypted := ciphertext[1+nonceSize:]
|
||||
return c.gcm.Open(nil, nonce, encrypted, nil)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package encryption
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCipherRoundtrip(t *testing.T) {
|
||||
cipher, err := New(bytes.Repeat([]byte{1}, keySize))
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
|
||||
plaintext := []byte("secret-proxmox-token")
|
||||
ciphertext, err := cipher.Encrypt(plaintext)
|
||||
if err != nil {
|
||||
t.Fatalf("Encrypt() error = %v", err)
|
||||
}
|
||||
if bytes.Contains(ciphertext, plaintext) {
|
||||
t.Fatal("ciphertext contains plaintext")
|
||||
}
|
||||
if ciphertext[0] != currentVersion {
|
||||
t.Fatalf("version = %d, want %d", ciphertext[0], currentVersion)
|
||||
}
|
||||
|
||||
got, err := cipher.Decrypt(ciphertext)
|
||||
if err != nil {
|
||||
t.Fatalf("Decrypt() error = %v", err)
|
||||
}
|
||||
if string(got) != string(plaintext) {
|
||||
t.Fatalf("plaintext = %q, want %q", got, plaintext)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptRejectsWrongKey(t *testing.T) {
|
||||
cipherA, err := New(bytes.Repeat([]byte{1}, keySize))
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
cipherB, err := New(bytes.Repeat([]byte{2}, keySize))
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
|
||||
ciphertext, err := cipherA.Encrypt([]byte("secret-proxmox-token"))
|
||||
if err != nil {
|
||||
t.Fatalf("Encrypt() error = %v", err)
|
||||
}
|
||||
|
||||
if _, err := cipherB.Decrypt(ciphertext); err == nil {
|
||||
t.Fatal("Decrypt() error = nil, want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptRejectsUnknownVersion(t *testing.T) {
|
||||
cipher, err := New(bytes.Repeat([]byte{1}, keySize))
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
|
||||
ciphertext, err := cipher.Encrypt([]byte("secret-proxmox-token"))
|
||||
if err != nil {
|
||||
t.Fatalf("Encrypt() error = %v", err)
|
||||
}
|
||||
ciphertext[0] = 99
|
||||
|
||||
if _, err := cipher.Decrypt(ciphertext); err == nil {
|
||||
t.Fatal("Decrypt() error = nil, want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRejectsInvalidKeySize(t *testing.T) {
|
||||
if _, err := New(bytes.Repeat([]byte{1}, keySize-1)); err == nil {
|
||||
t.Fatal("New() error = nil, want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewFromBase64(t *testing.T) {
|
||||
encoded := base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{1}, keySize))
|
||||
|
||||
if _, err := NewFromBase64(encoded); err != nil {
|
||||
t.Fatalf("NewFromBase64() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewFromBase64RejectsMissingKey(t *testing.T) {
|
||||
_, err := NewFromBase64("")
|
||||
if err == nil {
|
||||
t.Fatal("NewFromBase64() error = nil, want error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "MASTER_KEY_BASE64") {
|
||||
t.Fatalf("error = %q, want MASTER_KEY_BASE64 hint", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
package proxmox
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"forgejo.digital-droplets.de/philschlo/proxui/platform/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)
|
||||
}
|
||||
|
||||
type TaskStatus struct {
|
||||
Status string
|
||||
ExitStatus string
|
||||
}
|
||||
|
||||
func (c *Client) GetTaskStatus(ctx context.Context, node string, upid string) (TaskStatus, error) {
|
||||
response, err := c.Get(ctx, fmt.Sprintf(
|
||||
"/nodes/%s/tasks/%s/status",
|
||||
url.PathEscape(node),
|
||||
url.PathEscape(upid),
|
||||
))
|
||||
if err != nil {
|
||||
return TaskStatus{}, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode >= http.StatusBadRequest {
|
||||
_, _ = io.Copy(io.Discard, response.Body)
|
||||
return TaskStatus{}, fmt.Errorf("proxmox returned %s", response.Status)
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Data struct {
|
||||
Status string `json:"status"`
|
||||
ExitStatus string `json:"exitstatus"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(response.Body).Decode(&body); err != nil {
|
||||
return TaskStatus{}, err
|
||||
}
|
||||
|
||||
return TaskStatus{
|
||||
Status: body.Data.Status,
|
||||
ExitStatus: body.Data.ExitStatus,
|
||||
}, 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,144 @@
|
||||
package proxmox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"forgejo.digital-droplets.de/philschlo/proxui/platform/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 TestClientGetsTaskStatus(t *testing.T) {
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api2/json/nodes/pve/tasks/UPID:pve:1/status" {
|
||||
t.Fatalf("path = %q", r.URL.Path)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"data":{"status":"stopped","exitstatus":"OK"}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := newTestClient(t, server, fingerprintForServer(server))
|
||||
status, err := client.GetTaskStatus(context.Background(), "pve", "UPID:pve:1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetTaskStatus() error = %v", err)
|
||||
}
|
||||
|
||||
if status.Status != "stopped" {
|
||||
t.Fatalf("Status = %q, want stopped", status.Status)
|
||||
}
|
||||
if status.ExitStatus != "OK" {
|
||||
t.Fatalf("ExitStatus = %q, want OK", status.ExitStatus)
|
||||
}
|
||||
}
|
||||
|
||||
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