Phase 0 : client API PVE et modèle d'objets génériques
This commit is contained in:
1 parent
5f4b3ab930
commit
69c340e4c3
3 files changed
+480
No files matched your search
@@ -0,0 +1,202 @@
|
||||
// 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)
|
||||
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 != "":
|
||||
pem, err := os.ReadFile(o.CAFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cluster %s: %w", o.Name, err)
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
if !pool.AppendCertsFromPEM(pem) {
|
||||
return nil, fmt.Errorf("cluster %s: no certificate in %s", o.Name, o.CAFile)
|
||||
}
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user