Initial commit: forge-tools-proxmox — MCP-сервер для Proxmox VE
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user