Files
proxui/platform/proxmox/client.go
T
2026-06-12 14:25:34 +02:00

538 lines
13 KiB
Go

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,
InsecureSkipVerify: true,
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
}
type VMStatus struct {
Status 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),
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) PowerVM(ctx context.Context, node string, vmid int, action string) (string, error) {
response, err := c.Post(ctx, fmt.Sprintf(
"/nodes/%s/qemu/%d/status/%s",
url.PathEscape(node),
vmid,
url.PathEscape(action),
), nil)
if err != nil {
return "", err
}
defer response.Body.Close()
if response.StatusCode >= http.StatusBadRequest {
_, _ = io.Copy(io.Discard, response.Body)
return "", fmt.Errorf("proxmox returned %s", response.Status)
}
var body struct {
Data string `json:"data"`
}
if err := json.NewDecoder(response.Body).Decode(&body); err != nil {
return "", err
}
if body.Data == "" {
return "", fmt.Errorf("proxmox response missing UPID")
}
return body.Data, nil
}
func (c *Client) GetVMStatus(ctx context.Context, node string, vmid int) (VMStatus, error) {
response, err := c.Get(ctx, fmt.Sprintf(
"/nodes/%s/qemu/%d/status/current",
url.PathEscape(node),
vmid,
))
if err != nil {
return VMStatus{}, err
}
defer response.Body.Close()
if response.StatusCode >= http.StatusBadRequest {
_, _ = io.Copy(io.Discard, response.Body)
return VMStatus{}, fmt.Errorf("proxmox returned %s", response.Status)
}
var body struct {
Data struct {
Status string `json:"status"`
} `json:"data"`
}
if err := json.NewDecoder(response.Body).Decode(&body); err != nil {
return VMStatus{}, err
}
if body.Data.Status == "" {
return VMStatus{}, fmt.Errorf("proxmox response missing VM status")
}
return VMStatus{Status: body.Data.Status}, nil
}
func (c *Client) CloneVM(ctx context.Context, node string, templateVMID int, newVMID int, name string) (string, error) {
requestBody := map[string]any{
"newid": newVMID,
"name": name,
}
body, err := json.Marshal(requestBody)
if err != nil {
return "", err
}
response, err := c.Post(ctx, fmt.Sprintf(
"/nodes/%s/qemu/%d/clone",
url.PathEscape(node),
templateVMID,
), body)
if err != nil {
return "", err
}
defer response.Body.Close()
if response.StatusCode >= http.StatusBadRequest {
_, _ = io.Copy(io.Discard, response.Body)
return "", fmt.Errorf("proxmox returned %s", response.Status)
}
return decodeUPID(response.Body)
}
func (c *Client) ConfigureHardware(ctx context.Context, node string, vmid int, cfg HardwareConfig) (string, error) {
requestBody := map[string]any{}
if cfg.Cores > 0 {
requestBody["cores"] = cfg.Cores
}
if cfg.Memory > 0 {
requestBody["memory"] = cfg.Memory
}
body, err := json.Marshal(requestBody)
if err != nil {
return "", err
}
response, err := c.Post(ctx, fmt.Sprintf(
"/nodes/%s/qemu/%d/config",
url.PathEscape(node),
vmid,
), body)
if err != nil {
return "", err
}
defer response.Body.Close()
if response.StatusCode >= http.StatusBadRequest {
_, _ = io.Copy(io.Discard, response.Body)
return "", fmt.Errorf("proxmox returned %s", response.Status)
}
return decodeUPID(response.Body)
}
func (c *Client) ResizeDisk(ctx context.Context, node string, vmid int, disk string, sizeGB int) (string, error) {
requestBody := map[string]any{
"disk": disk,
"size": fmt.Sprintf("%dG", sizeGB),
}
body, err := json.Marshal(requestBody)
if err != nil {
return "", err
}
response, err := c.Put(ctx, fmt.Sprintf(
"/nodes/%s/qemu/%d/resize",
url.PathEscape(node),
vmid,
), body)
if err != nil {
return "", err
}
defer response.Body.Close()
if response.StatusCode >= http.StatusBadRequest {
_, _ = io.Copy(io.Discard, response.Body)
return "", fmt.Errorf("proxmox returned %s", response.Status)
}
return decodeUPID(response.Body)
}
func (c *Client) ConfigureCloudInit(ctx context.Context, node string, vmid int, cfg CloudInitConfig) (string, error) {
requestBody := map[string]any{
"ciuser": cfg.CIUser,
}
if cfg.SSHKeys != "" {
requestBody["sshkeys"] = cfg.SSHKeys
}
if cfg.IPConfig0 != "" {
requestBody["ipconfig0"] = cfg.IPConfig0
}
if cfg.Hostname != "" {
requestBody["searchdomain"] = cfg.Hostname
}
body, err := json.Marshal(requestBody)
if err != nil {
return "", err
}
response, err := c.Post(ctx, fmt.Sprintf(
"/nodes/%s/qemu/%d/config",
url.PathEscape(node),
vmid,
), body)
if err != nil {
return "", err
}
defer response.Body.Close()
if response.StatusCode >= http.StatusBadRequest {
_, _ = io.Copy(io.Discard, response.Body)
return "", fmt.Errorf("proxmox returned %s", response.Status)
}
return decodeUPID(response.Body)
}
func (c *Client) StartVM(ctx context.Context, node string, vmid int) (string, error) {
return c.PowerVM(ctx, node, vmid, "start")
}
func (c *Client) StopVM(ctx context.Context, node string, vmid int) (string, error) {
return c.PowerVM(ctx, node, vmid, "stop")
}
func (c *Client) DeleteVM(ctx context.Context, node string, vmid int) (string, error) {
response, err := c.Delete(ctx, fmt.Sprintf(
"/nodes/%s/qemu/%d",
url.PathEscape(node),
vmid,
))
if err != nil {
return "", err
}
defer response.Body.Close()
if response.StatusCode >= http.StatusBadRequest {
_, _ = io.Copy(io.Discard, response.Body)
return "", fmt.Errorf("proxmox returned %s", response.Status)
}
return decodeUPID(response.Body)
}
type CloudInitConfig struct {
CIUser string `json:"ciuser"`
SSHKeys string `json:"sshkeys,omitempty"`
IPConfig0 string `json:"ipconfig0,omitempty"`
Hostname string `json:"hostname,omitempty"`
}
type HardwareConfig struct {
Cores int
Memory int
}
func decodeUPID(body io.Reader) (string, error) {
var response struct {
Data string `json:"data"`
}
if err := json.NewDecoder(body).Decode(&response); err != nil {
return "", err
}
if response.Data == "" {
return "", fmt.Errorf("proxmox response missing UPID")
}
return response.Data, nil
}
func (c *Client) GetVNCTicket(ctx context.Context, node string, vmid int) (VNCInfo, error) {
requestBody := map[string]any{}
body, err := json.Marshal(requestBody)
if err != nil {
return VNCInfo{}, err
}
response, err := c.Post(ctx, fmt.Sprintf(
"/nodes/%s/qemu/%d/vncproxy",
url.PathEscape(node),
vmid,
), body)
if err != nil {
return VNCInfo{}, err
}
defer response.Body.Close()
if response.StatusCode >= http.StatusBadRequest {
_, _ = io.Copy(io.Discard, response.Body)
return VNCInfo{}, fmt.Errorf("proxmox returned %s", response.Status)
}
var result struct {
Data struct {
Port int `json:"port"`
Ticket string `json:"ticket"`
User string `json:"user"`
Cert string `json:"cert"`
UPID string `json:"upid"`
} `json:"data"`
}
if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
return VNCInfo{}, err
}
return VNCInfo{
Port: result.Data.Port,
Ticket: result.Data.Ticket,
User: result.Data.User,
Cert: result.Data.Cert,
UPID: result.Data.UPID,
}, nil
}
type VNCInfo struct {
Port int
Ticket string
User string
Cert string
UPID string
}
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
}