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 } 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), 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) 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) 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"` } 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 }