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