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
+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, "/")
}