Initial commit: forge-tools-proxmox — MCP-сервер для Proxmox VE
This commit is contained in:
+18
@@ -0,0 +1,18 @@
|
||||
# Собранный бинарник
|
||||
/forge-tools-proxmox
|
||||
*.exe
|
||||
|
||||
# Резервные копии от редактора файлов
|
||||
*.bak
|
||||
|
||||
# Локальные конфиги с реальными адресами и токенами.
|
||||
# В репозитории хранится только pve.json.example.
|
||||
/pve.json
|
||||
/config.json
|
||||
*.local.json
|
||||
|
||||
# Редакторы и ОС
|
||||
.DS_Store
|
||||
*.swp
|
||||
.idea/
|
||||
.vscode/
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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
@@ -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)).
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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, "/")
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user