// Package pve is a minimal Proxmox VE API client: API token auth, generic JSON, // form-encoded writes, task polling and version check. No typed SDK on purpose: // objects stay generic (map[string]any) and are interpreted by the adapters. package pve import ( "context" "crypto/sha256" "crypto/tls" "crypto/x509" "encoding/hex" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "os" "sort" "strconv" "strings" "time" ) // Options describes how to reach and authenticate against one cluster. type Options struct { Name string // cluster name in the config (for messages only) BaseURL string // https://host:8006 TokenID string // user@realm!tokenname TokenSecret string // never logged CAFile string // PEM bundle to trust (optional) CAPEM []byte // PEM bundle given inline (web UI); CAFile wins when both are set Fingerprint string // SHA-256 of the leaf certificate, "AA:BB:..." (optional, pinning) Insecure bool // skip TLS verification (lab only) Timeout time.Duration } // Client talks to the PVE API of one cluster. type Client struct { name string base *url.URL auth string http *http.Client } // APIError is a non-2xx answer of the API. type APIError struct { Method, Path string Status int Message string Errors map[string]string } func (e *APIError) Error() string { msg := fmt.Sprintf("%s %s: HTTP %d", e.Method, e.Path, e.Status) if e.Message != "" { msg += ": " + e.Message } if len(e.Errors) > 0 { keys := make([]string, 0, len(e.Errors)) for k := range e.Errors { keys = append(keys, k) } sort.Strings(keys) for _, k := range keys { msg += fmt.Sprintf(" [%s: %s]", k, strings.TrimSpace(e.Errors[k])) } } return msg } // New builds a client. The token secret is kept in the Authorization header only. func New(o Options) (*Client, error) { u, err := url.Parse(strings.TrimRight(o.BaseURL, "/")) if err != nil || u.Scheme == "" || u.Host == "" { return nil, fmt.Errorf("cluster %s: invalid url %q", o.Name, o.BaseURL) } if o.TokenID == "" || o.TokenSecret == "" { return nil, fmt.Errorf("cluster %s: token id and secret are required", o.Name) } tlsCfg := &tls.Config{MinVersion: tls.VersionTLS12} switch { case o.Fingerprint != "": want := normalizeFingerprint(o.Fingerprint) // Self-signed PVE certificates: pin the leaf certificate instead of a CA. tlsCfg.InsecureSkipVerify = true tlsCfg.VerifyPeerCertificate = func(raw [][]byte, _ [][]*x509.Certificate) error { if len(raw) == 0 { return errors.New("no peer certificate") } sum := sha256.Sum256(raw[0]) if got := hex.EncodeToString(sum[:]); got != want { return fmt.Errorf("certificate fingerprint mismatch (got %s)", got) } return nil } case o.CAFile != "" || len(o.CAPEM) > 0: pem, src := o.CAPEM, "the CA certificate" if o.CAFile != "" { var err error if pem, err = os.ReadFile(o.CAFile); err != nil { return nil, fmt.Errorf("cluster %s: %w", o.Name, err) } src = o.CAFile } pool := x509.NewCertPool() if !pool.AppendCertsFromPEM(pem) { return nil, fmt.Errorf("cluster %s: no certificate in %s", o.Name, src) } tlsCfg.RootCAs = pool case o.Insecure: tlsCfg.InsecureSkipVerify = true } timeout := o.Timeout if timeout == 0 { timeout = 30 * time.Second } return &Client{ name: o.Name, base: u, auth: "PVEAPIToken=" + o.TokenID + "=" + o.TokenSecret, http: &http.Client{ Timeout: timeout, Transport: &http.Transport{TLSClientConfig: tlsCfg, Proxy: http.ProxyFromEnvironment}, }, }, nil } // Name returns the cluster name given in the options. func (c *Client) Name() string { return c.name } func normalizeFingerprint(s string) string { return strings.ToLower(strings.NewReplacer(":", "", " ", "").Replace(s)) } // Get reads path (relative to /api2/json) and decodes "data" into out. func (c *Client) Get(ctx context.Context, path string, query url.Values, out any) error { return c.do(ctx, http.MethodGet, path, query, nil, out) } // Post, Put and Delete send form-encoded parameters (the encoding pvesh and the GUI use). func (c *Client) Post(ctx context.Context, path string, params url.Values, out any) error { return c.do(ctx, http.MethodPost, path, nil, params, out) } func (c *Client) Put(ctx context.Context, path string, params url.Values, out any) error { return c.do(ctx, http.MethodPut, path, nil, params, out) } // Delete passes its parameters in the query string, as the PVE API expects for DELETE. func (c *Client) Delete(ctx context.Context, path string, params url.Values, out any) error { return c.do(ctx, http.MethodDelete, path, params, nil, out) } func (c *Client) do(ctx context.Context, method, path string, query, form url.Values, out any) error { u := *c.base u.Path = "/api2/json/" + strings.TrimLeft(path, "/") if len(query) > 0 { u.RawQuery = query.Encode() } var body io.Reader if form != nil { body = strings.NewReader(form.Encode()) } req, err := http.NewRequestWithContext(ctx, method, u.String(), body) if err != nil { return err } req.Header.Set("Authorization", c.auth) req.Header.Set("Accept", "application/json") if form != nil { req.Header.Set("Content-Type", "application/x-www-form-urlencoded") } resp, err := c.http.Do(req) if err != nil { // url.Error contains the URL only, never the Authorization header. return fmt.Errorf("cluster %s: %w", c.name, err) } defer resp.Body.Close() raw, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20)) if err != nil { return fmt.Errorf("cluster %s: %s %s: %w", c.name, method, path, err) } var env struct { Data json.RawMessage `json:"data"` Message string `json:"message"` Errors map[string]string `json:"errors"` } _ = json.Unmarshal(raw, &env) if resp.StatusCode < 200 || resp.StatusCode > 299 { msg := env.Message if msg == "" { // pveproxy puts the reason in the status line ("596 Broken pipe", "403 Permission check failed") msg = strings.TrimSpace(strings.TrimPrefix(resp.Status, strconv.Itoa(resp.StatusCode))) } return &APIError{Method: method, Path: path, Status: resp.StatusCode, Message: msg, Errors: env.Errors} } if out == nil || len(env.Data) == 0 || string(env.Data) == "null" { return nil } d := json.NewDecoder(strings.NewReader(string(env.Data))) d.UseNumber() if err := d.Decode(out); err != nil { return fmt.Errorf("cluster %s: %s %s: decode: %w", c.name, method, path, err) } return nil }