commit a7addaead9a720a38f1e966dcb173816a6b04799 Author: Maksim Totmin Date: Thu Oct 1 10:41:43 2026 +0700 Initial commit: forge-tools-proxmox — MCP-сервер для Proxmox VE diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..afa4929 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# Собранный бинарник +/forge-tools-proxmox +*.exe + +# Резервные копии от редактора файлов +*.bak + +# Локальные конфиги с реальными адресами и токенами. +# В репозитории хранится только pve.json.example. +/pve.json +/config.json +*.local.json + +# Редакторы и ОС +.DS_Store +*.swp +.idea/ +.vscode/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b145d27 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2025 en2zmax + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d0f9cd3 --- /dev/null +++ b/Makefile @@ -0,0 +1,26 @@ +BINARY := forge-tools-proxmox + +.PHONY: build test vet fmt lint clean + +## build: собрать статический бинарник +build: + CGO_ENABLED=0 go build -trimpath -o $(BINARY) . + +## test: тесты с детектором гонок +test: + go test -race ./... + +## vet: статический анализ +vet: + go vet ./... + +## fmt: проверить форматирование (пусто = ок) +fmt: + gofmt -l . + +## lint: vet + fmt +lint: vet fmt + +## clean: удалить артефакты сборки +clean: + rm -f $(BINARY) diff --git a/README.md b/README.md new file mode 100644 index 0000000..16df1ac --- /dev/null +++ b/README.md @@ -0,0 +1,227 @@ +# forge-tools-proxmox + +MCP-сервер для **гипервизора Proxmox VE** (MCP по stdio) — управление +кластером/нодами, QEMU-ВМ, LXC-контейнерами, снапшотами, бэкапами, задачами, +хранилищами и сетью. Зеркало архитектуры `forge-tools/ssh` + `postgres`: +**stdio-only, одиночный потокобезопасный Manager, ручные JSON-Schema, +`--health`, graceful shutdown, `isolation=pooled`** (per-agent конфиг приходит +на каждый вызов через `_tenant_config`). + +> **Границы (принцип «дополнять, а не дублировать»).** Домен — только +> гипервизор. Запуск кода *внутри* гостя — не здесь: это `ssh__run` +> (remote exec); локальные файлы — `filesystem`; БД — `postgres`. +> Управление Ceph/ZFS/SDN/firewall/HA/ACL и восстановление бэкапов — +> кандидаты в отдельные узкие `forge-tools` (см. «Roadmap»). + +## Инструменты (54) + +| Домен | Инструменты | +|---|---| +| **cluster** (4) | `clusters_list`, `cluster_status`, `nodes_list`, `node_status` (read) | +| **node** (1) | `node_network` (read) | +| **VM** (13) | `vms_list`, `vm_describe`, `vm_config`, `vm_next_id` (read) · `vm_start`, `vm_stop`, `vm_reboot`, `vm_shutdown`, `vm_suspend`, `vm_resume`, `vm_clone`, `vm_delete`, `vm_convert_template` (мутации) | +| **VM config/disk/net** (10) | `vm_config_update`, `vm_resize_disk`, `vm_add_disk`, `vm_remove_disk`, `vm_move_disk`, `vm_add_network`, `vm_update_network`, `vm_remove_network`, `vm_set_cloudinit` (мутации) | +| **LXC** (12) | `containers_list`, `container_describe`, `container_config` (read) · `container_start`, `container_stop`, `container_reboot`, `container_shutdown`, `container_clone`, `container_delete`, `container_config_update`, `container_resize` (мутации) | +| **snapshot** (4) | `snapshot_list` (read) · `snapshot_create`, `snapshot_delete`, `snapshot_rollback` (мутации) | +| **backup** (2) | `backup_list` (read) · `backup_create` (async UPID) | +| **task** (4) | `tasks_list`, `task_status` (+wait), `task_log` (read) · `task_stop` (мутация) | +| **guest** (1) | `guest_ips` (read, QEMU guest-agent) | +| **storage** (4) | `storage_list`, `storage_status`, `templates_list`, `isos_list` (read) | + +## Конфигурация + +Путь per-agent `pve.json` (pooled → на каждый вызов ядро инжектит +`_tenant_config`); либо статический `-config` для одиночного режима. + +```jsonc +{ + "hosts": [ + { "alias": "pve1", + "url": "https://10.0.0.10:8006/api2/json", + "token_id": "root@pam!forge-dev", + "token_secret": "${PROXMOX_TOKEN_SECRET}", // из env / gitignored *.local.json + "ca_file": "${PROXMOX_CA_FILE}", // предпочтительно; insecure=true только for dev-lab + "insecure": false, + "allow_nodes": ["pve1"], + "allow_vmids": [500] } + ], + "default": "pve1", + "read_only": true, // false включает мутации (в доп. к confirm+approval) + "timeout_sec": 15, + "task_poll_max_sec": 600, + "max_output_bytes": 4194304 +} +``` + +- **Секреты никогда в git**: `token_secret`/пароль — только `${VAR}` или + gitignored `*.local.json`. В репозитории хранится лишь шаблон + `pve.json.example`; рабочие `pve.json`/`config.json` и `*.local.json` закрыты + локальным [`.gitignore`](.gitignore). +- **`read_only`** (дефолт `true`) и **`allow_nodes`/`allow_vmids`** (per-host) — + fail-closed: без явных `allow_vmids` мутации недостижимы. Это второй слой + поверх least-privilege токена. +- **`ca_file`** — кастомный CA для self-signed кластера; `insecure` — только + для локального dev-lab. +- **Live-reload** через `configreload`: правки `pve.json` применяются без + рестарта (по контент-хэшу). + +### Переменные окружения + +| Переменная | Назначение | +|---|---| +| `FORGE_TENANT_CONFIG` | Каталог с per-agent `pve.json` (режим `isolation=pooled`). | +| `PROXMOX_HOST` | Адрес гипервизора (подставляется в `url`). | +| `PROXMOX_TOKEN_ID` | ID API-токена PVE, напр. `root@pam!forge-dev`. | +| `PROXMOX_TOKEN_SECRET` | Секрет API-токена. | +| `PROXMOX_CA_FILE` | Путь к кастомному CA self-signed кластера. | + +Значения в конфиге поддерживают подстановку `${VAR}` из окружения — реальные +адреса и токены в репозиторий не попадают. + +## Несколько гипервизоров и коллизии VMID + +Каждый `hosts[]` — отдельный кластер со **своими** кредами/TLS и **своими** +правами. **VMID/ноды на разных гипервизорах могут пересекаться**, поэтому +идентичность ресурса — всегда **`(host, node, vmid)`**, а не «голый vmid». + +- **`host`** выбирает целевой гипервизор (aliасс, дефолт = `default`). +- **`allow_vmids`/`allow_nodes` — per-host**: права изолированы между + кластерами. Одни и те же `vmid=100` на pve1 и pve2 дают **разный** вердикт + авторизации (никакого протекания прав). +- **При `len(hosts) > 1` `host` обязателен** для всех инструментов, кроме + `clusters_list` (флот-обзор). Не указан → отказ (fail-closed на + неоднозначность), чтобы случайно не задеть чужой кластер. +- **Глобальный `allowlist` при мульти-гипервизоре — запрещён** (ошибка + конфигурации): права обязаны быть per-host. Для одиночного гипервизора + глобальный `allowlist` остаётся допустимым фолбэком. +- **Probe-паттерны** несут `host=` первым — permission-правила могут + различать кластеры: + ```yaml + patterns: + - match: "proxmox__vm_delete" + pattern: "host=pve1 node=pve vmid=500" + then: ask + ``` +- **Нет флот-агрегатов** по VMID (`clusters_list` даёт карту алиасов, дальше — + вызов с `host`), чтобы не терять принадлежность хоста. + + +## Безопасность (§9.5) + +| Против чего | Защита в модуле | Внешний барьер | +|---|---|---| +| Случайное удаление/rollback/migrate | `confirm="true"`, `read_only`, **per-host** `allow_vmids`/`allow_nodes`, path-валидация | `require_approval` в agent.yaml | +| Коллизия VMID между гипервизорами | `host` обязателен при мульти; авторизация **per-host**; глобальный allowlist запрещён | — | +| Инъекция в URL (node/vmid/snapname/storage/upid) | guard-валидация (`ValidateIdentifier`/`ValidateUPID`) + `url.PathEscape` | least-privilege токен в PVE | +| Изменение конфига вслепую | `DenyConfigKey` (delete/revert/hotplug/spice/...) для update-инструментов | — | +| Внешний доступ | URL объявляет оператор (модель не задаёт хост), кастомный CA, таймаут | — | +| Долгие/необратимые операции | async UPID + `task_status(wait)` (bounded `task_poll_max_sec`) | — | +| Запуск кода в госте | **не реализуется** (зона `ssh__run`) | — | + +**Главный барьер — на стороне PVE**: токен с минимальными правами +(для чтения достаточно `Sys.Audit, VM.Audit, Datastore.Audit`; для мутаций — +только нужное). Модуль держит guard как второй слой (стандарт +двухслойности в §9.5). + +**Probe/approval**: мутации регистрируются через `registerPatternTool` +(эмитятся `host= node= vmid=`), оператор может задать +`patterns`-правила по ресурсу; дефолт permission — `ask`, и всё +деструктивное ещё и в `require_approval`. `always` не эмитим — каждый +опасный вызов отдельный ask. + +## Подключение к агенту + +```jsonc +// config.json ядра +"mcp": { "servers": { + "proxmox": { "command": "./forge-tools/proxmox/forge-tools-proxmox", "args": [], "isolation": "pooled" } +}} +``` + +```yaml +# agent.yaml +mcp_servers: + - proxmox +tools: + require_approval: + - "proxmox__vm_start" # и прочие lifecycle + - "proxmox__vm_delete" + - "proxmox__vm_config_update" + - "proxmox__vm_add_disk" ... "proxmox__vm_move_disk" + - "proxmox__vm_add_network" ... "proxmox__vm_remove_network" + - "proxmox__container_*" + - "proxmox__snapshot_rollback" # + snapshot_delete + - "proxmox__backup_create" + - "proxmox__task_stop" + blocked: [] +# при желании тонкие правила по ресурсу: +permission: + default: ask + patterns: + - match: "proxmox__vm_delete" + pattern: "node=pve vmid=500" + then: ask +``` + +## Сборка и ручной тест (T1) + +```bash +make build # CGO_ENABLED=0, статический бинарник +./forge-tools-proxmox --health # ok +``` + +T1 по stdio: + +```bash +printf '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}\n' \ + | ./forge-tools-proxmox -config /abs/path/pve.json +``` + +`tools/list` → 54 инструмента (27 мутаций с probe-флагом); `node_status` в +`read_only` → данные; `vm_delete` без `confirm`/вне `allow_vmids` → понятный отказ +без обращения к API. + +## Безопасный тест на homelab (не трогая существующие ВМ) + +- Single-node `pve` (10.0.0.10, PVE 8.4.21). Существующие ВМ — 100…304. +- **Тестовая ВМ 500** — клон шаблона **106** (`template.example.com`, server: OVMF, + `scsi0` 16G на `local-nc`, `net0`→`vmbr0`, `agent:1`), через + `proxmox__vm_clone` (newid=500) ИЛИ вручную. +- Dev-конфиг: `"hosts":[{"alias":"pve","allow_nodes":["pve"],"allow_vmids":[500]}]` → модуль + физически не может задеть 100–304. +- `guest_ips` тестируется на клоне 500 (нужен guest-agent, в шаблоне включён). + +## Roadmap (отдельные узкие модули, не сюда) + +- `proxmox-backup` — restore/delete/prune vzdump (необратимые, нужна выверенная + семантика), расписания, retention. +- `proxmox-storage` — Ceph/ZFS-pools, ISO/template upload, disk import. +- `proxmox-net` — bridges/bonds/SDN-VXLAN, HA, firewall, ACL/users. +- Node reboot/shutdown — сознательно НЕ включено (однонодовый blast-radius). + +## Тесты + +```bash +go test ./... -count=1 # unit: политика/guard (без сети и без PVE) +``` +Integration-кейсы (`-run Integration`) — против реального PVE, только на VMID 500. + +--- + +## Разработка + +```bash +make lint # go vet + gofmt -l (пусто = ок) +make test # go test -race ./... +make build +``` + +Требования: Go 1.27+ и доступ к приватному Go-модулю +[`forge-toolkit`](https://git.totmin.ru/en2zmax/forge-toolkit) +(`export GOPRIVATE=git.totmin.ru`). + +--- + +## Лицензия + +Apache-2.0 — см. [`LICENSE`](LICENSE). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..f86f83a --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,52 @@ +# Безопасность + +`forge-tools-proxmox` намеренно устроен так, чтобы AI-ассистент не мог выйти за +рамки, заданные оператором. Если вы нашли способ обойти эти рамки — сообщите, +пожалуйста, команде разработки, не открывая публичный issue. + +## Модель угроз + +- **Хосты и кластеры.** URL гипервизора объявляет оператор в конфиге; модель его + не задаёт. Права изолированы **per-host**: `allow_nodes`/`allow_vmids` у каждого + `hosts[]` свои, поэтому одинаковый `vmid` на разных кластерах даёт разный вердикт. +- **Неоднозначность — отказ.** При `len(hosts) > 1` параметр `host` обязателен для + всех инструментов (кроме `clusters_list`); глобальный allowlist при мульти-хосте + запрещён как ошибка конфигурации. Всё это fail-closed. +- **Учётные данные.** Ассистент работает только с alias хоста; `token_id` и + `token_secret` берутся из конфига (подстановка `${VAR}` или gitignored + `*.local.json`). Произвольные токены/пароли из модели не используются. +- **Мутации.** Дефолт `read_only: true`; без явных `allow_vmids` мутации + недостижимы. Деструктивные операции требуют `confirm="true"` и эмитят + permission-паттерны (`host= node= vmid=`) для внешнего + движка одобрения. `DenyConfigKey` блокирует правку потенциально опасных ключей + конфига ВМ (delete/revert/hotplug/spice/…). +- **Инъекции.** Значения `node`/`vmid`/`snapshot`/`storage`/`upid` проходят + guard-валидацию (`ValidateIdentifier`/`ValidateUPID`) и `url.PathEscape` перед + попаданием в URL API. +- **Таймауты и объёмы.** Настраиваемые `timeout_sec`, `task_poll_max_sec` и + `max_output_bytes` ограничивают время и размер ответа. + +## Границы модуля + +- **Запуск кода внутри гостя не реализуется** — это зона `ssh__run` (remote exec). +- **Восстановление/удаление бэкапов, Ceph/ZFS/SDN/firewall/HA/ACL, node + reboot/shutdown** — сознательно вне модуля (blast-radius и отдельная семантика; + см. Roadmap в README). + +## Известные ограничения + +- **`insecure: true`** отключает проверку TLS-сертификата. Это осознанный + компромисс только для локального dev-lab; в проде используйте `ca_file`. +- **Главный барьер — на стороне PVE.** Токен заводите с минимальными правами + (для чтения достаточно `Sys.Audit, VM.Audit, Datastore.Audit`); guard модуля — + лишь второй слой. + +## Гигиена репозитория + +- Реальные адреса и токены в репозиторий не коммитятся: только + `pve.json.example` с подстановками `${VAR}`. +- Бинарник и локальные конфиги исключены через `.gitignore` (`/forge-tools-proxmox`, + `/pve.json`, `*.local.json`). + +Перед публикацией убедитесь, что токены и рабочие конфиги не попали в репозиторий +(см. [`.gitignore`](.gitignore)). diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f3b33fd --- /dev/null +++ b/go.mod @@ -0,0 +1,20 @@ +module forge-tools-proxmox + +go 1.27.0 + +require ( + git.totmin.ru/en2zmax/forge-toolkit v0.1.0 + github.com/modelcontextprotocol/go-sdk v1.8.0 +) + +require ( + github.com/google/jsonschema-go v0.4.3 // indirect + github.com/segmentio/asm v1.2.1 // indirect + github.com/segmentio/encoding v0.5.4 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + golang.org/x/oauth2 v0.37.0 // indirect + golang.org/x/sync v0.23.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/time v0.16.0 // indirect + golang.org/x/tools v0.49.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..55448f9 --- /dev/null +++ b/go.sum @@ -0,0 +1,26 @@ +git.totmin.ru/en2zmax/forge-toolkit v0.1.0 h1:d4p1mDzPwG/CuehTXVlyeT0vAWkQmzHG83NLmqUxkAo= +git.totmin.ru/en2zmax/forge-toolkit v0.1.0/go.mod h1:4LVtO/yq0SsTP6opDYS32JiDNC/tBBIAwGLGGw/D88Q= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/modelcontextprotocol/go-sdk v1.8.0 h1:KIvahhYqwtbeniWVPs3TcXEA7b8jEtwfBpOTAI+Urx4= +github.com/modelcontextprotocol/go-sdk v1.8.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts= +github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= +github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98= +golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE= +golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s= +golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= +golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= diff --git a/internal/pve/api.go b/internal/pve/api.go new file mode 100644 index 0000000..f6da6b8 --- /dev/null +++ b/internal/pve/api.go @@ -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() +} diff --git a/internal/pve/client.go b/internal/pve/client.go new file mode 100644 index 0000000..5a016a3 --- /dev/null +++ b/internal/pve/client.go @@ -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< 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, "/") +} diff --git a/internal/pve/config_test.go b/internal/pve/config_test.go new file mode 100644 index 0000000..f235a4c --- /dev/null +++ b/internal/pve/config_test.go @@ -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 } diff --git a/internal/pve/manager.go b/internal/pve/manager.go new file mode 100644 index 0000000..f104a8a --- /dev/null +++ b/internal/pve/manager.go @@ -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 = /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 +} diff --git a/internal/tools/backup.go b/internal/tools/backup.go new file mode 100644 index 0000000..4ab9109 --- /dev/null +++ b/internal/tools/backup.go @@ -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 +} diff --git a/internal/tools/cluster.go b/internal/tools/cluster.go new file mode 100644 index 0000000..86d8bcb --- /dev/null +++ b/internal/tools/cluster.go @@ -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 +} diff --git a/internal/tools/guest.go b/internal/tools/guest.go new file mode 100644 index 0000000..4e554fa --- /dev/null +++ b/internal/tools/guest.go @@ -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 +} diff --git a/internal/tools/helpers.go b/internal/tools/helpers.go new file mode 100644 index 0000000..834d074 --- /dev/null +++ b/internal/tools/helpers.go @@ -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) +} diff --git a/internal/tools/lxc.go b/internal/tools/lxc.go new file mode 100644 index 0000000..2080c52 --- /dev/null +++ b/internal/tools/lxc.go @@ -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 +} diff --git a/internal/tools/patterns.go b/internal/tools/patterns.go new file mode 100644 index 0000000..98085ce --- /dev/null +++ b/internal/tools/patterns.go @@ -0,0 +1,40 @@ +package tools + +import ( + "strings" +) + +// patterns.go — доменные forge-probe эмиттеры proxmox-модуля. Механика probe +// (wrapProbe/registerPatternTool/isProbe, флаг поддержки) — в forge-toolkit. +// +// Тулы эмитируют КАНОНИЧЕСКИЙ паттерн ресурса. ВАЖНО: паттерн первым +// компонентом несёт `host=` — только так 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 +} diff --git a/internal/tools/registry.go b/internal/tools/registry.go new file mode 100644 index 0000000..98161e8 --- /dev/null +++ b/internal/tools/registry.go @@ -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) +} diff --git a/internal/tools/snapshot.go b/internal/tools/snapshot.go new file mode 100644 index 0000000..2861d9e --- /dev/null +++ b/internal/tools/snapshot.go @@ -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 +} diff --git a/internal/tools/storage.go b/internal/tools/storage.go new file mode 100644 index 0000000..2a09a21 --- /dev/null +++ b/internal/tools/storage.go @@ -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 +} diff --git a/internal/tools/task.go b/internal/tools/task.go new file mode 100644 index 0000000..1953731 --- /dev/null +++ b/internal/tools/task.go @@ -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 +} diff --git a/internal/tools/toolkit.go b/internal/tools/toolkit.go new file mode 100644 index 0000000..0403cfa --- /dev/null +++ b/internal/tools/toolkit.go @@ -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) +} diff --git a/internal/tools/vm.go b/internal/tools/vm.go new file mode 100644 index 0000000..af4ae0d --- /dev/null +++ b/internal/tools/vm.go @@ -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/ → 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 +} diff --git a/internal/tools/vm_cloudinit.go b/internal/tools/vm_cloudinit.go new file mode 100644 index 0000000..99851c6 --- /dev/null +++ b/internal/tools/vm_cloudinit.go @@ -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 +} diff --git a/internal/tools/vm_config.go b/internal/tools/vm_config.go new file mode 100644 index 0000000..1240249 --- /dev/null +++ b/internal/tools/vm_config.go @@ -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 +} diff --git a/internal/tools/vm_disk.go b/internal/tools/vm_disk.go new file mode 100644 index 0000000..2b26b6e --- /dev/null +++ b/internal/tools/vm_disk.go @@ -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= (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 +} diff --git a/internal/tools/vm_net.go b/internal/tools/vm_net.go new file mode 100644 index 0000000..49d6743 --- /dev/null +++ b/internal/tools/vm_net.go @@ -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 +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..7899872 --- /dev/null +++ b/main.go @@ -0,0 +1,42 @@ +// forge-tools-proxmox — MCP-сервер для управления гипервизором Proxmox VE +// (forge-tools). Покрывает только домен гипервизора: кластер/ноды, QEMU-ВМ, +// LXC-контейнеры, снапшоты, бэкапы, задачи, хранилища, сеть и состояние — +// и НЕ дублирует возможности других модулей (ssh — remote exec, filesystem — +// локальные файлы, postgres — БД). stdio-only, одиночный потокобезопасный +// Manager, ручные JSON-Schema, --health, graceful shutdown, isolation=pooled +// (per-agent конфиг приходит на каждый вызов через _tenant_config). +package main + +import ( + "flag" + "fmt" + "os" + + "forge-tools-proxmox/internal/pve" + "forge-tools-proxmox/internal/tools" + + "git.totmin.ru/en2zmax/forge-toolkit" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func main() { + if toolkit.Health() { + return + } + + // -config: легаси-одиночный режим (постоянный конфиг). Пусто = pooled: + // per-agent конфиг приходит на вызов через _tenant_config (ядро + // перезаписывает ключ, модель подменить не может). + configPath := flag.String("config", "", "путь к pve.json (пусто = pooled через _tenant_config)") + flag.Parse() + + mgr := pve.NewManager(*configPath) + defer mgr.Close() + + if err := toolkit.Run("forge-tools-proxmox", func(s *mcp.Server) { + tools.RegisterAll(s, mgr) + }); err != nil { + fmt.Fprintf(os.Stderr, "forge-tools-proxmox: %v\n", err) + os.Exit(1) + } +} diff --git a/pve.json.example b/pve.json.example new file mode 100644 index 0000000..c526e55 --- /dev/null +++ b/pve.json.example @@ -0,0 +1,19 @@ +{ + "hosts": [ + { + "alias": "pve1", + "url": "https://${PROXMOX_HOST}:8006/api2/json", + "token_id": "${PROXMOX_TOKEN_ID}", + "token_secret": "${PROXMOX_TOKEN_SECRET}", + "ca_file": "${PROXMOX_CA_FILE}", + "insecure": false, + "allow_nodes": ["pve1"], + "allow_vmids": [500] + } + ], + "default": "pve1", + "read_only": true, + "timeout_sec": 15, + "task_poll_max_sec": 600, + "max_output_bytes": 4194304 +}