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