Initial commit: forge-tools-proxmox — MCP-сервер для Proxmox VE

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