Files
forge-tools-proxmox/internal/pve/client.go
T

231 lines
7.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
}