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