345 lines
11 KiB
Go
345 lines
11 KiB
Go
// Package pve is a minimal Proxmox VE API client: ticket auth with the root@pam
|
|
// credentials, 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. What the API does not cover (the password hashes
|
|
// of the pve realm) goes through a Shell, an SSH session on a node.
|
|
package pve
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"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
|
|
User string // user@realm, root@pam by default
|
|
Password string // never logged
|
|
CAPEM []byte // 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
|
|
Shell Shell // SSH access to a node (optional)
|
|
}
|
|
|
|
// Shell runs a command on a node of the cluster (SSH), stdin fed with in.
|
|
type Shell interface {
|
|
Run(ctx context.Context, cmd string, in []byte) ([]byte, error)
|
|
}
|
|
|
|
// ticketTTL: PVE tickets live 2 hours (pve-access-control AccessControl.pm,
|
|
// $ticket_lifetime); a new one is requested well before.
|
|
const ticketTTL = 90 * time.Minute
|
|
|
|
// Client talks to the PVE API of one cluster.
|
|
type Client struct {
|
|
name string
|
|
base *url.URL
|
|
user string
|
|
password string
|
|
http *http.Client
|
|
shell Shell
|
|
|
|
mu sync.Mutex
|
|
ticket string
|
|
csrf string
|
|
ticketAt time.Time
|
|
}
|
|
|
|
// ErrNoShell: the operation needs an SSH access to a node.
|
|
var ErrNoShell = errors.New("SSH access required")
|
|
|
|
// 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.User == "" {
|
|
o.User = "root@pam"
|
|
}
|
|
if o.Password == "" {
|
|
return nil, fmt.Errorf("cluster %s: the password of %s is required", o.Name, o.User)
|
|
}
|
|
tlsCfg, err := TLSConfig(o.Name, o.CAPEM, o.Fingerprint, o.Insecure)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
timeout := o.Timeout
|
|
if timeout == 0 {
|
|
timeout = 30 * time.Second
|
|
}
|
|
return &Client{
|
|
name: o.Name,
|
|
base: u,
|
|
user: o.User,
|
|
password: o.Password,
|
|
shell: o.Shell,
|
|
http: &http.Client{
|
|
Timeout: timeout,
|
|
Transport: &http.Transport{TLSClientConfig: tlsCfg, Proxy: http.ProxyFromEnvironment},
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// TLSConfig builds the TLS settings of a cluster: certificate pinning (self-signed
|
|
// PVE certificates), a CA bundle, the system authorities, or no verification.
|
|
func TLSConfig(name string, caPEM []byte, fingerprint string, insecure bool) (*tls.Config, error) {
|
|
tlsCfg := &tls.Config{MinVersion: tls.VersionTLS12}
|
|
switch {
|
|
case fingerprint != "":
|
|
want := normalizeFingerprint(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)", FormatFingerprint(got))
|
|
}
|
|
return nil
|
|
}
|
|
case len(caPEM) > 0:
|
|
pool := x509.NewCertPool()
|
|
if !pool.AppendCertsFromPEM(caPEM) {
|
|
return nil, fmt.Errorf("cluster %s: no certificate in the CA bundle", name)
|
|
}
|
|
tlsCfg.RootCAs = pool
|
|
case insecure:
|
|
tlsCfg.InsecureSkipVerify = true
|
|
}
|
|
return tlsCfg, nil
|
|
}
|
|
|
|
// PeerFingerprint connects to the API and returns the SHA-256 fingerprint of the
|
|
// certificate it presents, without verifying it: the administrator pins it on
|
|
// the first connection (trust on first use) and sees it in the cluster list.
|
|
func PeerFingerprint(ctx context.Context, baseURL string) (string, error) {
|
|
u, err := url.Parse(baseURL)
|
|
if err != nil || u.Host == "" {
|
|
return "", fmt.Errorf("invalid url %q", baseURL)
|
|
}
|
|
host := u.Host
|
|
if u.Port() == "" {
|
|
host += ":8006"
|
|
}
|
|
d := tls.Dialer{Config: &tls.Config{MinVersion: tls.VersionTLS12, InsecureSkipVerify: true}}
|
|
conn, err := d.DialContext(ctx, "tcp", host)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer conn.Close()
|
|
certs := conn.(*tls.Conn).ConnectionState().PeerCertificates
|
|
if len(certs) == 0 {
|
|
return "", errors.New("no peer certificate")
|
|
}
|
|
sum := sha256.Sum256(certs[0].Raw)
|
|
return FormatFingerprint(hex.EncodeToString(sum[:])), nil
|
|
}
|
|
|
|
// FormatFingerprint prints a SHA-256 fingerprint like PVE does ("AA:BB:...").
|
|
func FormatFingerprint(fp string) string {
|
|
fp = strings.ToUpper(normalizeFingerprint(fp))
|
|
var b strings.Builder
|
|
for i := 0; i < len(fp); i += 2 {
|
|
if i > 0 {
|
|
b.WriteByte(':')
|
|
}
|
|
b.WriteString(fp[i:min(i+2, len(fp))])
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// Name returns the cluster name given in the options.
|
|
func (c *Client) Name() string { return c.name }
|
|
|
|
// Shell returns the SSH access to a node, or ErrNoShell.
|
|
func (c *Client) Shell() (Shell, error) {
|
|
if c.shell == nil {
|
|
return nil, fmt.Errorf("cluster %s: %w", c.name, ErrNoShell)
|
|
}
|
|
return c.shell, nil
|
|
}
|
|
|
|
func normalizeFingerprint(s string) string {
|
|
return strings.ToLower(strings.NewReplacer(":", "", " ", "").Replace(s))
|
|
}
|
|
|
|
// login gets a ticket and a CSRF token (POST /access/ticket). Accounts with a
|
|
// second factor are refused: the tool cannot answer the TFA challenge.
|
|
func (c *Client) login(ctx context.Context) error {
|
|
var res struct {
|
|
Ticket string `json:"ticket"`
|
|
CSRF string `json:"CSRFPreventionToken"`
|
|
NeedTFA any `json:"NeedTFA"`
|
|
}
|
|
f := url.Values{"username": {c.user}, "password": {c.password}}
|
|
if err := c.send(ctx, http.MethodPost, "access/ticket", nil, f, &res, false); err != nil {
|
|
var ae *APIError
|
|
if errors.As(err, &ae) && ae.Status == http.StatusUnauthorized {
|
|
return fmt.Errorf("cluster %s: authentication of %s failed", c.name, c.user)
|
|
}
|
|
return err
|
|
}
|
|
if Str(res.NeedTFA) == "1" {
|
|
return fmt.Errorf("cluster %s: %s has a second factor (TFA), not supported", c.name, c.user)
|
|
}
|
|
if res.Ticket == "" {
|
|
return fmt.Errorf("cluster %s: no ticket in the login answer", c.name)
|
|
}
|
|
c.ticket, c.csrf, c.ticketAt = res.Ticket, res.CSRF, time.Now()
|
|
return nil
|
|
}
|
|
|
|
// Login checks the credentials (and renews the ticket when needed).
|
|
func (c *Client) Login(ctx context.Context) error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if c.ticket != "" && time.Since(c.ticketAt) < ticketTTL {
|
|
return nil
|
|
}
|
|
return c.login(ctx)
|
|
}
|
|
|
|
// 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 {
|
|
if err := c.Login(ctx); err != nil {
|
|
return err
|
|
}
|
|
err := c.send(ctx, method, path, query, form, out, true)
|
|
var ae *APIError
|
|
if errors.As(err, &ae) && ae.Status == http.StatusUnauthorized {
|
|
// ticket expired early (key rotation on the cluster): log in again once
|
|
c.mu.Lock()
|
|
c.ticket = ""
|
|
c.mu.Unlock()
|
|
if err := c.Login(ctx); err != nil {
|
|
return err
|
|
}
|
|
err = c.send(ctx, method, path, query, form, out, true)
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (c *Client) send(ctx context.Context, method, path string, query, form url.Values, out any, auth bool) 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
|
|
}
|
|
if auth {
|
|
c.mu.Lock()
|
|
req.AddCookie(&http.Cookie{Name: "PVEAuthCookie", Value: c.ticket})
|
|
if method != http.MethodGet {
|
|
req.Header.Set("CSRFPreventionToken", c.csrf)
|
|
}
|
|
c.mu.Unlock()
|
|
}
|
|
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 ticket nor the password.
|
|
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
|
|
}
|