472 lines
11 KiB
Go
472 lines
11 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,
|
|
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
|
|
}
|