231 lines
7.9 KiB
Go
231 lines
7.9 KiB
Go
package pve
|
||
|
||
import (
|
||
"context"
|
||
"crypto/tls"
|
||
"crypto/x509"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/url"
|
||
"os"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// Client — тонкий HTTP-клиент к одному Proxmox-кластеру (api2/json).
|
||
// Только stdio-модуль (forge-tools): никакого TCP/HTTP-сервера, никакого
|
||
// session-pool — обычный обозреватель API. Аутентификация — API-токен
|
||
// (отзываемый, ревизуемый; не пароль-ticket с 3-сек. задержкой на 401).
|
||
type Client struct {
|
||
base string // полный URL, напр. https://host:8006/api2/json
|
||
tokenID string
|
||
secret string
|
||
http *http.Client
|
||
maxBody int
|
||
}
|
||
|
||
// APIError — ошибка со стороны Proxmox (HTTP >= 400): доменный отказ.
|
||
// Такие ошибки хендлеры возвращают как errorResult (модель видит и может
|
||
// исправить), а НЕ как Go-ошибку (§3.3 контракт).
|
||
type APIError struct {
|
||
Status int
|
||
Method string
|
||
Path string
|
||
Message string
|
||
}
|
||
|
||
func (e *APIError) Error() string {
|
||
return fmt.Sprintf("proxmox api %s %s: %s (status %d)", e.Method, e.Path, e.Message, e.Status)
|
||
}
|
||
|
||
// IsAPIError сообщает, является ли ошибка доменным отказом Proxmox.
|
||
func IsAPIError(err error) bool {
|
||
var ae *APIError
|
||
return errors.As(err, &ae)
|
||
}
|
||
|
||
// NewClient строит клиент по host-конфигу. TLS: предпочтителен CA-файл
|
||
// (самоподписанный кластер) — insecure только для dev-lab, не по умолчанию.
|
||
// URL объявляет оператор, поэтому SSRF-вектора «модель ввела хост» нет.
|
||
func NewClient(h HostConfig) (*Client, error) {
|
||
if !isValidHTTPURL(h.URL) {
|
||
return nil, fmt.Errorf("proxmox: invalid url for host %q", h.Alias)
|
||
}
|
||
tlsCfg, err := tlsConfig(h)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
transport := &http.Transport{TLSClientConfig: tlsCfg}
|
||
return &Client{
|
||
base: strings.TrimRight(h.URL, "/"),
|
||
tokenID: h.TokenID,
|
||
secret: h.TokenSecret,
|
||
http: &http.Client{Transport: transport},
|
||
maxBody: DefaultMaxOutputBytes,
|
||
}, nil
|
||
}
|
||
|
||
// tlsConfig собирает конфигурацию TLS: CA-файл (рекомендован) либо
|
||
// InsecureSkipVerify (только dev-lab). По умолчанию — системные корни
|
||
// (fail-closed: самоподписанный сертификат не пройдёт без явного выбора).
|
||
func tlsConfig(h HostConfig) (*tls.Config, error) {
|
||
if h.CAFile != "" {
|
||
pem, err := os.ReadFile(h.CAFile)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("proxmox: read ca_file: %w", err)
|
||
}
|
||
pool := x509.NewCertPool()
|
||
if !pool.AppendCertsFromPEM(pem) {
|
||
return nil, fmt.Errorf("proxmox: no certs parsed from ca_file %q", h.CAFile)
|
||
}
|
||
return &tls.Config{RootCAs: pool}, nil
|
||
}
|
||
// Оператор явно выбрал insecure — это dev-lab (самоподписанный PVE).
|
||
return &tls.Config{InsecureSkipVerify: h.Insecure}, nil
|
||
}
|
||
|
||
// Get выполняет GET и возвращает поле "data" из ответа Proxmox (raw JSON).
|
||
func (c *Client) Get(ctx context.Context, path string) (json.RawMessage, error) {
|
||
return c.do(ctx, http.MethodGet, path, nil)
|
||
}
|
||
|
||
// Post выполняет POST с form-телом (PVE принимает application/x-www-form-urlencoded).
|
||
func (c *Client) Post(ctx context.Context, path string, values url.Values) (json.RawMessage, error) {
|
||
return c.do(ctx, http.MethodPost, path, values)
|
||
}
|
||
|
||
// Delete выполняет DELETE.
|
||
func (c *Client) Delete(ctx context.Context, path string) (json.RawMessage, error) {
|
||
return c.do(ctx, http.MethodDelete, path, nil)
|
||
}
|
||
|
||
// do — единая точка запроса: auth-заголовок, таймаут через ctx, retry для
|
||
// идемпотентных GET, разбор {"data":...}, классификация APIError.
|
||
func (c *Client) do(ctx context.Context, method, path string, form url.Values) (json.RawMessage, error) {
|
||
// Ретраим только GET (идемпотентный) на 429/502/503/504 и сетевых сбоях.
|
||
if method == http.MethodGet {
|
||
var last error
|
||
for attempt := 0; attempt < 3; attempt++ {
|
||
data, err := c.once(ctx, method, path, form)
|
||
if err == nil || !retryable(err) {
|
||
return data, err
|
||
}
|
||
last = err
|
||
if !sleep(ctx, backoff(attempt)) {
|
||
return nil, last
|
||
}
|
||
}
|
||
return nil, last
|
||
}
|
||
return c.once(ctx, method, path, form)
|
||
}
|
||
|
||
// once выполняет один HTTP-запрос и разбирает ответ.
|
||
func (c *Client) once(ctx context.Context, method, path string, form url.Values) (json.RawMessage, error) {
|
||
var body io.Reader
|
||
if form != nil {
|
||
body = strings.NewReader(form.Encode())
|
||
}
|
||
req, err := http.NewRequestWithContext(ctx, method, c.base+path, body)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("proxmox: build request: %w", err)
|
||
}
|
||
req.Header.Set("Authorization", "PVEAPIToken="+c.tokenID+"="+c.secret)
|
||
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 {
|
||
return nil, fmt.Errorf("proxmox: %s %s: %w", method, path, err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
raw, err := io.ReadAll(io.LimitReader(resp.Body, int64(c.maxBody)+1))
|
||
if err != nil {
|
||
return nil, fmt.Errorf("proxmox: read %s %s: %w", method, path, err)
|
||
}
|
||
if len(raw) > c.maxBody {
|
||
return nil, fmt.Errorf("proxmox: %s %s response exceeds %d bytes", method, path, c.maxBody)
|
||
}
|
||
|
||
// Доменный отказ (>=400) — APIError с телом/текстом для модели.
|
||
if resp.StatusCode >= 400 {
|
||
return nil, &APIError{
|
||
Status: resp.StatusCode,
|
||
Method: method,
|
||
Path: path,
|
||
Message: apiErrorMessage(raw, resp.Status),
|
||
}
|
||
}
|
||
|
||
// PVE всегда оборачивает успех в {"data": ...}; вытаскиваем его.
|
||
return unwrapData(raw)
|
||
}
|
||
|
||
// unwrapData достаёт поле "data" из ответа {"data": ...}. Если его нет —
|
||
// возвращаем null (напр. "undefined" у части POST).
|
||
func unwrapData(raw []byte) (json.RawMessage, error) {
|
||
if len(raw) == 0 {
|
||
return json.RawMessage("null"), nil
|
||
}
|
||
var wrapper struct {
|
||
Data json.RawMessage `json:"data"`
|
||
}
|
||
if err := json.Unmarshal(raw, &wrapper); err != nil {
|
||
return json.RawMessage("null"), nil
|
||
}
|
||
if wrapper.Data == nil {
|
||
return json.RawMessage("null"), nil
|
||
}
|
||
return wrapper.Data, nil
|
||
}
|
||
|
||
// apiErrorMessage извлекает человекочитаемое сообщение из тела ошибки PVE.
|
||
func apiErrorMessage(raw []byte, status string) string {
|
||
var e struct {
|
||
Errors map[string]string `json:"errors"`
|
||
}
|
||
if err := json.Unmarshal(raw, &e); err == nil && len(e.Errors) > 0 {
|
||
var parts []string
|
||
for k, v := range e.Errors {
|
||
parts = append(parts, k+": "+v)
|
||
}
|
||
return strings.Join(parts, "; ")
|
||
}
|
||
s := strings.TrimSpace(string(raw))
|
||
if s == "" || s == "null" {
|
||
return status
|
||
}
|
||
return s
|
||
}
|
||
|
||
// retryable сообщает, стоит ли повторять запрос. Доменные 429/502/503/504 —
|
||
// да; отмену/deadline — нет (уважаем ctx).
|
||
func retryable(err error) bool {
|
||
var ae *APIError
|
||
if errors.As(err, &ae) {
|
||
return ae.Status == http.StatusTooManyRequests || ae.Status == 502 || ae.Status == 503 || ae.Status == 504
|
||
}
|
||
return !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded)
|
||
}
|
||
|
||
// backoff — простой джиттер-бэкфол (0.5s, 1s).
|
||
func backoff(attempt int) time.Duration {
|
||
return time.Duration(500*(1<<attempt)) * time.Millisecond
|
||
}
|
||
|
||
// sleep с уважением к ctx.
|
||
func sleep(ctx context.Context, d time.Duration) bool {
|
||
select {
|
||
case <-time.After(d):
|
||
return true
|
||
case <-ctx.Done():
|
||
return false
|
||
}
|
||
}
|