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<