Initial commit: forge-tools-proxmox — MCP-сервер для Proxmox VE

This commit is contained in:
Maksim Totmin
2026-10-01 10:41:43 +07:00
commit a7addaead9
30 changed files with 4226 additions and 0 deletions
+230
View File
@@ -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
}
}