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
+354
View File
@@ -0,0 +1,354 @@
package pve
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/url"
"time"
)
// api.go — типизированные обёртки над Proxmox VE REST (api2/json).
// Чтения возвращают raw-тело "data"; мутации — UPID (асинхронная задача),
// чтобы хендлер вернул идентификатор, а завершение опрашивали через
// task_status(wait) — так мы не блокируем единственный loop (§9.6).
//
// Путь идёт через joinURL (PathEscape), а значения идентификаторов перед
// этим ещё валидируются guard'ом в tools-слое — двойная защита.
// Version — версия PVE в кластере.
func (t *Tenant) Version(ctx context.Context, alias string) (json.RawMessage, error) {
return t.GET(ctx, alias, "/version")
}
// ClusterStatus — состояние кластера (quorum, узлы, версии).
func (t *Tenant) ClusterStatus(ctx context.Context, alias string) (json.RawMessage, error) {
return t.GET(ctx, alias, "/cluster/status")
}
// ClusterResources — ресурсы кластера по PVE-типу (vm/storage/node/sdn).
func (t *Tenant) ClusterResources(ctx context.Context, alias, rtype string) (json.RawMessage, error) {
path := "/cluster/resources"
if rtype != "" {
path += "?type=" + url.QueryEscape(rtype)
}
return t.GET(ctx, alias, path)
}
// GuestResources — VM/CT по типу (qemu|lxc): тянет type=vm и фильтрует по
// полю "type" каждой записи (PVE не принимает type=qemu напрямую).
func (t *Tenant) GuestResources(ctx context.Context, alias, kind string) (json.RawMessage, error) {
raw, err := t.GET(ctx, alias, "/cluster/resources?type=vm")
if err != nil {
return nil, err
}
var arr []map[string]any
if err := json.Unmarshal(raw, &arr); err != nil {
return raw, nil
}
out := make([]map[string]any, 0, len(arr))
for _, it := range arr {
if it["type"] == kind {
out = append(out, it)
}
}
return json.Marshal(out)
}
// Nodes — список нод кластера.
func (t *Tenant) Nodes(ctx context.Context, alias string) (json.RawMessage, error) {
return t.GET(ctx, alias, "/nodes")
}
// NodeStatus — детальное состояние ноды.
func (t *Tenant) NodeStatus(ctx context.Context, alias, node string) (json.RawMessage, error) {
return t.GET(ctx, alias, joinURL("/nodes", node, "/status"))
}
// NodeNetwork — сетевые интерфейсы ноды (мосты/eth).
func (t *Tenant) NodeNetwork(ctx context.Context, alias, node string) (json.RawMessage, error) {
return t.GET(ctx, alias, joinURL("/nodes", node, "/network"))
}
// VMConfig — конфиг QEMU-ВМ.
func (t *Tenant) VMConfig(ctx context.Context, alias, node string, vmid int) (json.RawMessage, error) {
return t.GET(ctx, alias, joinURL("/nodes", node, "/qemu", itoa(vmid), "/config"))
}
// VMStatus — текущий статус QEMU-ВМ.
func (t *Tenant) VMStatus(ctx context.Context, alias, node string, vmid int) (json.RawMessage, error) {
return t.GET(ctx, alias, joinURL("/nodes", node, "/qemu", itoa(vmid), "/status/current"))
}
// ContainerConfig — конфиг LXC.
func (t *Tenant) ContainerConfig(ctx context.Context, alias, node string, vmid int) (json.RawMessage, error) {
return t.GET(ctx, alias, joinURL("/nodes", node, "/lxc", itoa(vmid), "/config"))
}
// ContainerStatus — текущий статус LXC.
func (t *Tenant) ContainerStatus(ctx context.Context, alias, node string, vmid int) (json.RawMessage, error) {
return t.GET(ctx, alias, joinURL("/nodes", node, "/lxc", itoa(vmid), "/status/current"))
}
// StorageList — хранилища кластера.
func (t *Tenant) StorageList(ctx context.Context, alias string) (json.RawMessage, error) {
return t.GET(ctx, alias, "/storage")
}
// NodeStorage — хранилища конкретной ноды.
func (t *Tenant) NodeStorage(ctx context.Context, alias, node string) (json.RawMessage, error) {
return t.GET(ctx, alias, joinURL("/nodes", node, "/storage"))
}
// StorageContent — содержимое хранилища (iso/vztmpl/backup/images).
func (t *Tenant) StorageContent(ctx context.Context, alias, node, storage, contentType string) (json.RawMessage, error) {
path := joinURL("/nodes", node, "/storage", storage, "/content")
if contentType != "" {
path += "?content=" + url.QueryEscape(contentType)
}
return t.GET(ctx, alias, path)
}
// SnapshotList — снапшоты VM/CT.
func (t *Tenant) SnapshotList(ctx context.Context, alias, node, vmtype string, vmid int) (json.RawMessage, error) {
return t.GET(ctx, alias, joinURL("/nodes", node, vmtype, itoa(vmid), "/snapshot"))
}
// BackupList — задачи vzdump (по ноде/всему кластеру).
func (t *Tenant) BackupList(ctx context.Context, alias, node string) (json.RawMessage, error) {
if node != "" {
return t.GET(ctx, alias, joinURL("/nodes", node, "/tasks")+"?typefilter=vzdump")
}
return t.GET(ctx, alias, "/cluster/tasks?typefilter=vzdump")
}
// TasksList — последние задачи кластера.
func (t *Tenant) TasksList(ctx context.Context, alias, node string, limit int) (json.RawMessage, error) {
if node != "" {
return t.GET(ctx, alias, joinURL("/nodes", node, "/tasks")+"?limit="+itoa(limit))
}
return t.GET(ctx, alias, "/cluster/tasks?limit="+itoa(limit))
}
// TaskStatus — статус задачи по UPID.
func (t *Tenant) TaskStatus(ctx context.Context, alias, node, upid string) (json.RawMessage, error) {
return t.GET(ctx, alias, joinURL("/nodes", node, "/tasks", upid, "/status"))
}
// TaskLog — лог задачи по UPID.
func (t *Tenant) TaskLog(ctx context.Context, alias, node, upid string, limit int) (json.RawMessage, error) {
return t.GET(ctx, alias, joinURL("/nodes", node, "/tasks", upid, "/log")+"?limit="+itoa(limit))
}
// GuestIPs — IP-адреса гостя через QEMU guest-agent (только QEMU, agent:1).
func (t *Tenant) GuestIPs(ctx context.Context, alias, node string, vmid int) (json.RawMessage, error) {
return t.GET(ctx, alias, joinURL("/nodes", node, "/qemu", itoa(vmid), "/agent/network-get-interfaces"))
}
// --- Мутации → возвращают UPID или пустую строку (у части POST нет upid) ---
// VMStart / VMStop / VMReboot / VMShutdown / VMSuspend / VMResume — lifecycle.
// graceful может быть 0 (обычный), 1/2/... для shutdown. Здесь передаём action.
func (t *Tenant) VMAction(ctx context.Context, alias, node string, vmid int, action string, form url.Values) (string, error) {
if form == nil {
form = url.Values{}
}
return t.POSTUPID(ctx, alias, joinURL("/nodes", node, "/qemu", itoa(vmid), "/status", action), form)
}
// ContainerAction — lifecycle LXC (start/stop/reboot/shutdown).
func (t *Tenant) ContainerAction(ctx context.Context, alias, node string, vmid int, action string) (string, error) {
return t.POSTUPID(ctx, alias, joinURL("/nodes", node, "/lxc", itoa(vmid), "/status", action), nil)
}
// VMClone — клонирование из template/vmid.
func (t *Tenant) VMClone(ctx context.Context, alias, node string, vmid, newid int, form url.Values) (string, error) {
return t.POSTUPID(ctx, alias, joinURL("/nodes", node, "/qemu", itoa(vmid), "/clone"), form)
}
// ContainerClone — клонирование LXC.
func (t *Tenant) ContainerClone(ctx context.Context, alias, node string, vmid, newid int, form url.Values) (string, error) {
return t.POSTUPID(ctx, alias, joinURL("/nodes", node, "/lxc", itoa(vmid), "/clone"), form)
}
// VMDelete — удаление QEMU-ВМ.
func (t *Tenant) VMDelete(ctx context.Context, alias, node string, vmid int) (string, error) {
return t.DELETEUPID(ctx, alias, joinURL("/nodes", node, "/qemu", itoa(vmid)))
}
// ContainerDelete — удаление LXC.
func (t *Tenant) ContainerDelete(ctx context.Context, alias, node string, vmid int) (string, error) {
return t.DELETEUPID(ctx, alias, joinURL("/nodes", node, "/lxc", itoa(vmid)))
}
// VMConvertTemplate — превращает ВМ в шаблон.
func (t *Tenant) VMConvertTemplate(ctx context.Context, alias, node string, vmid int) (string, error) {
return t.POSTUPID(ctx, alias, joinURL("/nodes", node, "/qemu", itoa(vmid), "/template"), nil)
}
// NextID — следующий свободный VMID в кластере.
func (t *Tenant) NextID(ctx context.Context, alias string) (json.RawMessage, error) {
return t.GET(ctx, alias, "/cluster/nextid")
}
// SnapshotCreate / SnapshotDelete / SnapshotRollback — снапшоты VM/CT.
func (t *Tenant) SnapshotCreate(ctx context.Context, alias, node, vmtype string, vmid int, form url.Values) (string, error) {
return t.POSTUPID(ctx, alias, joinURL("/nodes", node, vmtype, itoa(vmid), "/snapshot"), form)
}
func (t *Tenant) SnapshotDelete(ctx context.Context, alias, node, vmtype string, vmid int, name string) (string, error) {
return t.DELETEUPID(ctx, alias, joinURL("/nodes", node, vmtype, itoa(vmid), "/snapshot", name))
}
func (t *Tenant) SnapshotRollback(ctx context.Context, alias, node, vmtype string, vmid int, name string) (string, error) {
return t.POSTUPID(ctx, alias, joinURL("/nodes", node, vmtype, itoa(vmid), "/snapshot", name, "/rollback"), nil)
}
// BackupCreate — запускает vzdump (async UPID).
func (t *Tenant) BackupCreate(ctx context.Context, alias, node, vmtype string, vmid int, form url.Values) (string, error) {
return t.POSTUPID(ctx, alias, joinURL("/nodes", node, "/vzdump"), withBackupForm(form, vmtype, vmid))
}
// BackupRestore (vzdump restore) реализуется через clone из backup volume;
// в v1 это отдельный инструмент, см. tools/backup.go.
// TaskStop — отменяет/стирает задачу.
func (t *Tenant) TaskStop(ctx context.Context, alias, node, upid string) (string, error) {
return t.DELETEUPID(ctx, alias, joinURL("/nodes", node, "/tasks", upid))
}
// ConfigPost — POST на /config (VM/CT): изменение конфига, включая
// добавление/удаление дисков и сетевых интерфейсов (структурный путь).
func (t *Tenant) ConfigPost(ctx context.Context, alias, node, vmtype string, vmid int, form url.Values) (string, error) {
return t.POSTUPID(ctx, alias, joinURL("/nodes", node, vmtype, itoa(vmid), "/config"), form)
}
// ResizeDisk — изменение размера диска VM/CT (/resize).
func (t *Tenant) ResizeDisk(ctx context.Context, alias, node, vmtype string, vmid int, form url.Values) (string, error) {
return t.POSTUPID(ctx, alias, joinURL("/nodes", node, vmtype, itoa(vmid), "/resize"), form)
}
// MoveDisk — перенос диска (storage-миграция) на другую подсистему хранения.
func (t *Tenant) MoveDisk(ctx context.Context, alias, node string, vmid int, form url.Values) (string, error) {
return t.POSTUPID(ctx, alias, joinURL("/nodes", node, "/qemu", itoa(vmid), "/move_disk"), form)
}
// TaskWait ожидает завершения задачи по UPID (с капом task_poll_max_sec),
// возвращая финальный статус. Poll строго bounded (§9.6) — не блокируем
// loop дольше капа даже для долгой операции.
func (t *Tenant) TaskWait(ctx context.Context, alias, node, upid string) (string, error) {
capSec := t.cfg.TaskPollMaxSec
if capSec <= 0 {
capSec = DefaultTaskPollMaxSec
}
deadline := time.Now().Add(time.Duration(capSec) * time.Second)
for {
status, err := t.TaskStatus(ctx, alias, node, upid)
if err != nil {
return "", err
}
done := taskIsDone(status)
if done {
return pretty(json.RawMessage(status)), nil
}
if time.Now().After(deadline) {
return "", fmt.Errorf("task %s still running after %ds (give timeout)", upid, capSec)
}
if !sleep(ctx, 2*time.Second) {
return "", fmt.Errorf("task wait interrupted: %w", ctx.Err())
}
}
}
// taskIsDone сообщает, завершилась ли задача (по статусам PVE).
func taskIsDone(raw json.RawMessage) bool {
var obj struct {
Status string `json:"status"`
}
if err := json.Unmarshal(raw, &obj); err != nil {
return true
}
switch obj.Status {
case "stopped", "failed", "error": // completed (либо упала)
return true
}
return false
}
// --- низкоуровневый доступ (GET/POST/DELETE) ---
// GET — чтение "data".
func (t *Tenant) GET(ctx context.Context, alias, path string) (json.RawMessage, error) {
c, err := t.Client(ctx, alias)
if err != nil {
return nil, err
}
return c.Get(ctx, path)
}
// POSTUPID — POST и возврат UPID из ответа ("upid" либо null).
func (t *Tenant) POSTUPID(ctx context.Context, alias, path string, form url.Values) (string, error) {
c, err := t.Client(ctx, alias)
if err != nil {
return "", err
}
data, err := c.Post(ctx, path, form)
if err != nil {
return "", err
}
return upidOf(data), nil
}
// DELETEUPID — DELETE и возврат UPID из ответа.
func (t *Tenant) DELETEUPID(ctx context.Context, alias, path string) (string, error) {
c, err := t.Client(ctx, alias)
if err != nil {
return "", err
}
data, err := c.Delete(ctx, path)
if err != nil {
return "", err
}
return upidOf(data), nil
}
// upidOf вытаскивает "upid" из JSON-объекта данных (у части POST его нет).
func upidOf(data json.RawMessage) string {
var obj struct {
UPID string `json:"upid"`
}
if err := json.Unmarshal(data, &obj); err == nil && obj.UPID != "" {
return obj.UPID
}
return ""
}
// withBackupForm добавляет типы/цель в form vzdump.
func withBackupForm(form url.Values, vmtype string, vmid int) url.Values {
if form == nil {
form = url.Values{}
}
if vmtype == "qemu" {
form.Set("vmid", itoa(vmid))
form.Set("mode", "snapshot")
} else {
form.Set("vmid", itoa(vmid))
form.Set("mode", "suspend")
}
return form
}
func itoa(i int) string { return fmt.Sprintf("%d", i) }
// pretty форматирует raw JSON для отдачи модели.
func pretty(raw json.RawMessage) string {
if len(raw) == 0 || string(raw) == "null" {
return "(no data)"
}
var b bytes.Buffer
if err := json.Indent(&b, raw, "", " "); err != nil {
return string(raw)
}
return b.String()
}
+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
}
}
+324
View File
@@ -0,0 +1,324 @@
// Package pve — доменный слой forge-tools-proxmox: чтение per-agent
// конфигурации (configreload / live-reload), тонкий stdio-безопасный
// HTTP-клиент к Proxmox VE API (api2/json), guard-валидация идентификаторов
// и потокобезопасный Manager. Здесь НЕТ MCP-зависимостей (см.
// forge-tools/ARCHITECTURE.md §2, столп разделения домен/инструменты).
package pve
import (
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"git.totmin.ru/en2zmax/forge-toolkit"
)
// Политика доступа по умолчанию (least-privilege): все операции чтения
// разрешены, мутации — только явно (read_only=false + allowlist.vmids).
// Ниже — значения по умолчанию и безопасные границы.
const (
// DefaultTimeoutSec — таймаут одного HTTP-запроса к API (§9.6 bounded).
DefaultTimeoutSec = 15
// DefaultTaskPollMaxSec — максимальное время ожидания завершения task
// (UPID) в одном вызове task_status(wait=true): не блокируем loop дольше капа.
DefaultTaskPollMaxSec = 600
// DefaultMaxOutputBytes — кап размера тела ответа, чтобы не отдавать модели
// гигантские JSON (лимиты больших результатов §9.6).
DefaultMaxOutputBytes = 4 << 20 // 4 MiB
)
// DefaultDenyConfigKeys — поля VM/CT config, запрещённые для изменения через
// vm_config_update / container_config_update. Это структурно-опасные ключи:
// их изменение надо делать выделенными инструментами + confirm, а не через
// общий update (иначе модель может «незаметно» перестроить машину).
var DefaultDenyConfigKeys = []string{
"delete", "revert", "hotplug", "spice",
"hostpci_mapping", "realm", "bootorder",
"sockets", "cores", // меняем явно через отдельные поля, не через update
}
// HostConfig — одно подключение к Proxmox-кластеру (или ноде). URL — полный
// базовый путь API, включая /api2/json. Секреты (token_secret) берутся из
// ${VAR} или gitignored *.local.json — никогда не коммитятся (§8).
//
// AllowNodes/AllowVMIDs — per-host ограничения мутаций. Их авторитетность
// выше глобального Config.Allowlist: в мульти-гипервизорной конфигурации
// права каждого хоста изолированы, а VMID/ноды на разных гипервизорах могут
// пересекаться, поэтому «голый» VMID здесь НЕ идентифицирует ресурс —
// ресурс всегда (host, node, vmid).
type HostConfig struct {
Alias string `json:"alias"`
URL string `json:"url"`
TokenID string `json:"token_id"`
TokenSecret string `json:"token_secret"`
CAFile string `json:"ca_file"`
Insecure bool `json:"insecure"`
// AllowNodes — ноды этого хоста, доступные для мутаций (fail-closed:
// пусто = мутации уровня ноды запрещены).
AllowNodes []string `json:"allow_nodes"`
// AllowVMIDs — VMID/CTID этого хоста, доступные для мутаций (fail-closed:
// пусто = мутации гостей запрещены). Ключевой перенос: права per-host.
AllowVMIDs []int `json:"allow_vmids"`
}
// Allowlist — ограничение ресурсов, доступных для МУТАЦИЙ. Пустой список =
// fail-closed (мутации запрещены), а не «всё разрешено». Это второй слой
// безопасности поверх least-privilege токена (§9.5).
type Allowlist struct {
Nodes []string `json:"nodes"`
VMIDs []int `json:"vmids"`
}
// Config — per-agent политика подключения и лимитов.
type Config struct {
Hosts []HostConfig `json:"hosts"`
Default string `json:"default"`
ReadOnly *bool `json:"read_only"`
Allowlist Allowlist `json:"allowlist"`
TimeoutSec int `json:"timeout_sec"`
TaskPollMaxSec int `json:"task_poll_max_sec"`
MaxOutputBytes int `json:"max_output_bytes"`
denyConfigKeys []string
}
// ParseConfig разбирает байты pve.json (${VAR} + валидация + дефолты).
// Выделена отдельной функцией, чтобы её использовали и configreload.Loader
// (live-reload), и стартовый -config. Fail-closed: без hosts — ошибка.
func ParseConfig(data []byte) (*Config, error) {
// ${VAR} разворачиваем до unmarshal; отсутствующая переменная → пустая
// строка, а валидация ниже отвергнет пустой обязательный secret (не
// подставляем мусор).
expanded := toolkit.Expand(data)
cfg := &Config{}
if err := json.Unmarshal(expanded, cfg); err != nil {
return nil, fmt.Errorf("parse proxmox config: %w", err)
}
if err := cfg.normalize(); err != nil {
return nil, err
}
return cfg, nil
}
// LoadConfig читает и парсит файл конфига по пути.
func LoadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read proxmox config %s: %w", path, err)
}
return ParseConfig(data)
}
// normalize проверяет обязательные поля и применяет дефолты. Fail-closed:
// невалидная политика — ошибка, а не «предположим что-то разумное».
// Мульти-гипервизор: глобальный allowlist запрещён (права обязаны быть
// per-host — иначе VMID пересекутся между кластерами), алиасы/URL уникальны.
func (c *Config) normalize() error {
if len(c.Hosts) == 0 {
return errors.New("proxmox config: at least one host is required")
}
multi := len(c.Hosts) > 1
if multi && (len(c.Allowlist.VMIDs) > 0 || len(c.Allowlist.Nodes) > 0) {
return errors.New("proxmox config: global allowlist is not allowed with multiple hosts — set allow_vmids/allow_nodes per host")
}
seenAlias := map[string]bool{}
seenURL := map[string]bool{}
for i := range c.Hosts {
h := &c.Hosts[i]
if h.Alias == "" {
return fmt.Errorf("proxmox config: host[%d].alias is required", i)
}
if seenAlias[h.Alias] {
return fmt.Errorf("proxmox config: duplicate host alias %q", h.Alias)
}
seenAlias[h.Alias] = true
if !isValidHTTPURL(h.URL) {
return fmt.Errorf("proxmox config: host %q has invalid/unsupported url", h.Alias)
}
if seenURL[h.URL] {
return fmt.Errorf("proxmox config: duplicate host url %q", h.URL)
}
seenURL[h.URL] = true
if h.TokenID == "" || h.TokenSecret == "" {
return fmt.Errorf("proxmox config: host %q requires token_id and token_secret", h.Alias)
}
}
if c.Default == "" {
c.Default = c.Hosts[0].Alias
}
if c.ReadOnly == nil {
t := true
c.ReadOnly = &t
}
if c.TimeoutSec <= 0 {
c.TimeoutSec = DefaultTimeoutSec
}
if c.TaskPollMaxSec <= 0 {
c.TaskPollMaxSec = DefaultTaskPollMaxSec
}
if c.MaxOutputBytes <= 0 {
c.MaxOutputBytes = DefaultMaxOutputBytes
}
if len(c.denyConfigKeys) == 0 {
c.denyConfigKeys = DefaultDenyConfigKeys
}
return nil
}
// HasHosts сообщает, настроено ли хотя бы одно подключение.
func (c *Config) HasHosts() bool { return c != nil && len(c.Hosts) > 0 }
// IsReadOnly сообщает, запрещены ли мутации (дефолт: true).
func (c *Config) IsReadOnly() bool {
if c == nil || c.ReadOnly == nil {
return true
}
return *c.ReadOnly
}
// Host возвращает подключение по алиасу (или default).
func (c *Config) Host(alias string) (HostConfig, bool) {
if c == nil {
return HostConfig{}, false
}
if alias == "" {
alias = c.Default
}
for _, h := range c.Hosts {
if h.Alias == alias {
return h, true
}
}
return HostConfig{}, false
}
// HostExists сообщает, настроен ли хост по алиасу.
func (c *Config) HostExists(alias string) bool {
if c == nil {
return false
}
_, ok := c.Host(alias)
return ok
}
// MultiHost сообщает, настроено ли больше одного гипервизора.
func (c *Config) MultiHost() bool { return c != nil && len(c.Hosts) > 1 }
// WriteAllowed решает, разрешена ли МУТАЦИЯ над VM/CT на конкретном хосте.
// Перенос on per-host allow_vmids; глобальный allowlist — только фолбэк для
// одно-гипервизорной конфигурации (в мульти-конфиге он запрещён). Fail-closed:
// оба пустые → запрещено. Это исключает коллизию VMID между гипервизорами.
func (c *Config) WriteAllowed(alias string, vmid int) bool {
if c == nil || c.IsReadOnly() {
return false
}
h, ok := c.Host(alias)
if !ok {
return false
}
arr := h.AllowVMIDs
if len(arr) == 0 {
arr = c.Allowlist.VMIDs
}
return containsInt(arr, vmid)
}
// NodeWriteAllowed — то же для мутаций уровня ноды, перенос on allow_nodes.
func (c *Config) NodeWriteAllowed(alias, node string) bool {
if c == nil || c.IsReadOnly() {
return false
}
h, ok := c.Host(alias)
if !ok {
return false
}
arr := h.AllowNodes
if len(arr) == 0 {
arr = c.Allowlist.Nodes
}
return containsStr(arr, node)
}
func containsInt(arr []int, v int) bool {
for _, x := range arr {
if x == v {
return true
}
}
return false
}
func containsStr(arr []string, v string) bool {
for _, x := range arr {
if x == v {
return true
}
}
return false
}
// DenyConfigKey сообщает, запрещён ли ключ конфига для update-инструментов.
func (c *Config) DenyConfigKey(key string) bool {
if c == nil {
return false
}
for _, k := range c.denyConfigKeys {
if k == key {
return true
}
}
return false
}
// ValidateIdentifier — экспортированный guard против path-traversal для
// идентификаторов (node, vmid, snapname, storage, upid), попадающих в URL.
func ValidateIdentifier(s string) error { return validatePathToken(s) }
// ValidateUPID — guard для значений UPID (task): они содержат ':' '@' '!',
// поэтому допустимая шире, но строго БЕЗ разделителей пути и подъёма '..'.
func ValidateUPID(s string) error {
if s == "" {
return errors.New("empty task upid")
}
if strings.ContainsAny(s, "/\\\x00") || strings.Contains(s, "..") {
return fmt.Errorf("unsafe upid %q", s)
}
return nil
}
// validatePathToken — guard против path-traversal: идентификаторы (node,
// vmid, snapname, storage, upid), попадающие в URL-путь, обязаны быть из
// безопасного алфавита и не содержать сепараторов/подъёма (анти-инъекция §9).
func validatePathToken(s string) error {
if s == "" {
return errors.New("empty path identifier")
}
if strings.ContainsAny(s, "/\\\x00") || strings.Contains(s, "..") {
return fmt.Errorf("unsafe path identifier %q", s)
}
for _, r := range s {
switch {
case r >= 'a' && r <= 'z':
case r >= 'A' && r <= 'Z':
case r >= '0' && r <= '9':
case r == '.' || r == '_' || r == '-':
default:
return fmt.Errorf("unsafe path identifier %q: invalid char %q", s, r)
}
}
return nil
}
// isValidHTTPURL проверяет схему и наличие хоста (модель не задаёт URL —
// его объявляет оператор; здесь лишь отсекаем явный мусор).
func isValidHTTPURL(raw string) bool {
if !strings.HasPrefix(raw, "http://") && !strings.HasPrefix(raw, "https://") {
return false
}
rest := strings.TrimPrefix(strings.TrimPrefix(raw, "https://"), "http://")
// хост обязан быть, но может содержать порт; путь — /api2/json или глубже.
return rest != "" && rest != "/" && !strings.HasPrefix(rest, "/")
}
+209
View File
@@ -0,0 +1,209 @@
package pve
import (
"testing"
)
// include: unit-тесты политики/конфига домена (без сети и без MCP).
func TestParseConfigDefaults(t *testing.T) {
raw := []byte(`{
"hosts": [{"alias":"pve","url":"https://10.0.0.5:8006/api2/json","token_id":"u@pve!mcp","token_secret":"S"}]
}`)
cfg, err := ParseConfig(raw)
if err != nil {
t.Fatalf("ParseConfig: %v", err)
}
if !cfg.IsReadOnly() {
t.Error("default ReadOnly should be true")
}
if cfg.Default != "pve" {
t.Errorf("default host = %q, want pve", cfg.Default)
}
if !cfg.HostExists("pve") {
t.Error("pve should exist")
}
if cfg.MultiHost() {
t.Error("single host should not be multi")
}
}
func TestParseConfigNoHostsFails(t *testing.T) {
if _, err := ParseConfig([]byte(`{}`)); err == nil {
t.Fatal("expected error for config without hosts")
}
}
func TestParseConfigMissingTokenFails(t *testing.T) {
raw := []byte(`{
"hosts":[{"alias":"pve","url":"https://10.0.0.5:8006/api2/json"}]
}`)
if _, err := ParseConfig(raw); err == nil {
t.Fatal("expected fail-closed on missing token")
}
}
func TestParseConfigUnexpandedVarFails(t *testing.T) {
// ${VAR} отсутствует в окружении => token_secret пустой => fail-closed.
raw := []byte(`{
"hosts":[{"alias":"pve","url":"https://10.0.0.5:8006/api2/json","token_id":"m","token_secret":"${DEFINITELY_MISSING_VAR}"}]
}`)
if _, err := ParseConfig(raw); err == nil {
t.Fatal("expected fail-closed on unexpanded secret var")
}
}
func TestParseConfigDuplicateAliasFails(t *testing.T) {
raw := []byte(`{
"hosts":[
{"alias":"pve","url":"https://10.0.0.1:8006/api2/json","token_id":"a","token_secret":"s"},
{"alias":"pve","url":"https://10.0.0.2:8006/api2/json","token_id":"b","token_secret":"t"}
]}`)
if _, err := ParseConfig(raw); err == nil {
t.Fatal("expected fail on duplicate alias")
}
}
func TestParseConfigDuplicateURLFails(t *testing.T) {
raw := []byte(`{
"hosts":[
{"alias":"a","url":"https://10.0.0.1:8006/api2/json","token_id":"a","token_secret":"s"},
{"alias":"b","url":"https://10.0.0.1:8006/api2/json","token_id":"b","token_secret":"t"}
]}`)
if _, err := ParseConfig(raw); err == nil {
t.Fatal("expected fail on duplicate url")
}
}
func TestParseConfigMultiHostGlobalAllowlistFails(t *testing.T) {
// Мульти-гипервизор + глобальный allowlist = коллизия VMID → fail-closed.
raw := []byte(`{
"hosts":[
{"alias":"a","url":"https://10.0.0.1:8006/api2/json","token_id":"a","token_secret":"s"},
{"alias":"b","url":"https://10.0.0.2:8006/api2/json","token_id":"b","token_secret":"t"}
],
"allowlist":{"vmids":[100]}}`)
if _, err := ParseConfig(raw); err == nil {
t.Fatal("expected fail on global allowlist in multi-host config")
}
}
// Ключевой тест коллизии VMID: один и тот же vmid на разных хостах
// обязан давать РАЗНЫЙ вердикт по авторизации.
func TestWriteAllowed_NoCrossHostLeak(t *testing.T) {
cfg := &Config{
Hosts: []HostConfig{
{Alias: "a", URL: "https://10.0.0.1:8006/api2/json", TokenID: "u", TokenSecret: "s", AllowVMIDs: []int{500}},
{Alias: "b", URL: "https://10.0.0.2:8006/api2/json", TokenID: "u", TokenSecret: "s", AllowVMIDs: []int{200}},
},
ReadOnly: boolPtr(false),
}
if !cfg.WriteAllowed("a", 500) {
t.Error("host a vmid 500 should be allowed")
}
if cfg.WriteAllowed("b", 500) {
t.Error("host b vmid 500 must be DENIED (not in its allowlist) — no cross-host leak")
}
if !cfg.WriteAllowed("b", 200) {
t.Error("host b vmid 200 should be allowed")
}
}
func TestWriteAllowedFailClosedEmpty(t *testing.T) {
cfg := &Config{Hosts: []HostConfig{{Alias: "a", URL: "https://10.0.0.1:8006/api2/json", TokenID: "u", TokenSecret: "s"}}, ReadOnly: boolPtr(false)}
if cfg.WriteAllowed("a", 500) {
t.Error("empty allow_vmids must fail-closed (deny writes)")
}
}
func TestWriteAllowedReadOnlyDenies(t *testing.T) {
cfg := &Config{Hosts: []HostConfig{{Alias: "a", URL: "https://10.0.0.1:8006/api2/json", TokenID: "u", TokenSecret: "s", AllowVMIDs: []int{500}}}, ReadOnly: boolPtr(true)}
if cfg.WriteAllowed("a", 500) {
t.Error("read_only must deny writes even if allowlist set")
}
}
func TestWriteAllowedGlobalFallbackSingleHost(t *testing.T) {
// Одиночный гипервизор: глобальный allowlist — допустимый фолбэк.
cfg := &Config{
Hosts: []HostConfig{{Alias: "a", URL: "https://10.0.0.1:8006/api2/json", TokenID: "u", TokenSecret: "s"}},
Allowlist: Allowlist{VMIDs: []int{500}},
ReadOnly: boolPtr(false),
}
if !cfg.WriteAllowed("a", 500) {
t.Error("global allowlist should fall back for single host")
}
if cfg.WriteAllowed("a", 100) {
t.Error("vmid 100 should be denied")
}
}
func TestNodeWriteAllowed(t *testing.T) {
cfg := &Config{
Hosts: []HostConfig{{Alias: "a", URL: "https://10.0.0.1:8006/api2/json", TokenID: "u", TokenSecret: "s", AllowNodes: []string{"pve"}}},
ReadOnly: boolPtr(false),
}
if !cfg.NodeWriteAllowed("a", "pve") {
t.Error("node pve should be allowed")
}
if cfg.NodeWriteAllowed("a", "other") {
t.Error("node other should be denied")
}
}
func TestDenyConfigKeys(t *testing.T) {
cfg := &Config{denyConfigKeys: DefaultDenyConfigKeys}
for _, k := range []string{"delete", "revert", "hotplug"} {
if !cfg.DenyConfigKey(k) {
t.Errorf("key %q should be denied", k)
}
}
if cfg.DenyConfigKey("name") {
t.Error("name should NOT be denied")
}
}
func TestIsValidHTTPURL(t *testing.T) {
valid := []string{"https://pve.local:8006/api2/json", "http://10.0.0.1:8006/api2/json"}
for _, u := range valid {
if !isValidHTTPURL(u) {
t.Errorf("expected valid: %s", u)
}
}
invalid := []string{"", "ftp://x", "https://", "javascript:alert(1)"}
for _, u := range invalid {
if isValidHTTPURL(u) {
t.Errorf("expected invalid: %s", u)
}
}
}
func TestValidateIdentifier(t *testing.T) {
ok := []string{"pve", "pve1", "snap-name", "104", "local-lvm", "scsi0"}
for _, s := range ok {
if err := ValidateIdentifier(s); err != nil {
t.Errorf("expected valid %q: %v", s, err)
}
}
bad := []string{"", "a/b", "..", "a b", "a;rm", "a\\b", "a\x00b", "a:b", "a@b"}
for _, s := range bad {
if err := ValidateIdentifier(s); err == nil {
t.Errorf("expected invalid %q", s)
}
}
}
func TestValidateUPID(t *testing.T) {
ok := "UPID:pve:00000000:root@pam!mcp:1:2:3:qemu:100:abc"
if err := ValidateUPID(ok); err != nil {
t.Errorf("expected valid upid %q: %v", ok, err)
}
bad := []string{"", "a/b", "..", "a\\b", "a\x00b"}
for _, s := range bad {
if err := ValidateUPID(s); err == nil {
t.Errorf("expected invalid upid %q", s)
}
}
}
func boolPtr(b bool) *bool { return &b }
+154
View File
@@ -0,0 +1,154 @@
package pve
import (
"context"
"errors"
"fmt"
"net/url"
"sync"
"git.totmin.ru/en2zmax/forge-toolkit/configreload"
)
// Manager — одиночный, ПОТОКО-БЕЗОПАСНЫЙ диспетчер процесса (столп 2 §2):
// агенты запускаются в отдельных goroutine и конкурентно дёргают один
// Manager. В pooled-режиме (isolation=pooled) ядро на каждый вызов
// инжектит серверный аргумент _tenant_config = <agentDir>/forge-tools/
// proxmox.json — поэтому менеджер кэширует per-путь загрузчики конфига
// (configreload, live-reload по контент-хэшу) и per-путь Tenant'ы (свои
// клиенты/секреты). Тенанты НЕ делят клиентов между агентами.
type Manager struct {
mu sync.Mutex
// configPath — статический -config (легаси-одиночный режим). Пусто =
// pooled: конфиг приходит на каждый вызов через _tenant_config.
configPath string
// loaders кэширует configreload.Loader[*Config] по пути конфига.
loaders map[string]*configreload.Loader[*Config]
// tenants кэширует Tenant (свои клиенты) по ключу-пути конфига.
tenants map[string]*Tenant
}
// NewManager создаёт менеджер. configPath — опциональный постоянный конфиг
// (-config); пусто = pooled (тенант из _tenant_config на каждый вызов).
func NewManager(configPath string) *Manager {
return &Manager{
configPath: configPath,
loaders: make(map[string]*configreload.Loader[*Config]),
tenants: make(map[string]*Tenant),
}
}
// Tenant возвращает per-agent тенант. path — _tenant_config (пусто при
// -config-режиме). Fail-closed: нет конфига — ошибка (модуль отказывает,
// а не работает «с общими» кредами).
func (m *Manager) Tenant(ctx context.Context, path string) (*Tenant, error) {
cfgPath := m.resolvePath(path)
if cfgPath == "" {
return nil, errors.New("proxmox: no config (set -config or provide _tenant_config)")
}
loader := m.loaderFor(cfgPath)
cfg, err := loader.Get()
if err != nil {
// ErrNotFound / parse-error без last-good — fail-closed, а не фолбэк.
if cfg == nil || !cfg.HasHosts() {
return nil, fmt.Errorf("proxmox: tenant config %s: %w", cfgPath, err)
}
// Есть last-good — работаем со старым (правка была битой), но
// сигналим, чтобы не молчать.
// (здесь последний рабочий конфиг уже возвращён в cfg)
}
m.mu.Lock()
defer m.mu.Unlock()
if t, ok := m.tenants[cfgPath]; ok {
return t, nil
}
t := &Tenant{cfg: cfg, clients: make(map[string]*Client)}
m.tenants[cfgPath] = t
return t, nil
}
// resolvePath выбирает путь конфига: _tenant_config в приоритете, иначе
// статический -config.
func (m *Manager) resolvePath(tenantPath string) string {
if tenantPath != "" {
return tenantPath
}
return m.configPath
}
// loaderFor возвращает (и кэширует) загрузчик конфига по пути.
func (m *Manager) loaderFor(path string) *configreload.Loader[*Config] {
m.mu.Lock()
defer m.mu.Unlock()
if l, ok := m.loaders[path]; ok {
return l
}
l := configreload.New(path, ParseConfig)
m.loaders[path] = l
return l
}
// Close закрывает все тенанты (и их клиенты).
func (m *Manager) Close() {
m.mu.Lock()
defer m.mu.Unlock()
for _, t := range m.tenants {
t.closeLocked()
}
m.tenants = nil
}
// Tenant — per-agent конфигурация + свои HTTP-клиенты к хостам. Секреты и
// разрешения (allowlist) — строго в рамках одного тенанта.
type Tenant struct {
cfg *Config
mu sync.Mutex
clients map[string]*Client // alias -> client
}
// Config возвращает политику тенанта.
func (t *Tenant) Config() *Config { return t.cfg }
// Client возвращает HTTP-клиент для хоста по алиасу (или default).
// Строится лениво и кэшируется; потокобезопасно.
func (t *Tenant) Client(ctx context.Context, alias string) (*Client, error) {
host, ok := t.cfg.Host(alias)
if !ok {
return nil, fmt.Errorf("proxmox: host %q not configured", alias)
}
t.mu.Lock()
defer t.mu.Unlock()
if c, ok := t.clients[host.Alias]; ok {
return c, nil
}
c, err := NewClient(host)
if err != nil {
return nil, err
}
t.clients[host.Alias] = c
return c, nil
}
// Close закрывает клиенты тенанта. Не используй вне RWMutex Manager.
func (t *Tenant) Close() {
t.mu.Lock()
defer t.mu.Unlock()
t.closeLocked()
}
func (t *Tenant) closeLocked() {
// net/http.Client не имеет Close; здесь точка для будущего пула/окружения.
t.clients = nil
}
// joinURL собирает корректный путь API (PathEscape против path-traversal).
func joinURL(path string, ids ...string) string {
p := path
for _, id := range ids {
p += "/" + url.PathEscape(id)
}
return p
}
+90
View File
@@ -0,0 +1,90 @@
package tools
import (
"context"
"fmt"
"net/url"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// backup.go — бэкапы (vzdump): list (read) и create (async UPID).
// В v1 НЕ реализованы restore/delete/prune: это тяжёлые необратимые
// операции, требующие выверенной семантики; их лучше сделать отдельным
// модулем-уточнением, чем рисковать в базовом. (см. README «Roadmap».)
func registerBackupTools(s *mcp.Server) {
s.AddTool(&mcp.Tool{
Name: "backup_list",
Description: "List recent backup (vzdump) tasks. Read-only.",
InputSchema: schema(map[string]any{
"node": strProps("Node name (empty = whole cluster)", false),
"host": strProps("Cluster alias (default: primary)", false),
}, nil),
}, backupListHandler)
registerPatternTool(s, &mcp.Tool{
Name: "backup_create",
Description: "Create a backup (vzdump) of a VM/CT. Async — returns UPID; poll via task_status(wait). Requires confirm + write permission.",
InputSchema: schema(map[string]any{
"node": strProps("Node name", true),
"vmid": strProps("VM/CT ID", true),
"storage": strProps("Backup storage (default: node local)", false),
"mode": strProps("Backup mode", true, "snapshot", "suspend", "stop"),
"compress": strProps("Compression", false, "zstd", "gzip", "lzo", "none"),
"kind": strProps("Guest type", false, "qemu", "lxc"),
"confirm": strProps("Set to \"true\" to confirm", true, "true"),
}, []string{"node", "vmid", "confirm"}),
}, vmPatterns, backupCreateHandler)
}
func backupListHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
t, err := tenantFor(ctx, req)
if err != nil {
return errorResult(err.Error()), nil
}
args := requestArgs(req)
host, err := resolveHost(t, args)
if err != nil {
return errorResult(err.Error()), nil
}
cctx, cancel := timeout(ctx, t)
defer cancel()
data, err := t.BackupList(cctx, host, getString(args, "node", ""))
if err != nil {
return errorResult(err.Error()), nil
}
return textResult(pretty(data)), nil
}
func backupCreateHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
t, err := tenantFor(ctx, req)
if err != nil {
return errorResult(err.Error()), nil
}
args := requestArgs(req)
node, vmid, host, r := resolveVMArgs(t, args)
if r != nil {
return r, nil
}
if err := confirm(args, "backup_create"); err != nil {
return errorResult(err.Error()), nil
}
if err := gateVMWrite(t, host, vmid); err != nil {
return errorResult(err.Error()), nil
}
kind := guestKind(args)
// mode: snapshot/suspend/stop. Если задан storage/compress — кладём в form.
form := url.Values{"mode": {getString(args, "mode", "snapshot")}}
if s := getString(args, "storage", ""); s != "" {
form.Set("storage", s)
}
if c := getString(args, "compress", ""); c != "" && c != "none" {
form.Set("compress", c)
}
upid, err := t.BackupCreate(ctx, host, node, kind, vmid, form)
if err != nil {
return errorResult(err.Error()), nil
}
return textResult(upidMsg("backup_create", fmt.Sprintf("%s/%d", node, vmid), upid)), nil
}
+161
View File
@@ -0,0 +1,161 @@
package tools
import (
"context"
"fmt"
"strings"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// cluster.go — read-only инструменты кластера/нод (наблюдение, без мутаций).
// Здесь НЕТ node_reboot/shutdown: в однонодовом homelab рестарт хоста
// обрушил бы всё окружение (blast-radius при нулевой ценности).
func registerClusterTools(s *mcp.Server) {
// clusters_list — перечень настроенных кластеров (операторских) + версия.
s.AddTool(&mcp.Tool{
Name: "clusters_list",
Description: "List configured Proxmox clusters (host aliases) and their PVE version. Read-only.",
InputSchema: schema(nil, nil),
}, clustersListHandler)
// cluster_status — состояние кластера (quorum, узлы, версии).
s.AddTool(&mcp.Tool{
Name: "cluster_status",
Description: "Get cluster health: quorum, node list, versions. Read-only.",
InputSchema: schema(map[string]any{
"host": strProps("Cluster alias (default: primary)", false),
}, nil),
}, clusterStatusHandler)
// nodes_list — список нод кластера.
s.AddTool(&mcp.Tool{
Name: "nodes_list",
Description: "List all nodes in the cluster with status and resource usage. Read-only.",
InputSchema: schema(map[string]any{
"host": strProps("Cluster alias (default: primary)", false),
}, nil),
}, nodesListHandler)
// node_status — детальное состояние ноды.
s.AddTool(&mcp.Tool{
Name: "node_status",
Description: "Get detailed status (CPU/memory/disk/uptime) of one node. Read-only.",
InputSchema: schema(map[string]any{
"node": strProps("Node name", true),
"host": strProps("Cluster alias (default: primary)", false),
}, []string{"node"}),
}, nodeStatusHandler)
// node_network — сетевые интерфейсы ноды (мосты/eth).
s.AddTool(&mcp.Tool{
Name: "node_network",
Description: "List network interfaces and bridges of a node. Read-only.",
InputSchema: schema(map[string]any{
"node": strProps("Node name", true),
"host": strProps("Cluster alias (default: primary)", false),
}, []string{"node"}),
}, nodeNetworkHandler)
}
func clustersListHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
t, err := tenantFor(ctx, req)
if err != nil {
return errorResult(err.Error()), nil
}
var aliases []string
for _, h := range t.Config().Hosts {
aliases = append(aliases, h.Alias)
}
cctx, cancel := timeout(ctx, t)
defer cancel()
ver, err := t.Version(cctx, "")
if err != nil {
return textResult(fmt.Sprintf("Clusters configured: %s\nPVE version: unavailable (%s)", strings.Join(aliases, ", "), err)), nil
}
return textResult(fmt.Sprintf("Clusters configured: %s\nPVE version: %s",
strings.Join(aliases, ", "), pretty(ver))), nil
}
func clusterStatusHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
t, err := tenantFor(ctx, req)
if err != nil {
return errorResult(err.Error()), nil
}
host, err := resolveHost(t, requestArgs(req))
if err != nil {
return errorResult(err.Error()), nil
}
cctx, cancel := timeout(ctx, t)
defer cancel()
data, err := t.ClusterStatus(cctx, host)
if err != nil {
return errorResult(err.Error()), nil
}
return textResult(pretty(data)), nil
}
func nodesListHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
t, err := tenantFor(ctx, req)
if err != nil {
return errorResult(err.Error()), nil
}
host, err := resolveHost(t, requestArgs(req))
if err != nil {
return errorResult(err.Error()), nil
}
cctx, cancel := timeout(ctx, t)
defer cancel()
data, err := t.Nodes(cctx, host)
if err != nil {
return errorResult(err.Error()), nil
}
return textResult(pretty(data)), nil
}
func nodeStatusHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
t, err := tenantFor(ctx, req)
if err != nil {
return errorResult(err.Error()), nil
}
args := requestArgs(req)
node, err := requireNode(args)
if err != nil {
return errorResult(err.Error()), nil
}
host, err := resolveHost(t, args)
if err != nil {
return errorResult(err.Error()), nil
}
cctx, cancel := timeout(ctx, t)
defer cancel()
data, err := t.NodeStatus(cctx, host, node)
if err != nil {
return errorResult(err.Error()), nil
}
return textResult(pretty(data)), nil
}
func nodeNetworkHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
t, err := tenantFor(ctx, req)
if err != nil {
return errorResult(err.Error()), nil
}
args := requestArgs(req)
node, err := requireNode(args)
if err != nil {
return errorResult(err.Error()), nil
}
host, err := resolveHost(t, args)
if err != nil {
return errorResult(err.Error()), nil
}
cctx, cancel := timeout(ctx, t)
defer cancel()
data, err := t.NodeNetwork(cctx, host, node)
if err != nil {
return errorResult(err.Error()), nil
}
return textResult(pretty(data)), nil
}
+44
View File
@@ -0,0 +1,44 @@
package tools
import (
"context"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// guest.go — взаимодействие с гостем через QEMU guest-agent.
// Сознательно ограничено: только guest_ips (read-only обнаружение IP через
// agent). Исполнение команд ВНУТРИ гостя (guest_exec) НЕ реализуем —
// это зона ssh__run (remote exec), а не гипервизорного домена
// (анти-дубликат). QEMU guest-agent работает только для QEMU-ВМ с agent=1.
func registerGuestTools(s *mcp.Server) {
s.AddTool(&mcp.Tool{
Name: "guest_ips",
Description: "Get guest IP addresses via QEMU guest-agent (requires agent=1 and running VM). Read-only.",
InputSchema: schema(map[string]any{
"node": strProps("Node name", true),
"vmid": strProps("VM ID", true),
"host": strProps("Cluster alias (default: primary)", false),
}, []string{"node", "vmid"}),
}, guestIPsHandler)
}
func guestIPsHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
t, err := tenantFor(ctx, req)
if err != nil {
return errorResult(err.Error()), nil
}
args := requestArgs(req)
node, vmid, host, r := resolveVMArgs(t, args)
if r != nil {
return r, nil
}
cctx, cancel := timeout(ctx, t)
defer cancel()
data, err := t.GuestIPs(cctx, host, node, vmid)
if err != nil {
return errorResult(err.Error()), nil
}
return textResult(pretty(data)), nil
}
+168
View File
@@ -0,0 +1,168 @@
package tools
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"strconv"
"time"
"forge-tools-proxmox/internal/pve"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// helpers.go — общие помощники инструментов: доступ к тенанту, валидация
// идентификаторов (guard), гейты записи (read_only + allowlist + confirm),
// таймауты и форматирование JSON. Никакой бизнес-логики — только трансляция
// запрос/ответ (см. ARCHITECTURE.md §4 «золотое правило разделения»).
var mgr *pve.Manager
// SetManager связывает одиночный Manager процесса (forge: 1 подпроцесс).
func SetManager(m *pve.Manager) { mgr = m }
// manager возвращает одиночный Manager.
func manager() *pve.Manager { return mgr }
// tenantFor резолвит per-agent тенант из серверного аргумента _tenant_config
// (инжектится ядром в pooled-режиме). Пусто — статический -config. Ошибка
// загрузки конфига — доменная (errorResult), а не падение процесса.
func tenantFor(ctx context.Context, req *mcp.CallToolRequest) (*pve.Tenant, error) {
m := manager()
if m == nil {
return nil, errors.New("proxmox: no manager initialized")
}
path := getString(requestArgs(req), "_tenant_config", "")
return m.Tenant(ctx, path)
}
// timeout контекст по конфигу тенанта (bounded §9.6).
func timeout(ctx context.Context, t *pve.Tenant) (context.Context, context.CancelFunc) {
sec := t.Config().TimeoutSec
if sec <= 0 {
sec = pve.DefaultTimeoutSec
}
return context.WithTimeout(ctx, time.Duration(sec)*time.Second)
}
// requireNode возвращает node с guard-валидацией идентификатора.
func requireNode(args map[string]any) (string, error) {
node, err := requireString(args, "node")
if err != nil {
return "", err
}
if err := pve.ValidateIdentifier(node); err != nil {
return "", err
}
return node, nil
}
// requireVMID возвращает vmid (int или token-string) с валидацией.
func requireVMID(args map[string]any) (int, error) {
raw := getString(args, "vmid", "")
if raw == "" {
return 0, errors.New("missing required argument 'vmid'")
}
if err := pve.ValidateIdentifier(raw); err != nil {
return 0, err
}
v, err := strconv.Atoi(raw)
if err != nil || v <= 0 {
return 0, fmt.Errorf("argument 'vmid' must be a positive integer, got %q", raw)
}
return v, nil
}
// requireName возвращает имя (snapshot/storage) с guard-валидацией.
func requireName(args map[string]any, key string) (string, error) {
name, err := requireString(args, key)
if err != nil {
return "", err
}
if err := pve.ValidateIdentifier(name); err != nil {
return "", err
}
return name, nil
}
// requireUPID возвращает UPID с отдельным guard (допускает ':' '@' '!').
func requireUPID(args map[string]any) (string, error) {
upid, err := requireString(args, "upid")
if err != nil {
return "", err
}
if err := pve.ValidateUPID(upid); err != nil {
return "", err
}
return upid, nil
}
// resolveHost выбирает целевой гипервизор (алиас). Правила:
// - host задан → валидируем, что он настроен (иначе fail-closed);
// - не задан и хостов больше одного → отказ (неоднозначность: VMID/ноды на
// разных гипервизорах могут пересекаться, поэтому цель обязана быть явной);
// - не задан и хост один → пустая строка (= default, резолвится в Client).
func resolveHost(t *pve.Tenant, args map[string]any) (string, error) {
h := getString(args, "host", "")
if h != "" {
if !t.Config().HostExists(h) {
return "", fmt.Errorf("host %q not configured", h)
}
return h, nil
}
if t.Config().MultiHost() {
return "", errors.New("specify 'host' — multiple hypervisors configured")
}
return "", nil
}
// confirm проверяет явный флаг "confirm": "true" для деструктивных операций.
// Без него — отказ ДО обращения к API (безопасность §9).
func confirm(args map[string]any, what string) error {
if getString(args, "confirm", "") != "true" {
return fmt.Errorf("%s requires confirm=\"true\" argument (destructive)", what)
}
return nil
}
// gateVMWrite применяет политику записи к VM/CT на конкретный гипервизор:
// fail-closed по read_only и per-host allowlist.vmids (или глобальному
// фолбэку для одиночного гипервизора). Это второй слой поверх
// least-privilege токена (§9.5), и он исключает коллизию VMID между хостами.
func gateVMWrite(t *pve.Tenant, host string, vmid int) error {
if !t.Config().WriteAllowed(host, vmid) {
return fmt.Errorf("proxmox: write to vmid %d on host %q not allowed (read_only or allowlist.vmids)", vmid, host)
}
return nil
}
// gateNodeWrite — то же для мутаций уровня ноды на конкретный гипервизор.
func gateNodeWrite(t *pve.Tenant, host, node string) error {
if !t.Config().NodeWriteAllowed(host, node) {
return fmt.Errorf("proxmox: write to node %q on host %q not allowed (read_only or allowlist.nodes)", node, host)
}
return nil
}
// pretty форматирует raw JSON для отдачи модели (читабельно).
func pretty(raw json.RawMessage) string {
if len(raw) == 0 || string(raw) == "null" {
return "(no data)"
}
var buf bytes.Buffer
if err := json.Indent(&buf, raw, "", " "); err != nil {
return string(raw)
}
return buf.String()
}
// upidMsg собирает человекочитаемое сообщение мутации (с UPID, если есть).
func upidMsg(action, target string, upid string) string {
if upid != "" {
return fmt.Sprintf("%s %s queued (UPID: %s)\nUse task_status (wait=true) to confirm completion.", action, target, upid)
}
return fmt.Sprintf("%s %s done", action, target)
}
+354
View File
@@ -0,0 +1,354 @@
package tools
import (
"context"
"fmt"
"net/url"
"strconv"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// lxc.go — LXC-контейнеры: чтение (list/describe/config) + lifecycle
// (start/stop/reboot/shutdown), clone, delete, config_update, resize.
// Всё по аналогии с QEMU, но через /lxc. Мутации = gate + confirm + probe.
const vmTypeLXC = "lxc"
func registerLXCTools(s *mcp.Server) {
// --- read ---
s.AddTool(&mcp.Tool{
Name: "containers_list",
Description: "List LXC containers across the cluster. Read-only.",
InputSchema: schema(map[string]any{
"host": strProps("Cluster alias (default: primary)", false),
}, nil),
}, containersListHandler)
s.AddTool(&mcp.Tool{
Name: "container_describe",
Description: "Describe one LXC container: status + config. Read-only.",
InputSchema: schema(map[string]any{
"node": strProps("Node name", true),
"vmid": strProps("CT ID", true),
"host": strProps("Cluster alias (default: primary)", false),
}, []string{"node", "vmid"}),
}, containerDescribeHandler)
s.AddTool(&mcp.Tool{
Name: "container_config",
Description: "Get the raw LXC config of a container. Read-only.",
InputSchema: schema(map[string]any{
"node": strProps("Node name", true),
"vmid": strProps("CT ID", true),
"host": strProps("Cluster alias (default: primary)", false),
}, []string{"node", "vmid"}),
}, containerConfigHandler)
// --- lifecycle ---
registerPatternTool(s, &mcp.Tool{
Name: "container_start",
Description: "Start an LXC container. Requires write permission.",
InputSchema: containerActionSchema(false),
}, vmPatterns, containerStartHandler)
registerPatternTool(s, &mcp.Tool{
Name: "container_stop",
Description: "Stop an LXC container. Requires confirm + write permission.",
InputSchema: containerActionSchema(true),
}, vmPatterns, containerStopHandler)
registerPatternTool(s, &mcp.Tool{
Name: "container_reboot",
Description: "Reboot an LXC container. Requires confirm + write permission.",
InputSchema: containerActionSchema(true),
}, vmPatterns, containerRebootHandler)
registerPatternTool(s, &mcp.Tool{
Name: "container_shutdown",
Description: "Gracefully shut down an LXC container. Requires confirm + write permission.",
InputSchema: containerActionSchema(true),
}, vmPatterns, containerShutdownHandler)
// --- clone / delete / config / resize ---
registerPatternTool(s, &mcp.Tool{
Name: "container_clone",
Description: "Clone an LXC container/template into a new CT ID. Requires confirm + write permission.",
InputSchema: schema(map[string]any{
"node": strProps("Node name", true),
"vmid": strProps("Source CT ID", true),
"newid": intProps("New CT ID (0 = next free)", false),
"name": strProps("Name for the clone", false),
"confirm": strProps("Set to \"true\" to confirm", true, "true"),
}, []string{"node", "vmid", "confirm"}),
}, vmPatterns, containerCloneHandler)
registerPatternTool(s, &mcp.Tool{
Name: "container_delete",
Description: "Permanently delete an LXC container. Requires confirm + write permission.",
InputSchema: schema(map[string]any{
"node": strProps("Node name", true),
"vmid": strProps("CT ID", true),
"confirm": strProps("Set to \"true\" to confirm permanent deletion", true, "true"),
}, []string{"node", "vmid", "confirm"}),
}, vmPatterns, containerDeleteHandler)
registerPatternTool(s, &mcp.Tool{
Name: "container_config_update",
Description: "Update safe LXC config fields (hostname, memory, swap, cores, unprivileged). Denied keys rejected. Requires confirm + write permission.",
InputSchema: schema(map[string]any{
"node": strProps("Node name", true),
"vmid": strProps("CT ID", true),
"updates": objectProps("Object of key->value config fields", true),
"confirm": strProps("Set to \"true\" to confirm", true, "true"),
}, []string{"node", "vmid", "updates", "confirm"}),
}, vmPatterns, containerConfigUpdateHandler)
registerPatternTool(s, &mcp.Tool{
Name: "container_resize",
Description: "Resize a rootfs/mountpoint of an LXC container. Requires confirm + write permission.",
InputSchema: schema(map[string]any{
"node": strProps("Node name", true),
"vmid": strProps("CT ID", true),
"disk": strProps("Disk to resize (e.g. rootfs)", true),
"size": strProps("Size change, e.g. +5G", true),
"confirm": strProps("Set to \"true\" to confirm", true, "true"),
}, []string{"node", "vmid", "disk", "size", "confirm"}),
}, vmPatterns, containerResizeHandler)
}
// containerActionSchema — схема для lifecycle LXC (с/без confirm).
func containerActionSchema(withConfirm bool) map[string]any {
props := map[string]any{
"node": strProps("Node name", true),
"vmid": strProps("CT ID", true),
}
req := []string{"node", "vmid"}
if withConfirm {
props["confirm"] = strProps("Set to \"true\" to confirm", true, "true")
req = append(req, "confirm")
}
return schema(props, req)
}
// --- read ---
func containersListHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
t, err := tenantFor(ctx, req)
if err != nil {
return errorResult(err.Error()), nil
}
host, err := resolveHost(t, requestArgs(req))
if err != nil {
return errorResult(err.Error()), nil
}
cctx, cancel := timeout(ctx, t)
defer cancel()
data, err := t.GuestResources(cctx, host, vmTypeLXC)
if err != nil {
return errorResult(err.Error()), nil
}
return textResult(pretty(data)), nil
}
func containerDescribeHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
t, err := tenantFor(ctx, req)
if err != nil {
return errorResult(err.Error()), nil
}
args := requestArgs(req)
node, vmid, host, r := resolveVMArgs(t, args)
if r != nil {
return r, nil
}
cctx, cancel := timeout(ctx, t)
defer cancel()
status, err := t.ContainerStatus(cctx, host, node, vmid)
if err != nil {
return errorResult(err.Error()), nil
}
cfg, err := t.ContainerConfig(cctx, host, node, vmid)
if err != nil {
return errorResult(err.Error()), nil
}
return textResult("=== Status ===\n" + pretty(status) + "\n\n=== Config ===\n" + pretty(cfg)), nil
}
func containerConfigHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
t, err := tenantFor(ctx, req)
if err != nil {
return errorResult(err.Error()), nil
}
args := requestArgs(req)
node, vmid, host, r := resolveVMArgs(t, args)
if r != nil {
return r, nil
}
cctx, cancel := timeout(ctx, t)
defer cancel()
data, err := t.ContainerConfig(cctx, host, node, vmid)
if err != nil {
return errorResult(err.Error()), nil
}
return textResult(pretty(data)), nil
}
// --- lifecycle ---
func containerStartHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return containerActionHandler(ctx, req, "start", false)
}
func containerStopHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return containerActionHandler(ctx, req, "stop", true)
}
func containerRebootHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return containerActionHandler(ctx, req, "reboot", true)
}
func containerShutdownHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return containerActionHandler(ctx, req, "shutdown", true)
}
func containerActionHandler(ctx context.Context, req *mcp.CallToolRequest, action string, needConfirm bool) (*mcp.CallToolResult, error) {
t, err := tenantFor(ctx, req)
if err != nil {
return errorResult(err.Error()), nil
}
args := requestArgs(req)
node, vmid, host, r := resolveVMArgs(t, args)
if r != nil {
return r, nil
}
if needConfirm {
if err := confirm(args, "container_"+action); err != nil {
return errorResult(err.Error()), nil
}
}
if err := gateVMWrite(t, host, vmid); err != nil {
return errorResult(err.Error()), nil
}
upid, err := t.ContainerAction(ctx, host, node, vmid, action)
if err != nil {
return errorResult(err.Error()), nil
}
return textResult(upidMsg("container_"+action, fmt.Sprintf("%s/%d", node, vmid), upid)), nil
}
func containerCloneHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
t, err := tenantFor(ctx, req)
if err != nil {
return errorResult(err.Error()), nil
}
args := requestArgs(req)
node, src, host, r := resolveVMArgs(t, args)
if r != nil {
return r, nil
}
if err := confirm(args, "container_clone"); err != nil {
return errorResult(err.Error()), nil
}
if err := gateVMWrite(t, host, src); err != nil {
return errorResult(err.Error()), nil
}
newID := getInt(args, "newid", 0)
if newID == 0 {
data, err := t.NextID(ctx, host)
if err != nil {
return errorResult(err.Error()), nil
}
newID = intFromData(data)
}
form := url.Values{"newid": {strconv.Itoa(newID)}}
if name := getString(args, "name", ""); name != "" {
form.Set("name", name)
}
upid, err := t.ContainerClone(ctx, host, node, src, newID, form)
if err != nil {
return errorResult(err.Error()), nil
}
return textResult(upidMsg("container_clone", fmt.Sprintf("%d -> %d", src, newID), upid)), nil
}
func containerDeleteHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
t, err := tenantFor(ctx, req)
if err != nil {
return errorResult(err.Error()), nil
}
args := requestArgs(req)
node, vmid, host, r := resolveVMArgs(t, args)
if r != nil {
return r, nil
}
if err := confirm(args, "container_delete"); err != nil {
return errorResult(err.Error()), nil
}
if err := gateVMWrite(t, host, vmid); err != nil {
return errorResult(err.Error()), nil
}
upid, err := t.ContainerDelete(ctx, host, node, vmid)
if err != nil {
return errorResult(err.Error()), nil
}
return textResult(upidMsg("container_delete", fmt.Sprintf("%s/%d", node, vmid), upid)), nil
}
func containerConfigUpdateHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
t, err := tenantFor(ctx, req)
if err != nil {
return errorResult(err.Error()), nil
}
args := requestArgs(req)
node, vmid, host, r := resolveVMArgs(t, args)
if r != nil {
return r, nil
}
if err := confirm(args, "container_config_update"); err != nil {
return errorResult(err.Error()), nil
}
if err := gateVMWrite(t, host, vmid); err != nil {
return errorResult(err.Error()), nil
}
updates := getObject(args, "updates")
if len(updates) == 0 {
return errorResult("'updates' must be a non-empty object"), nil
}
form := make(url.Values, len(updates))
for k, v := range updates {
if t.Config().DenyConfigKey(k) {
return errorResult(fmt.Sprintf("field %q is denied by policy", k)), nil
}
form.Set(k, fmt.Sprint(v))
}
upid, err := t.ConfigPost(ctx, host, node, vmTypeLXC, vmid, form)
if err != nil {
return errorResult(err.Error()), nil
}
return textResult(upidMsg("container_config_update", fmt.Sprintf("%s/%d", node, vmid), upid)), nil
}
func containerResizeHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
t, err := tenantFor(ctx, req)
if err != nil {
return errorResult(err.Error()), nil
}
args := requestArgs(req)
node, vmid, host, r := resolveVMArgs(t, args)
if r != nil {
return r, nil
}
if err := confirm(args, "container_resize"); err != nil {
return errorResult(err.Error()), nil
}
if err := gateVMWrite(t, host, vmid); err != nil {
return errorResult(err.Error()), nil
}
disk, err := requireName(args, "disk")
if err != nil {
return errorResult(err.Error()), nil
}
size := getString(args, "size", "")
if size == "" {
return errorResult("'size' is required (e.g. +5G)"), nil
}
upid, err := t.ResizeDisk(ctx, host, node, vmTypeLXC, vmid, url.Values{"disk": {disk}, "size": {size}})
if err != nil {
return errorResult(err.Error()), nil
}
return textResult(upidMsg("container_resize", fmt.Sprintf("%s/%d %s", node, vmid, disk), upid)), nil
}
+40
View File
@@ -0,0 +1,40 @@
package tools
import (
"strings"
)
// patterns.go — доменные forge-probe эмиттеры proxmox-модуля. Механика probe
// (wrapProbe/registerPatternTool/isProbe, флаг поддержки) — в forge-toolkit.
//
// Тулы эмитируют КАНОНИЧЕСКИЙ паттерн ресурса. ВАЖНО: паттерн первым
// компонентом несёт `host=<alias>` — только так permission-правила оператора
// могут различить один и тот же vmid/node на разных гипервизорах (коллизия
// VMID). Пример правила:
//
// patterns:
// - match: "proxmox__vm_delete"
// pattern: "host=pve1 node=pve vmid=500"
// then: ask
//
// Для мутаций always НЕ эмитим: каждый деструктивный вызов — отдельный ask
// (никакого авто-одобрения по префиксу без явного allow у оператора).
// vmPatterns — паттерн ресурса для VM/CT-операции: host + node + vmid.
func vmPatterns(args map[string]any) (patterns, always []string) {
var b strings.Builder
b.WriteString("host=" + getString(args, "host", ""))
if n := getString(args, "node", ""); n != "" {
b.WriteString(" node=" + n)
}
if v := getString(args, "vmid", ""); v != "" {
b.WriteString(" vmid=" + v)
}
return []string{b.String()}, nil
}
// nodePatterns — паттерн для операции уровня ноды: host + node.
func nodePatterns(args map[string]any) (patterns, always []string) {
p := "host=" + getString(args, "host", "") + " node=" + getString(args, "node", "")
return []string{p}, nil
}
+28
View File
@@ -0,0 +1,28 @@
// Package tools — MCP-слой forge-tools-proxmox: превращение JSON-RPC
// запроса/ответа в вызовы доменного pve.Tenant. Никакой бизнес-логики.
package tools
import (
"forge-tools-proxmox/internal/pve"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// RegisterAll регистрирует все инструменты proxmox-сервера. Инструменты
// сгруппированы по доменам (см. README): кластер/ноды, VM, конфиг/диски/сеть/
// cloud-init, LXC, снапшоты, бэкапы, задачи, гость, метрики, хранилища.
func RegisterAll(s *mcp.Server, m *pve.Manager) {
SetManager(m)
registerClusterTools(s)
registerVMTools(s)
registerVMConfigTools(s)
registerVMDiskTools(s)
registerVMNetTools(s)
registerVMCloudInitTools(s)
registerLXCTools(s)
registerSnapshotTools(s)
registerBackupTools(s)
registerTaskTools(s)
registerGuestTools(s)
registerStorageTools(s)
}
+181
View File
@@ -0,0 +1,181 @@
package tools
import (
"context"
"fmt"
"net/url"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// snapshot.go — снапшоты VM/CT: list (read), create, delete, rollback.
// Снапшот — точка восстановления гостя; delete/rollback необратимы →
// confirm + gate + probe.
func registerSnapshotTools(s *mcp.Server) {
s.AddTool(&mcp.Tool{
Name: "snapshot_list",
Description: "List snapshots of a VM/CT. Read-only.",
InputSchema: schema(map[string]any{
"node": strProps("Node name", true),
"vmid": strProps("VM/CT ID", true),
"kind": strProps("Guest type", false, "qemu", "lxc"),
"host": strProps("Cluster alias (default: primary)", false),
}, []string{"node", "vmid"}),
}, snapshotListHandler)
registerPatternTool(s, &mcp.Tool{
Name: "snapshot_create",
Description: "Create a snapshot of a VM/CT. Requires confirm + write permission.",
InputSchema: schema(map[string]any{
"node": strProps("Node name", true),
"vmid": strProps("VM/CT ID", true),
"name": strProps("Snapshot name", true),
"desc": strProps("Snapshot description", false),
"vmstate": boolProps("Include running state (RAM). Default: false", false),
"kind": strProps("Guest type", false, "qemu", "lxc"),
"confirm": strProps("Set to \"true\" to confirm", true, "true"),
}, []string{"node", "vmid", "name", "confirm"}),
}, vmPatterns, snapshotCreateHandler)
registerPatternTool(s, &mcp.Tool{
Name: "snapshot_delete",
Description: "Delete a snapshot. Requires confirm + write permission.",
InputSchema: schema(map[string]any{
"node": strProps("Node name", true),
"vmid": strProps("VM/CT ID", true),
"name": strProps("Snapshot name", true),
"kind": strProps("Guest type", false, "qemu", "lxc"),
"confirm": strProps("Set to \"true\" to confirm permanent deletion", true, "true"),
}, []string{"node", "vmid", "name", "confirm"}),
}, vmPatterns, snapshotDeleteHandler)
registerPatternTool(s, &mcp.Tool{
Name: "snapshot_rollback",
Description: "Roll back a VM/CT to a snapshot (restores disk/state). Requires confirm + write permission.",
InputSchema: schema(map[string]any{
"node": strProps("Node name", true),
"vmid": strProps("VM/CT ID", true),
"name": strProps("Snapshot name", true),
"kind": strProps("Guest type", false, "qemu", "lxc"),
"confirm": strProps("Set to \"true\" to confirm destructive rollback", true, "true"),
}, []string{"node", "vmid", "name", "confirm"}),
}, vmPatterns, snapshotRollbackHandler)
}
func snapshotListHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
t, err := tenantFor(ctx, req)
if err != nil {
return errorResult(err.Error()), nil
}
args := requestArgs(req)
node, vmid, host, r := resolveVMArgs(t, args)
if r != nil {
return r, nil
}
kind := guestKind(args)
cctx, cancel := timeout(ctx, t)
defer cancel()
data, err := t.SnapshotList(cctx, host, node, kind, vmid)
if err != nil {
return errorResult(err.Error()), nil
}
return textResult(pretty(data)), nil
}
func snapshotCreateHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
t, err := tenantFor(ctx, req)
if err != nil {
return errorResult(err.Error()), nil
}
args := requestArgs(req)
node, vmid, host, r := resolveVMArgs(t, args)
if r != nil {
return r, nil
}
if err := confirm(args, "snapshot_create"); err != nil {
return errorResult(err.Error()), nil
}
if err := gateVMWrite(t, host, vmid); err != nil {
return errorResult(err.Error()), nil
}
name, err := requireName(args, "name")
if err != nil {
return errorResult(err.Error()), nil
}
form := url.Values{"snapname": {name}}
if d := getString(args, "desc", ""); d != "" {
form.Set("description", d)
}
if getBool(args, "vmstate", false) {
form.Set("vmstate", "1")
}
upid, err := t.SnapshotCreate(ctx, host, node, guestKind(args), vmid, form)
if err != nil {
return errorResult(err.Error()), nil
}
return textResult(upidMsg("snapshot_create", fmt.Sprintf("%s/%d %s", node, vmid, name), upid)), nil
}
func snapshotDeleteHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
t, err := tenantFor(ctx, req)
if err != nil {
return errorResult(err.Error()), nil
}
args := requestArgs(req)
node, vmid, host, r := resolveVMArgs(t, args)
if r != nil {
return r, nil
}
if err := confirm(args, "snapshot_delete"); err != nil {
return errorResult(err.Error()), nil
}
if err := gateVMWrite(t, host, vmid); err != nil {
return errorResult(err.Error()), nil
}
name, err := requireName(args, "name")
if err != nil {
return errorResult(err.Error()), nil
}
upid, err := t.SnapshotDelete(ctx, host, node, guestKind(args), vmid, name)
if err != nil {
return errorResult(err.Error()), nil
}
return textResult(upidMsg("snapshot_delete", fmt.Sprintf("%s/%d %s", node, vmid, name), upid)), nil
}
func snapshotRollbackHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
t, err := tenantFor(ctx, req)
if err != nil {
return errorResult(err.Error()), nil
}
args := requestArgs(req)
node, vmid, host, r := resolveVMArgs(t, args)
if r != nil {
return r, nil
}
if err := confirm(args, "snapshot_rollback"); err != nil {
return errorResult(err.Error()), nil
}
if err := gateVMWrite(t, host, vmid); err != nil {
return errorResult(err.Error()), nil
}
name, err := requireName(args, "name")
if err != nil {
return errorResult(err.Error()), nil
}
upid, err := t.SnapshotRollback(ctx, host, node, guestKind(args), vmid, name)
if err != nil {
return errorResult(err.Error()), nil
}
return textResult(upidMsg("snapshot_rollback", fmt.Sprintf("%s/%d %s", node, vmid, name), upid)), nil
}
// guestKind возвращает тип гостя (qemu/lxc) из аргумента, по умолчанию qemu.
func guestKind(args map[string]any) string {
k := getString(args, "kind", vmTypeQEMU)
if k != vmTypeQEMU && k != vmTypeLXC {
return vmTypeQEMU
}
return k
}
+131
View File
@@ -0,0 +1,131 @@
package tools
import (
"context"
"forge-tools-proxmox/internal/pve"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// storage.go — хранилища: storage_list, storage_status (read) и содержимое
// (templates_list / isos_list). Только наблюдение: изменение хранилищ
// (Ceph/ZFS/SDN) — зона отдельного модуля (см. README «Roadmap»), чтобы не
// тащить комбайн и не рисковать дисковой подсистемой.
func registerStorageTools(s *mcp.Server) {
s.AddTool(&mcp.Tool{
Name: "storage_list",
Description: "List storage pools (type/content/usage). Read-only.",
InputSchema: schema(map[string]any{
"host": strProps("Cluster alias (default: primary)", false),
}, nil),
}, storageListHandler)
s.AddTool(&mcp.Tool{
Name: "storage_status",
Description: "Storage pool status and usage (per node). Read-only.",
InputSchema: schema(map[string]any{
"node": strProps("Node name", true),
"host": strProps("Cluster alias (default: primary)", false),
}, []string{"node"}),
}, storageStatusHandler)
s.AddTool(&mcp.Tool{
Name: "templates_list",
Description: "List LXC templates (vztmpl) on a storage. Read-only.",
InputSchema: schema(map[string]any{
"node": strProps("Node name", true),
"storage": strProps("Storage (default: local)", false),
"host": strProps("Cluster alias (default: primary)", false),
}, []string{"node"}),
}, templatesListHandler)
s.AddTool(&mcp.Tool{
Name: "isos_list",
Description: "List ISO images on a storage. Read-only.",
InputSchema: schema(map[string]any{
"node": strProps("Node name", true),
"storage": strProps("Storage (default: local)", false),
"host": strProps("Cluster alias (default: primary)", false),
}, []string{"node"}),
}, isosListHandler)
}
func storageListHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
t, err := tenantFor(ctx, req)
if err != nil {
return errorResult(err.Error()), nil
}
host, err := resolveHost(t, requestArgs(req))
if err != nil {
return errorResult(err.Error()), nil
}
cctx, cancel := timeout(ctx, t)
defer cancel()
data, err := t.StorageList(cctx, host)
if err != nil {
return errorResult(err.Error()), nil
}
return textResult(pretty(data)), nil
}
func storageStatusHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
// storage_status — статус хранилищ ноды (через /nodes/{node}/storage).
t, err := tenantFor(ctx, req)
if err != nil {
return errorResult(err.Error()), nil
}
args := requestArgs(req)
node, err := requireNode(args)
if err != nil {
return errorResult(err.Error()), nil
}
host, err := resolveHost(t, args)
if err != nil {
return errorResult(err.Error()), nil
}
cctx, cancel := timeout(ctx, t)
defer cancel()
data, err := t.NodeStorage(cctx, host, node)
if err != nil {
return errorResult(err.Error()), nil
}
return textResult(pretty(data)), nil
}
func templatesListHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return contentListHandler(ctx, req, "vztmpl")
}
func isosListHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return contentListHandler(ctx, req, "iso")
}
// contentListHandler — содержимое хранилища по типу (vztmpl/iso/backup/images).
func contentListHandler(ctx context.Context, req *mcp.CallToolRequest, contentType string) (*mcp.CallToolResult, error) {
t, err := tenantFor(ctx, req)
if err != nil {
return errorResult(err.Error()), nil
}
args := requestArgs(req)
node, err := requireNode(args)
if err != nil {
return errorResult(err.Error()), nil
}
storage := getString(args, "storage", "local")
if err := pve.ValidateIdentifier(storage); err != nil {
return errorResult(err.Error()), nil
}
host, err := resolveHost(t, args)
if err != nil {
return errorResult(err.Error()), nil
}
cctx, cancel := timeout(ctx, t)
defer cancel()
data, err := t.StorageContent(cctx, host, node, storage, contentType)
if err != nil {
return errorResult(err.Error()), nil
}
return textResult(pretty(data)), nil
}
+172
View File
@@ -0,0 +1,172 @@
package tools
import (
"context"
"fmt"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// task.go — задачи Proxmox (UPID): list (read), status (read, с опц. wait),
// log (read), stop (confirm + write). Мутации возвращают UPID, поэтому
// task_status(wait=true) — ключ к подтверждению async-операций, при этом
// poll строго ограничен timeout (bounded §9.6).
func registerTaskTools(s *mcp.Server) {
s.AddTool(&mcp.Tool{
Name: "tasks_list",
Description: "List recent cluster/node tasks. Read-only.",
InputSchema: schema(map[string]any{
"node": strProps("Node name (empty = whole cluster)", false),
"limit": intProps("Number of tasks (default: 20)", false),
"host": strProps("Cluster alias (default: primary)", false),
}, nil),
}, tasksListHandler)
s.AddTool(&mcp.Tool{
Name: "task_status",
Description: "Get status of a task by UPID. Set wait=true to poll until it finishes (bounded by task_poll_max_sec). Read-only.",
InputSchema: schema(map[string]any{
"node": strProps("Node name", true),
"upid": strProps("Task UPID", true),
"wait": boolProps("Wait for completion (default: false)", false),
"host": strProps("Cluster alias (default: primary)", false),
}, []string{"node", "upid"}),
}, taskStatusHandler)
s.AddTool(&mcp.Tool{
Name: "task_log",
Description: "Get the log of a task by UPID. Read-only.",
InputSchema: schema(map[string]any{
"node": strProps("Node name", true),
"upid": strProps("Task UPID", true),
"limit": intProps("Number of lines (default: 50)", false),
"host": strProps("Cluster alias (default: primary)", false),
}, []string{"node", "upid"}),
}, taskLogHandler)
registerPatternTool(s, &mcp.Tool{
Name: "task_stop",
Description: "Stop/cancel a running task by UPID. Requires confirm + write permission.",
InputSchema: schema(map[string]any{
"node": strProps("Node name", true),
"upid": strProps("Task UPID", true),
"confirm": strProps("Set to \"true\" to confirm", true, "true"),
"host": strProps("Cluster alias (default: primary)", false),
}, []string{"node", "upid", "confirm"}),
}, nodePatterns, taskStopHandler)
}
func tasksListHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
t, err := tenantFor(ctx, req)
if err != nil {
return errorResult(err.Error()), nil
}
args := requestArgs(req)
host, err := resolveHost(t, args)
if err != nil {
return errorResult(err.Error()), nil
}
limit := getInt(args, "limit", 20)
cctx, cancel := timeout(ctx, t)
defer cancel()
data, err := t.TasksList(cctx, host, getString(args, "node", ""), limit)
if err != nil {
return errorResult(err.Error()), nil
}
return textResult(pretty(data)), nil
}
func taskStatusHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
t, err := tenantFor(ctx, req)
if err != nil {
return errorResult(err.Error()), nil
}
args := requestArgs(req)
node, err := requireNode(args)
if err != nil {
return errorResult(err.Error()), nil
}
upid, err := requireUPID(args)
if err != nil {
return errorResult(err.Error()), nil
}
host, err := resolveHost(t, args)
if err != nil {
return errorResult(err.Error()), nil
}
if getBool(args, "wait", false) {
out, err := t.TaskWait(ctx, host, node, upid)
if err != nil {
return errorResult(err.Error()), nil
}
return textResult(out), nil
}
cctx, cancel := timeout(ctx, t)
defer cancel()
data, err := t.TaskStatus(cctx, host, node, upid)
if err != nil {
return errorResult(err.Error()), nil
}
return textResult(pretty(data)), nil
}
func taskLogHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
t, err := tenantFor(ctx, req)
if err != nil {
return errorResult(err.Error()), nil
}
args := requestArgs(req)
node, err := requireNode(args)
if err != nil {
return errorResult(err.Error()), nil
}
upid, err := requireUPID(args)
if err != nil {
return errorResult(err.Error()), nil
}
host, err := resolveHost(t, args)
if err != nil {
return errorResult(err.Error()), nil
}
limit := getInt(args, "limit", 50)
cctx, cancel := timeout(ctx, t)
defer cancel()
data, err := t.TaskLog(cctx, host, node, upid, limit)
if err != nil {
return errorResult(err.Error()), nil
}
return textResult(pretty(data)), nil
}
func taskStopHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
t, err := tenantFor(ctx, req)
if err != nil {
return errorResult(err.Error()), nil
}
args := requestArgs(req)
node, err := requireNode(args)
if err != nil {
return errorResult(err.Error()), nil
}
upid, err := requireUPID(args)
if err != nil {
return errorResult(err.Error()), nil
}
host, err := resolveHost(t, args)
if err != nil {
return errorResult(err.Error()), nil
}
if err := confirm(args, "task_stop"); err != nil {
return errorResult(err.Error()), nil
}
// task_stop — отмена выполняющейся задачи (не ресурс кластера).
if err := gateNodeWrite(t, host, node); err != nil {
return errorResult(err.Error()), nil
}
upidMsg2, err := t.TaskStop(ctx, host, node, upid)
if err != nil {
return errorResult(err.Error()), nil
}
return textResult(upidMsg("task_stop", fmt.Sprintf("%s %s", node, upid), upidMsg2)), nil
}
+64
View File
@@ -0,0 +1,64 @@
package tools
import (
"git.totmin.ru/en2zmax/forge-toolkit"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// toolkit.go — тонкие адаптеры к общему слою forge-toolkit.
// Локальные имена сохранены, чтобы хендлеры не зависели от пакета-источника.
func requestArgs(req *mcp.CallToolRequest) map[string]any {
return toolkit.RequestArgs(req)
}
func getString(args map[string]any, key, def string) string {
return toolkit.GetString(args, key, def)
}
func requireString(args map[string]any, key string) (string, error) {
return toolkit.RequireString(args, key)
}
func getInt(args map[string]any, key string, def int) int {
return toolkit.GetInt(args, key, def)
}
func getBool(args map[string]any, key string, def bool) bool {
return toolkit.GetBool(args, key, def)
}
func schema(properties map[string]any, required []string) map[string]any {
return toolkit.Schema(properties, required)
}
func strProps(desc string, required bool, enum ...string) map[string]any {
return toolkit.StrProps(desc, required, enum...)
}
func intProps(desc string, required bool) map[string]any {
return toolkit.IntProps(desc, required)
}
func boolProps(desc string, required bool) map[string]any {
return toolkit.BoolProps(desc, required)
}
func objectProps(desc string, required bool) map[string]any {
return toolkit.ObjectProps(desc, required)
}
func textResult(text string) *mcp.CallToolResult {
return toolkit.Text(text)
}
func errorResult(msg string) *mcp.CallToolResult {
return toolkit.Error(msg)
}
// patternsFn — forge-probe эмиттер паттернов (см. toolkit.PatternsFn).
type patternsFn = toolkit.PatternsFn
func registerPatternTool(s *mcp.Server, tool *mcp.Tool, fn patternsFn, h mcp.ToolHandler) {
toolkit.RegisterPatternTool(s, tool, fn, h)
}
+390
View File
@@ -0,0 +1,390 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"net/url"
"strconv"
"forge-tools-proxmox/internal/pve"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// vm.go — QEMU-ВМ: чтение (list/describe/config/nextid) и lifecycle
// (start/stop/reboot/shutdown/suspend/resume) + clone/delete/convert-template.
// Мутации за гейтом (read_only + allowlist) и registerPatternTool (probe →
// require_approval). Удаление/конвертация — дополнительно confirm.
//
// ВАЖНО (анти-дубликат): запуск ВМ НЕ через guest/консоль — это зона ssh__run.
// Здесь только управление гостевой сущностью гипервизора.
// vmType — тип гостя в PVE API ("qemu"|"lxc"). Константа для единообразия.
const vmTypeQEMU = "qemu"
func registerVMTools(s *mcp.Server) {
// --- read ---
s.AddTool(&mcp.Tool{
Name: "vms_list",
Description: "List QEMU VMs across the cluster (status, resources). Read-only.",
InputSchema: schema(map[string]any{
"host": strProps("Cluster alias (default: primary)", false),
}, nil),
}, vmsListHandler)
s.AddTool(&mcp.Tool{
Name: "vm_describe",
Description: "Describe one VM: current status + config (cores, memory, disks, network). Read-only.",
InputSchema: schema(map[string]any{
"node": strProps("Node name", true),
"vmid": strProps("VM ID", true),
"host": strProps("Cluster alias (default: primary)", false),
}, []string{"node", "vmid"}),
}, vmDescribeHandler)
s.AddTool(&mcp.Tool{
Name: "vm_config",
Description: "Get the raw QEMU config of a VM (qm config). Read-only.",
InputSchema: schema(map[string]any{
"node": strProps("Node name", true),
"vmid": strProps("VM ID", true),
"host": strProps("Cluster alias (default: primary)", false),
}, []string{"node", "vmid"}),
}, vmConfigHandler)
s.AddTool(&mcp.Tool{
Name: "vm_next_id",
Description: "Get the next free VMID in the cluster. Read-only.",
InputSchema: schema(map[string]any{
"host": strProps("Cluster alias (default: primary)", false),
}, nil),
}, vmNextIDHandler)
// --- lifecycle (мутации ⇒ write gate + probe/approval) ---
s.AddTool(&mcp.Tool{
Name: "vm_start",
Description: "Start a VM. Requires write permission (allowlist + approve).",
InputSchema: schema(map[string]any{
"node": strProps("Node name", true),
"vmid": strProps("VM ID", true),
}, []string{"node", "vmid"}),
}, vmStartHandler)
for _, a := range []struct {
name string
description string
confirm bool
handler mcp.ToolHandler
}{
{"vm_stop", "Force-stop a VM. Requires confirm + write permission.", true, vmStopHandler},
{"vm_reboot", "Reboot a VM. Requires confirm + write permission.", true, vmRebootHandler},
{"vm_shutdown", "Gracefully shut down a VM (guest agent / ACPI). Requires confirm + write permission.", true, vmShutdownHandler},
{"vm_suspend", "Suspend (pause) a VM. Requires write permission.", false, vmSuspendHandler},
{"vm_resume", "Resume a VM. Requires write permission.", false, vmResumeHandler},
} {
props := map[string]any{
"node": strProps("Node name", true),
"vmid": strProps("VM ID", true),
}
required := []string{"node", "vmid"}
if a.confirm {
props["confirm"] = strProps("Set to \"true\" to confirm this destructive/lifecycle action", true, "true")
required = append(required, "confirm")
}
registerPatternTool(s, &mcp.Tool{
Name: a.name,
Description: a.description,
InputSchema: schema(props, required),
}, vmPatterns, a.handler)
}
// clone / delete / template
registerPatternTool(s, &mcp.Tool{
Name: "vm_clone",
Description: "Clone a VM (template) into a new VMID. If a template, clones it. Requires write permission.",
InputSchema: schema(map[string]any{
"node": strProps("Node name", true),
"vmid": strProps("Source VM/template ID", true),
"newid": intProps("New VMID (0 = next free)", false),
"name": strProps("Name for the clone", false),
"full": boolProps("Full clone (independent copy metadata). Default: depends on template", false),
"confirm": strProps("Set to \"true\" to confirm creating a new guest", true, "true"),
}, []string{"node", "vmid", "confirm"}),
}, vmPatterns, vmCloneHandler)
registerPatternTool(s, &mcp.Tool{
Name: "vm_delete",
Description: "Permanently delete a VM. Requires confirm + write permission.",
InputSchema: schema(map[string]any{
"node": strProps("Node name", true),
"vmid": strProps("VM ID", true),
"purge": boolProps("Also remove from backup jobs/HA/DR (default: true)", false),
"confirm": strProps("Set to \"true\" to confirm permanent deletion", true, "true"),
"force": boolProps("Force even if protected", false),
}, []string{"node", "vmid", "confirm"}),
}, vmPatterns, vmDeleteHandler)
registerPatternTool(s, &mcp.Tool{
Name: "vm_convert_template",
Description: "Convert a VM into a template. Requires confirm + write permission.",
InputSchema: schema(map[string]any{
"node": strProps("Node name", true),
"vmid": strProps("VM ID", true),
"confirm": strProps("Set to \"true\" to confirm conversion", true, "true"),
}, []string{"node", "vmid", "confirm"}),
}, vmPatterns, vmConvertTemplateHandler)
}
// --- read handlers ---
func vmsListHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
t, err := tenantFor(ctx, req)
if err != nil {
return errorResult(err.Error()), nil
}
host, err := resolveHost(t, requestArgs(req))
if err != nil {
return errorResult(err.Error()), nil
}
cctx, cancel := timeout(ctx, t)
defer cancel()
data, err := t.GuestResources(cctx, host, vmTypeQEMU)
if err != nil {
return errorResult(err.Error()), nil
}
return textResult(pretty(data)), nil
}
func vmDescribeHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
t, err := tenantFor(ctx, req)
if err != nil {
return errorResult(err.Error()), nil
}
args := requestArgs(req)
node, vmid, host, r := resolveVMArgs(t, args)
if r != nil {
return r, nil
}
cctx, cancel := timeout(ctx, t)
defer cancel()
status, err := t.VMStatus(cctx, host, node, vmid)
if err != nil {
return errorResult(err.Error()), nil
}
cfg, err := t.VMConfig(cctx, host, node, vmid)
if err != nil {
return errorResult(err.Error()), nil
}
return textResult("=== Status ===\n" + pretty(status) + "\n\n=== Config ===\n" + pretty(cfg)), nil
}
func vmConfigHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
t, err := tenantFor(ctx, req)
if err != nil {
return errorResult(err.Error()), nil
}
args := requestArgs(req)
node, vmid, host, r := resolveVMArgs(t, args)
if r != nil {
return r, nil
}
cctx, cancel := timeout(ctx, t)
defer cancel()
data, err := t.VMConfig(cctx, host, node, vmid)
if err != nil {
return errorResult(err.Error()), nil
}
return textResult(pretty(data)), nil
}
func vmNextIDHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
t, err := tenantFor(ctx, req)
if err != nil {
return errorResult(err.Error()), nil
}
host, err := resolveHost(t, requestArgs(req))
if err != nil {
return errorResult(err.Error()), nil
}
cctx, cancel := timeout(ctx, t)
defer cancel()
data, err := t.NextID(cctx, host)
if err != nil {
return errorResult(err.Error()), nil
}
return textResult(pretty(data)), nil
}
// --- lifecycle handlers ---
func vmStartHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return vmActionHandler(ctx, req, "start", false)
}
func vmStopHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return vmActionHandler(ctx, req, "stop", true)
}
func vmRebootHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return vmActionHandler(ctx, req, "reboot", true)
}
func vmShutdownHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return vmActionHandler(ctx, req, "shutdown", true)
}
func vmSuspendHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return vmActionHandler(ctx, req, "suspend", false)
}
func vmResumeHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return vmActionHandler(ctx, req, "resume", false)
}
// vmActionHandler — общий обработчик lifecycle: gateVMWrite + (confirm) +
// POST /status/<action> → UPID.
func vmActionHandler(ctx context.Context, req *mcp.CallToolRequest, action string, needConfirm bool) (*mcp.CallToolResult, error) {
t, err := tenantFor(ctx, req)
if err != nil {
return errorResult(err.Error()), nil
}
args := requestArgs(req)
node, vmid, host, r := resolveVMArgs(t, args)
if r != nil {
return r, nil
}
if needConfirm {
if err := confirm(args, "vm_"+action); err != nil {
return errorResult(err.Error()), nil
}
}
if err := gateVMWrite(t, host, vmid); err != nil {
return errorResult(err.Error()), nil
}
upid, err := t.VMAction(ctx, host, node, vmid, action, nil)
if err != nil {
return errorResult(err.Error()), nil
}
return textResult(upidMsg("vm_"+action, fmt.Sprintf("%s/%d", node, vmid), upid)), nil
}
func vmCloneHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
t, err := tenantFor(ctx, req)
if err != nil {
return errorResult(err.Error()), nil
}
args := requestArgs(req)
node, src, host, r := resolveVMArgs(t, args)
if r != nil {
return r, nil
}
if err := confirm(args, "vm_clone"); err != nil {
return errorResult(err.Error()), nil
}
// newid: 0 = next free; при клоне всегда гейт по allowlist на источник.
if err := gateVMWrite(t, host, src); err != nil {
return errorResult(err.Error()), nil
}
newID := getInt(args, "newid", 0)
if newID == 0 {
nid, err := t.NextID(ctx, host)
if err != nil {
return errorResult(err.Error()), nil
}
newID = intFromData(nid)
}
form := url.Values{"newid": {strconv.Itoa(newID)}}
if name := getString(args, "name", ""); name != "" {
form.Set("name", name)
}
// full: независимый клон; для template обычно 1, для машин задаёт агент.
if getBool(args, "full", false) {
form.Set("full", "1")
}
upid, err := t.VMClone(ctx, host, node, src, newID, form)
if err != nil {
return errorResult(err.Error()), nil
}
return textResult(upidMsg("vm_clone", fmt.Sprintf("%d -> %d", src, newID), upid)), nil
}
func vmDeleteHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
t, err := tenantFor(ctx, req)
if err != nil {
return errorResult(err.Error()), nil
}
args := requestArgs(req)
node, vmid, host, r := resolveVMArgs(t, args)
if r != nil {
return r, nil
}
if err := confirm(args, "vm_delete"); err != nil {
return errorResult(err.Error()), nil
}
if err := gateVMWrite(t, host, vmid); err != nil {
return errorResult(err.Error()), nil
}
upid, err := t.VMDelete(ctx, host, node, vmid)
if err != nil {
return errorResult(err.Error()), nil
}
return textResult(upidMsg("vm_delete", fmt.Sprintf("%s/%d", node, vmid), upid)), nil
}
func vmConvertTemplateHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
t, err := tenantFor(ctx, req)
if err != nil {
return errorResult(err.Error()), nil
}
args := requestArgs(req)
node, vmid, host, r := resolveVMArgs(t, args)
if r != nil {
return r, nil
}
if err := confirm(args, "vm_convert_template"); err != nil {
return errorResult(err.Error()), nil
}
if err := gateVMWrite(t, host, vmid); err != nil {
return errorResult(err.Error()), nil
}
upid, err := t.VMConvertTemplate(ctx, host, node, vmid)
if err != nil {
return errorResult(err.Error()), nil
}
return textResult(upidMsg("vm_convert_template", fmt.Sprintf("%s/%d", node, vmid), upid)), nil
}
// --- shared helpers ---
// resolveVMArgs извлекает и валидирует node/vmid/host; возвращает errorResult
// как *mcp.CallToolResult при ошибке (r != nil). Host резолвится с учётом
// мульти-гипервизора (при >1 хосте обязателен — иначе коллизия VMID).
func resolveVMArgs(t *pve.Tenant, args map[string]any) (node string, vmid int, host string, r *mcp.CallToolResult) {
var err error
if node, err = requireNode(args); err != nil {
return "", 0, "", errorResult(err.Error())
}
if vmid, err = requireVMID(args); err != nil {
return "", 0, "", errorResult(err.Error())
}
if host, err = resolveHost(t, args); err != nil {
return "", 0, "", errorResult(err.Error())
}
return node, vmid, host, nil
}
// intFromData вытаскивает число из json-ответа (nextid / id): {"data": "101"}.
func intFromData(data json.RawMessage) int {
var obj struct {
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(data, &obj); err != nil {
return 0
}
var s string
if err := json.Unmarshal(obj.Data, &s); err == nil {
n, _ := strconv.Atoi(s)
return n
}
var f float64
if err := json.Unmarshal(obj.Data, &f); err == nil {
return int(f)
}
return 0
}
+62
View File
@@ -0,0 +1,62 @@
package tools
import (
"context"
"fmt"
"net/url"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// vm_cloudinit.go — настройка cloud-init гостя (только QEMU): ciuser,
// cipassword, ipconfig0/1.., sshkeys. Структурная мутация → confirm + gate.
// Для гостей-ВМ; LXC-cloud-init не поддерживается PVE API.
func registerVMCloudInitTools(s *mcp.Server) {
registerPatternTool(s, &mcp.Tool{
Name: "vm_set_cloudinit",
Description: "Set cloud-init options of a QEMU VM: user, password, per-NIC ipconfig, SSH keys. Requires confirm + write permission.",
InputSchema: schema(map[string]any{
"node": strProps("Node name", true),
"vmid": strProps("VM ID", true),
"ciuser": strProps("Cloud-init login user", false),
"cipassword": strProps("Cloud-init login password", false),
"sshkeys": strProps("Public SSH key(s) for the user", false),
"ipconfig0": strProps("ipconfig0 spec, e.g. ip=dhcp or ip=192.168.1.10/24,gw=192.168.102.100", false),
"confirm": strProps("Set to \"true\" to confirm", true, "true"),
}, []string{"node", "vmid", "confirm"}),
}, vmPatterns, vmSetCloudInitHandler)
}
func vmSetCloudInitHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
t, err := tenantFor(ctx, req)
if err != nil {
return errorResult(err.Error()), nil
}
args := requestArgs(req)
node, vmid, host, r := resolveVMArgs(t, args)
if r != nil {
return r, nil
}
if err := confirm(args, "vm_set_cloudinit"); err != nil {
return errorResult(err.Error()), nil
}
if err := gateVMWrite(t, host, vmid); err != nil {
return errorResult(err.Error()), nil
}
form := url.Values{}
for _, k := range []string{"ciuser", "cipassword", "sshkeys", "ipconfig0"} {
if v := getString(args, k, ""); v != "" {
form.Set(k, v)
}
}
if len(form) == 0 {
return errorResult("at least one cloud-init field is required"), nil
}
// Включаем cloud-init диск (для OVMF/машин с cidata) — обычно ide2:cloudinit.
upid, err := t.ConfigPost(ctx, host, node, vmTypeQEMU, vmid, form)
if err != nil {
return errorResult(err.Error()), nil
}
return textResult(upidMsg("vm_set_cloudinit", fmt.Sprintf("%s/%d", node, vmid), upid)), nil
}
+73
View File
@@ -0,0 +1,73 @@
package tools
import (
"context"
"fmt"
"net/url"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// vm_config.go — изменение конфига ВМ (vm_config_update): НЕ структурные
// поля (name/cpu/core/memory/balloon/vcpus ...), безопасные для общего update.
// Структурно-опасные ключи (delete/revert/hotplug/sockets/cores...) —
// в pve.DefaultDenyConfigKeys, туда же отправляем через выделенные
// инструменты (disk/net) с confirm, а не через общий update.
//
// Принимает неструктурированный объект `updates` (map key→value) — модель
// собирает нужные ей поля; guard отклоняет запрещённые.
func registerVMConfigTools(s *mcp.Server) {
registerPatternTool(s, &mcp.Tool{
Name: "vm_config_update",
Description: "Update safe (non-structural) QEMU config fields of a VM: name, cpu, cores, memory, balloon, vcpus, tags, description. Structural/denied keys are rejected. Requires confirm + write permission.",
InputSchema: schema(map[string]any{
"node": strProps("Node name", true),
"vmid": strProps("VM ID", true),
"updates": objectProps("Object of key->value config fields to set", true),
"confirm": strProps("Set to \"true\" to confirm", true, "true"),
}, []string{"node", "vmid", "updates", "confirm"}),
}, vmPatterns, vmConfigUpdateHandler)
}
func vmConfigUpdateHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
t, err := tenantFor(ctx, req)
if err != nil {
return errorResult(err.Error()), nil
}
args := requestArgs(req)
node, vmid, host, r := resolveVMArgs(t, args)
if r != nil {
return r, nil
}
if err := confirm(args, "vm_config_update"); err != nil {
return errorResult(err.Error()), nil
}
if err := gateVMWrite(t, host, vmid); err != nil {
return errorResult(err.Error()), nil
}
updates := getObject(args, "updates")
if len(updates) == 0 {
return errorResult("'updates' must be a non-empty object of config fields"), nil
}
form := make(url.Values, len(updates))
for k, v := range updates {
if t.Config().DenyConfigKey(k) {
return errorResult(fmt.Sprintf("field %q is denied by policy (use a dedicated tool)", k)), nil
}
form.Set(k, fmt.Sprint(v))
}
upid, err := t.ConfigPost(ctx, host, node, vmTypeQEMU, vmid, form)
if err != nil {
return errorResult(err.Error()), nil
}
return textResult(upidMsg("vm_config_update", fmt.Sprintf("%s/%d", node, vmid), upid)), nil
}
// getObject — читает объект аргумента (map[string]any).
func getObject(args map[string]any, key string) map[string]any {
if v, ok := args[key].(map[string]any); ok {
return v
}
return nil
}
+204
View File
@@ -0,0 +1,204 @@
package tools
import (
"context"
"fmt"
"net/url"
"forge-tools-proxmox/internal/pve"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// vm_disk.go — операции над дисками ВМ через выделенные эндпоинты
// (/resize, /move_disk) или конфиг (добавить/удалить). Все — структурные
// мутации: require write gate + confirm + registerPatternTool (probe).
// Для QEMU имена дисков: scsi0.., virtio0.., ide0.., sata0...
func registerVMDiskTools(s *mcp.Server) {
registerPatternTool(s, &mcp.Tool{
Name: "vm_resize_disk",
Description: "Resize a VM disk by name. Size like '+5G' or absolute. Requires confirm + write permission.",
InputSchema: schema(map[string]any{
"node": strProps("Node name", true),
"vmid": strProps("VM ID", true),
"disk": strProps("Disk device (e.g. scsi0)", true),
"size": strProps("Size change, e.g. +5G or 20G", true),
"confirm": strProps("Set to \"true\" to confirm", true, "true"),
}, []string{"node", "vmid", "disk", "size", "confirm"}),
}, vmPatterns, vmResizeDiskHandler)
registerPatternTool(s, &mcp.Tool{
Name: "vm_add_disk",
Description: "Add a new disk (scsi/virtio/ide/sata slot) to a VM. Requires confirm + write permission.",
InputSchema: schema(map[string]any{
"node": strProps("Node name", true),
"vmid": strProps("VM ID", true),
"storage": strProps("Storage name (e.g. local-lvm)", true),
"size_gb": intProps("Disk size in GB", true),
"iface": strProps("Slot to use (e.g. scsi1); required to avoid overwriting", false),
"ssd": boolProps("Mark as SSD (default: false)", false),
"confirm": strProps("Set to \"true\" to confirm", true, "true"),
}, []string{"node", "vmid", "storage", "size_gb", "confirm"}),
}, vmPatterns, vmAddDiskHandler)
registerPatternTool(s, &mcp.Tool{
Name: "vm_remove_disk",
Description: "Remove a disk from a VM (permanently! frees the storage). Requires confirm + write permission.",
InputSchema: schema(map[string]any{
"node": strProps("Node name", true),
"vmid": strProps("VM ID", true),
"disk": strProps("Disk device to remove (e.g. scsi1)", true),
"confirm": strProps("Set to \"true\" to confirm permanent removal", true, "true"),
}, []string{"node", "vmid", "disk", "confirm"}),
}, vmPatterns, vmRemoveDiskHandler)
registerPatternTool(s, &mcp.Tool{
Name: "vm_move_disk",
Description: "Move a VM disk to another storage (storage migration). Requires confirm + write permission.",
InputSchema: schema(map[string]any{
"node": strProps("Node name", true),
"vmid": strProps("VM ID", true),
"disk": strProps("Disk to move (e.g. scsi0)", true),
"storage": strProps("Destination storage", true),
"delete": boolProps("Delete the source after successful move (default: false)", false),
"confirm": strProps("Set to \"true\" to confirm", true, "true"),
}, []string{"node", "vmid", "disk", "storage", "confirm"}),
}, vmPatterns, vmMoveDiskHandler)
}
func vmResizeDiskHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
t, err := tenantFor(ctx, req)
if err != nil {
return errorResult(err.Error()), nil
}
args := requestArgs(req)
node, vmid, host, r := resolveVMArgs(t, args)
if r != nil {
return r, nil
}
if err := confirm(args, "vm_resize_disk"); err != nil {
return errorResult(err.Error()), nil
}
if err := gateVMWrite(t, host, vmid); err != nil {
return errorResult(err.Error()), nil
}
disk, err := requireName(args, "disk")
if err != nil {
return errorResult(err.Error()), nil
}
size := getString(args, "size", "")
if size == "" {
return errorResult("'size' is required (e.g. +5G or 20G)"), nil
}
form := url.Values{"disk": {disk}, "size": {size}}
upid, err := t.ResizeDisk(ctx, host, node, vmTypeQEMU, vmid, form)
if err != nil {
return errorResult(err.Error()), nil
}
return textResult(upidMsg("vm_resize_disk", fmt.Sprintf("%s/%d %s", node, vmid, disk), upid)), nil
}
func vmAddDiskHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
t, err := tenantFor(ctx, req)
if err != nil {
return errorResult(err.Error()), nil
}
args := requestArgs(req)
node, vmid, host, r := resolveVMArgs(t, args)
if r != nil {
return r, nil
}
if err := confirm(args, "vm_add_disk"); err != nil {
return errorResult(err.Error()), nil
}
if err := gateVMWrite(t, host, vmid); err != nil {
return errorResult(err.Error()), nil
}
storage := getString(args, "storage", "")
sizeGB := getInt(args, "size_gb", 0)
if storage == "" || sizeGB <= 0 {
return errorResult("'storage' and positive 'size_gb' are required"), nil
}
iface := getString(args, "iface", "")
if iface == "" {
// Требуем явный слот: авто-выбор рискует перезаписать существующий
// диск (например scsi0). Fail-closed.
return errorResult("'iface' is required (e.g. scsi1) to avoid overwriting an existing disk"), nil
}
if err := pve.ValidateIdentifier(iface); err != nil {
return errorResult(err.Error()), nil
}
value := fmt.Sprintf("%s:%d", storage, sizeGB)
if getBool(args, "ssd", false) {
value += ",ssd=1"
}
upid, err := t.ConfigPost(ctx, host, node, vmTypeQEMU, vmid, url.Values{iface: {value}})
if err != nil {
return errorResult(err.Error()), nil
}
return textResult(upidMsg("vm_add_disk", fmt.Sprintf("%s/%d %s", node, vmid, iface), upid)), nil
}
func vmRemoveDiskHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
t, err := tenantFor(ctx, req)
if err != nil {
return errorResult(err.Error()), nil
}
args := requestArgs(req)
node, vmid, host, r := resolveVMArgs(t, args)
if r != nil {
return r, nil
}
if err := confirm(args, "vm_remove_disk"); err != nil {
return errorResult(err.Error()), nil
}
if err := gateVMWrite(t, host, vmid); err != nil {
return errorResult(err.Error()), nil
}
disk, err := requireName(args, "disk")
if err != nil {
return errorResult(err.Error()), nil
}
// Удаление диска = конфиг UPDATE с delete=<disk> (PVE-семантика).
upid, err := t.ConfigPost(ctx, host, node, vmTypeQEMU, vmid, url.Values{"delete": {disk}})
if err != nil {
return errorResult(err.Error()), nil
}
return textResult(upidMsg("vm_remove_disk", fmt.Sprintf("%s/%d %s", node, vmid, disk), upid)), nil
}
func vmMoveDiskHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
t, err := tenantFor(ctx, req)
if err != nil {
return errorResult(err.Error()), nil
}
args := requestArgs(req)
node, vmid, host, r := resolveVMArgs(t, args)
if r != nil {
return r, nil
}
if err := confirm(args, "vm_move_disk"); err != nil {
return errorResult(err.Error()), nil
}
if err := gateVMWrite(t, host, vmid); err != nil {
return errorResult(err.Error()), nil
}
disk, err := requireName(args, "disk")
if err != nil {
return errorResult(err.Error()), nil
}
storage := getString(args, "storage", "")
if storage == "" {
return errorResult("'storage' is required"), nil
}
form := url.Values{"disk": {disk}, "storage": {storage}}
if getBool(args, "delete", false) {
form.Set("delete", "1")
}
upid, err := t.MoveDisk(ctx, host, node, vmid, form)
if err != nil {
return errorResult(err.Error()), nil
}
return textResult(upidMsg("vm_move_disk", fmt.Sprintf("%s/%d %s", node, vmid, disk), upid)), nil
}
+161
View File
@@ -0,0 +1,161 @@
package tools
import (
"context"
"fmt"
"net/url"
"strings"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// vm_net.go — сетевые интерфейсы ВМ (net0..netN): добавить/обновить/удалить.
// Это структурные изменения конфига → write gate + confirm + probe. Модель
// задаёт iface (net0), модель/бридж; guard валидирует имя интерфейса.
func registerVMNetTools(s *mcp.Server) {
registerPatternTool(s, &mcp.Tool{
Name: "vm_add_network",
Description: "Add a network interface (netN) to a VM, e.g. net0 via vmbr0. Requires confirm + write permission.",
InputSchema: schema(map[string]any{
"node": strProps("Node name", true),
"vmid": strProps("VM ID", true),
"iface": strProps("Interface slot (e.g. net0)", true),
"model": strProps("NIC model", true, "virtio", "e1000", "vmxnet3", "rtl8139"),
"bridge": strProps("Bridge (e.g. vmbr0)", true),
"mac": strProps("MAC (empty = auto)", false),
"firewall": boolProps("Enable firewall (default: true)", false),
"confirm": strProps("Set to \"true\" to confirm", true, "true"),
}, []string{"node", "vmid", "iface", "bridge", "confirm"}),
}, vmPatterns, vmAddNetworkHandler)
registerPatternTool(s, &mcp.Tool{
Name: "vm_update_network",
Description: "Update an existing network interface (model/bridge/mac/firewall). Requires confirm + write permission.",
InputSchema: schema(map[string]any{
"node": strProps("Node name", true),
"vmid": strProps("VM ID", true),
"iface": strProps("Interface slot (e.g. net0)", true),
"model": strProps("NIC model", false, "virtio", "e1000", "vmxnet3", "rtl8139"),
"bridge": strProps("Bridge (e.g. vmbr0)", false),
"mac": strProps("MAC address", false),
"firewall": boolProps("Enable firewall (default: true)", false),
"confirm": strProps("Set to \"true\" to confirm", true, "true"),
}, []string{"node", "vmid", "iface", "confirm"}),
}, vmPatterns, vmUpdateNetworkHandler)
registerPatternTool(s, &mcp.Tool{
Name: "vm_remove_network",
Description: "Remove a network interface from a VM. Requires confirm + write permission.",
InputSchema: schema(map[string]any{
"node": strProps("Node name", true),
"vmid": strProps("VM ID", true),
"iface": strProps("Interface slot (e.g. net0)", true),
"confirm": strProps("Set to \"true\" to confirm", true, "true"),
}, []string{"node", "vmid", "iface", "confirm"}),
}, vmPatterns, vmRemoveNetworkHandler)
}
func vmAddNetworkHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
t, err := tenantFor(ctx, req)
if err != nil {
return errorResult(err.Error()), nil
}
args := requestArgs(req)
node, vmid, host, r := resolveVMArgs(t, args)
if r != nil {
return r, nil
}
if err := confirm(args, "vm_add_network"); err != nil {
return errorResult(err.Error()), nil
}
if err := gateVMWrite(t, host, vmid); err != nil {
return errorResult(err.Error()), nil
}
iface, err := requireName(args, "iface")
if err != nil {
return errorResult(err.Error()), nil
}
model := getString(args, "model", "virtio")
bridge := getString(args, "bridge", "")
if bridge == "" {
return errorResult("'bridge' is required"), nil
}
value := fmt.Sprintf("%s,bridge=%s", model, bridge)
if mac := getString(args, "mac", ""); mac != "" {
value += ",mac=" + mac
}
if getBool(args, "firewall", true) {
value += ",firewall=1"
}
upid, err := t.ConfigPost(ctx, host, node, vmTypeQEMU, vmid, url.Values{iface: {value}})
if err != nil {
return errorResult(err.Error()), nil
}
return textResult(upidMsg("vm_add_network", fmt.Sprintf("%s/%d %s", node, vmid, iface), upid)), nil
}
func vmUpdateNetworkHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
t, err := tenantFor(ctx, req)
if err != nil {
return errorResult(err.Error()), nil
}
args := requestArgs(req)
node, vmid, host, r := resolveVMArgs(t, args)
if r != nil {
return r, nil
}
if err := confirm(args, "vm_update_network"); err != nil {
return errorResult(err.Error()), nil
}
if err := gateVMWrite(t, host, vmid); err != nil {
return errorResult(err.Error()), nil
}
iface, err := requireName(args, "iface")
if err != nil {
return errorResult(err.Error()), nil
}
// update перезаписывает весь сетевой спецификатор: собираем из явных полей.
parts := []string{getString(args, "model", "virtio")}
if b := getString(args, "bridge", ""); b != "" {
parts = append(parts, "bridge="+b)
}
if m := getString(args, "mac", ""); m != "" {
parts = append(parts, "mac="+m)
}
if getBool(args, "firewall", true) {
parts = append(parts, "firewall=1")
}
upid, err := t.ConfigPost(ctx, host, node, vmTypeQEMU, vmid, url.Values{iface: {strings.Join(parts, ",")}})
if err != nil {
return errorResult(err.Error()), nil
}
return textResult(upidMsg("vm_update_network", fmt.Sprintf("%s/%d %s", node, vmid, iface), upid)), nil
}
func vmRemoveNetworkHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
t, err := tenantFor(ctx, req)
if err != nil {
return errorResult(err.Error()), nil
}
args := requestArgs(req)
node, vmid, host, r := resolveVMArgs(t, args)
if r != nil {
return r, nil
}
if err := confirm(args, "vm_remove_network"); err != nil {
return errorResult(err.Error()), nil
}
if err := gateVMWrite(t, host, vmid); err != nil {
return errorResult(err.Error()), nil
}
iface, err := requireName(args, "iface")
if err != nil {
return errorResult(err.Error()), nil
}
upid, err := t.ConfigPost(ctx, host, node, vmTypeQEMU, vmid, url.Values{"delete": {iface}})
if err != nil {
return errorResult(err.Error()), nil
}
return textResult(upidMsg("vm_remove_network", fmt.Sprintf("%s/%d %s", node, vmid, iface), upid)), nil
}