Initial commit: forge-tools-ssh — MCP-сервер для администрирования по SSH
This commit is contained in:
+21
@@ -0,0 +1,21 @@
|
|||||||
|
# Собранный бинарник
|
||||||
|
/forge-tools-ssh
|
||||||
|
*.exe
|
||||||
|
|
||||||
|
# Локальные данные: SSH-ключи и прочие секреты
|
||||||
|
/data/
|
||||||
|
|
||||||
|
# Резервные копии от редактора файлов
|
||||||
|
*.bak
|
||||||
|
|
||||||
|
# Локальные конфиги с реальными адресами и ключами.
|
||||||
|
# В репозитории хранится только ssh.json.example.
|
||||||
|
/ssh.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 [yyyy] [name of copyright owner]
|
||||||
|
|
||||||
|
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-ssh
|
||||||
|
|
||||||
|
.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,294 @@
|
|||||||
|
# forge-tools-ssh
|
||||||
|
|
||||||
|
> MCP-сервер, который даёт AI-ассистенту аккуратный доступ к серверам по SSH.
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
**forge-tools-ssh** — это небольшая отдельная программа, которая превращает
|
||||||
|
обычный SSH в набор инструментов, понятных AI-ассистенту. Ассистент вызывает
|
||||||
|
инструмент по имени — `run`, `disk_usage`, `docker_ps`, `journal_read` — и
|
||||||
|
получает готовый результат, не выдумывая каждый раз синтаксис `ssh`, `grep`,
|
||||||
|
`docker` и `df`.
|
||||||
|
|
||||||
|
Типичные задачи: посмотреть состояние сервера, найти причину падения сервиса,
|
||||||
|
прочитать лог, проверить занятое место, разобраться с контейнерами или БД, а
|
||||||
|
при явном разрешении — отредактировать конфиг и перезапустить службу.
|
||||||
|
|
||||||
|
> ### Коротко про MCP
|
||||||
|
> [MCP](https://modelcontextprotocol.io) — стандарт подключения AI-ассистентов
|
||||||
|
> к инструментам. Сервер — это программа, которая читает запросы ассистента
|
||||||
|
> через стандартные ввод/вывод и возвращает результат. Один и тот же сервер
|
||||||
|
> работает с любым MCP-клиентом.
|
||||||
|
|
||||||
|
Модуль намеренно **не содержит адресов, логинов и ключей**: всё, к чему можно
|
||||||
|
подключиться, объявляет оператор. Ассистент лишь выбирает из разрешённого.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Возможности
|
||||||
|
|
||||||
|
- **45 инструментов** — от запуска команды до разбора SIP-трафика.
|
||||||
|
- **Подключение через джамп-хост** — доступ к закрытому контуру через bastion.
|
||||||
|
- **PAM-шлюзы** — двухстадийная аутентификация через корпоративный шлюз.
|
||||||
|
- **Проверка синтаксиса** JSON/YAML/TOML/XML/INI/ENV/Dockerfile **до** записи
|
||||||
|
файла — на самом сервере MCP, без зависимостей на удалённой машине.
|
||||||
|
- **Политика подключений** (`ssh.json`): именованные профили и allowlist хостов,
|
||||||
|
которые оператор задаёт декларативно. Ошибка конфига — отказ, а не тихий обход.
|
||||||
|
- **Live-reload**: правки `ssh.json` подхватываются без перезапуска.
|
||||||
|
- **Устойчивость**: перезапуск связи при обрыве, таймауты, лимиты на размер и
|
||||||
|
длину вывода, корректная отмена задач.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Архитектура
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
A["AI-ассистент<br/>(MCP-клиент)"] -- "stdio · JSON-RPC" --> B["forge-tools-ssh"]
|
||||||
|
B -- "SSH / SFTP" --> C["Сервер"]
|
||||||
|
B -- "SSH → bastion" --> D["Сервер в закрытом контуре"]
|
||||||
|
B -- "SSH → PAM-шлюз" --> E["Целевой сервер"]
|
||||||
|
|
||||||
|
style B fill:#6E56CF,color:#fff
|
||||||
|
```
|
||||||
|
|
||||||
|
Сервер — stdio-процесс: он не открывает портов и не слушает сеть. Общение с
|
||||||
|
AI-клиентом идёт через потоки ввода/вывода, а наружу он ходит только по SSH.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Быстрый старт
|
||||||
|
|
||||||
|
### Сборка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export GOPRIVATE=git.totmin.ru
|
||||||
|
go build -trimpath -o forge-tools-ssh .
|
||||||
|
```
|
||||||
|
|
||||||
|
Проверить, что бинарник живой:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./forge-tools-ssh --health # → ok
|
||||||
|
```
|
||||||
|
|
||||||
|
### Подключение к AI-клиенту
|
||||||
|
|
||||||
|
Сервер запускается клиентом как дочерний процесс. Пример конфигурации:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcp": {
|
||||||
|
"servers": {
|
||||||
|
"ssh": {
|
||||||
|
"command": "/opt/forge-tools/ssh/forge-tools-ssh",
|
||||||
|
"env": {
|
||||||
|
"FORGE_TENANT_CONFIG": "/etc/forge/agents/my-agent",
|
||||||
|
"SSH_MCP_KEY_PATH": "/etc/forge/agents/my-agent/ssh/id_ed25519"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Дальше достаточно попросить ассистента, например: «посмотри, почему упал nginx на
|
||||||
|
`prod-web`, и покажи последние 100 строк журнала».
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Инструменты (45)
|
||||||
|
|
||||||
|
### Ядро — 5
|
||||||
|
|
||||||
|
| Инструмент | Назначение |
|
||||||
|
|---|---|
|
||||||
|
| `connect` | Установить SSH-соединение (по профилю или ad-hoc в рамках allowlist) |
|
||||||
|
| `disconnect` | Закрыть соединение (или все сразу) |
|
||||||
|
| `run` | Выполнить команду на удалённом хосте |
|
||||||
|
| `identity` | Показать публичный ключ сервера для `authorized_keys` |
|
||||||
|
| `info` | ОС, ядро, hostname, архитектура |
|
||||||
|
|
||||||
|
### Файлы — 6
|
||||||
|
|
||||||
|
| Инструмент | Назначение |
|
||||||
|
|---|---|
|
||||||
|
| `read` | Прочитать файл (лимит 10 МБ) |
|
||||||
|
| `write` | Записать файл с валидацией синтаксиса перед записью |
|
||||||
|
| `edit` | Правка файла: replace / regex / insert / append / prepend / delete / replace_line |
|
||||||
|
| `validate` | Проверить синтаксис файла на стороне MCP-сервера |
|
||||||
|
| `list_dir` | Список каталога |
|
||||||
|
| `sync` | Потоковая передача файла между двумя хостами |
|
||||||
|
|
||||||
|
### Мониторинг — 7
|
||||||
|
|
||||||
|
| Инструмент | Назначение |
|
||||||
|
|---|---|
|
||||||
|
| `usage` | Загрузка, память, диски |
|
||||||
|
| `ps` | Топ процессов по CPU или памяти |
|
||||||
|
| `logs` | Хвост лог-файла (опционально с фильтром) |
|
||||||
|
| `journal_read` | `journalctl` / syslog |
|
||||||
|
| `dmesg_read` | Кольцевой буфер ядра |
|
||||||
|
| `diagnose_system` | Экспресс-диагностика: load, OOM, диски, упавшие службы |
|
||||||
|
| `list_services` | Список служб (systemd / OpenRC и др.) |
|
||||||
|
|
||||||
|
### Диск — 2
|
||||||
|
|
||||||
|
| Инструмент | Назначение |
|
||||||
|
|---|---|
|
||||||
|
| `disk_usage` | Место на томе, содержащем указанный путь |
|
||||||
|
| `disk_usage_all` | Все точки монтирования, разделы ≥ 80% помечены |
|
||||||
|
|
||||||
|
### Сеть и поиск — 4
|
||||||
|
|
||||||
|
| Инструмент | Назначение |
|
||||||
|
|---|---|
|
||||||
|
| `net_stat` | Слушающие порты (`ss` / `netstat`) |
|
||||||
|
| `search_files` | Поиск файлов (`find`) |
|
||||||
|
| `search_text` | Поиск текста (`grep`) |
|
||||||
|
| `package_manage` | Пакеты: apt / apk / dnf / yum |
|
||||||
|
|
||||||
|
### Docker — 8
|
||||||
|
|
||||||
|
| Инструмент | Назначение |
|
||||||
|
|---|---|
|
||||||
|
| `docker_ps` | Список контейнеров |
|
||||||
|
| `docker_logs` | Логи контейнера |
|
||||||
|
| `docker_op` | start / stop / restart |
|
||||||
|
| `docker_ip` | IP-адреса контейнера |
|
||||||
|
| `docker_find_by_ip` | Найти контейнер по IP |
|
||||||
|
| `docker_networks` | Сети Docker |
|
||||||
|
| `docker_cp_from` | Копировать файл из контейнера на хост |
|
||||||
|
| `docker_cp_to` | Копировать файл с хоста в контейнер |
|
||||||
|
|
||||||
|
### Базы данных — 3
|
||||||
|
|
||||||
|
| Инструмент | Назначение |
|
||||||
|
|---|---|
|
||||||
|
| `db_query` | SQL/CQL/Mongo-запрос внутри контейнера (postgres, mysql, scylladb, cassandra, mongodb) |
|
||||||
|
| `db_schema` | Схема: таблицы / коллекции |
|
||||||
|
| `list_db_containers` | Найти контейнеры, похожие на БД |
|
||||||
|
|
||||||
|
### VoIP — 10
|
||||||
|
|
||||||
|
| Инструмент | Назначение |
|
||||||
|
|---|---|
|
||||||
|
| `voip_discover_containers` | Найти VoIP-контейнеры по имени/образу |
|
||||||
|
| `voip_sip_capture` | Захват SIP-сигналинга в PCAP (`sngrep`) |
|
||||||
|
| `voip_call_flow` | Разбор SIP call flow из PCAP |
|
||||||
|
| `voip_registrations` | REGISTER-диалоги и их исход |
|
||||||
|
| `voip_call_stats` | Агрегированная статистика вызовов |
|
||||||
|
| `voip_extract_sdp` | Кодеки и RTP-порты из SDP |
|
||||||
|
| `voip_packet_check` | Быстрая проверка наличия SIP-пакетов |
|
||||||
|
| `voip_network_capture` | Захват SIP через `tcpdump` |
|
||||||
|
| `voip_rtp_capture` | Захват RTP для проверки медиа-потока |
|
||||||
|
| `voip_network_diagnostics` | ping / traceroute / проверка TCP-портов |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Конфигурация
|
||||||
|
|
||||||
|
Доступы задаются декларативно в `<FORGE_TENANT_CONFIG>/ssh.json`. Оператор
|
||||||
|
описывает профили (что и как подключать) и allowlist хостов; ассистент работает
|
||||||
|
только с этими именами. Файл перечитывается на лету при изменении.
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
// Ключ по умолчанию, если профиль или вызов не задали свой.
|
||||||
|
"default_key_path": "${SSH_KEY_PATH}",
|
||||||
|
|
||||||
|
// Glob-паттерны разрешённых хостов. Наличие списка включает проверку,
|
||||||
|
// пустой список — ad-hoc без ограничений. Держите список непустым.
|
||||||
|
"allowed_hosts": ["10.0.*", "*.internal.example.com"],
|
||||||
|
|
||||||
|
"profiles": [
|
||||||
|
{
|
||||||
|
"alias": "prod-web",
|
||||||
|
"host": "10.0.1.10",
|
||||||
|
"username": "deploy",
|
||||||
|
"port": 22,
|
||||||
|
"key": "${PROD_SSH_KEY}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": "bastion",
|
||||||
|
"host": "bastion.example.com",
|
||||||
|
"username": "deploy"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": "db-via-pam",
|
||||||
|
"host": "pam-gateway.example.com",
|
||||||
|
"username": "operator",
|
||||||
|
"target": "10.0.2.20",
|
||||||
|
"target_password": "${DB_TARGET_PASSWORD}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Готовый шаблон — [`ssh.json.example`](ssh.json.example).
|
||||||
|
|
||||||
|
| Поле профиля | Смысл |
|
||||||
|
|---|---|
|
||||||
|
| `alias` | Имя профиля, на которое ссылается ассистент |
|
||||||
|
| `host` | Хост или IP |
|
||||||
|
| `username` | Пользователь SSH |
|
||||||
|
| `port` | Порт (по умолчанию `22`) |
|
||||||
|
| `key` | Приватный ключ (иначе `default_key_path`, иначе системный ключ) |
|
||||||
|
| `via` | Алиас jump-хоста, через который подключаться |
|
||||||
|
| `target` | Хост за PAM-шлюзом (SafeInspect): шлюзу отправляется `username@target` |
|
||||||
|
| `target_password` | Пароль целевого аккаунта для второй стадии аутентификации PAM |
|
||||||
|
|
||||||
|
### Переменные окружения
|
||||||
|
|
||||||
|
| Переменная | Назначение |
|
||||||
|
|---|---|
|
||||||
|
| `FORGE_TENANT_CONFIG` | Каталог с `ssh.json`. Если не задан — режим без политики. |
|
||||||
|
| `SSH_MCP_KEY_PATH` | Путь к системному приватному ключу Ed25519. |
|
||||||
|
|
||||||
|
Значения в конфиге поддерживают подстановку `${VAR}` из окружения — реальные
|
||||||
|
адреса и ключи в репозиторий не попадают.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Безопасность
|
||||||
|
|
||||||
|
- **Проваливается закрытым (fail-closed).** Если задан `allowed_hosts`, любой
|
||||||
|
хост вне списка получает отказ. Битый `ssh.json` — тоже отказ.
|
||||||
|
- **Креды задаёт оператор, а не модель.** Ассистент выбирает профиль по имени и
|
||||||
|
не может придумать произвольный хост, ключ или адрес.
|
||||||
|
- **Валидация вместо порчи.** `write` проверяет синтаксис перед записью, `edit`
|
||||||
|
— после правки, и предупреждает, если файл сломан.
|
||||||
|
- **Экранирование.** Все пользовательские строки, попадающие в shell-команды,
|
||||||
|
проходят кавычивание и санитизацию.
|
||||||
|
- **Лимиты.** Таймауты команд, ограничение размера файла и длины вывода,
|
||||||
|
отдельный лимит на чтение.
|
||||||
|
- **Ключи.** Каталог ключей создаётся с правами `0700`, приватный ключ — `0600`.
|
||||||
|
- **Осторожно:** host key удалённого сервера не проверяется
|
||||||
|
(`InsecureIgnoreHostKey`). Это осознанный компромисс для работы с динамической
|
||||||
|
инфраструктурой — за целостность канала отвечает сеть.
|
||||||
|
|
||||||
|
Перед публикацией убедитесь, что реальные ключи и конфиги с секретами не попали
|
||||||
|
в репозиторий (см. [`.gitignore`](.gitignore)).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Разработка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go build ./... && go vet ./...
|
||||||
|
go test -race ./...
|
||||||
|
gofmt -l . # должно быть пусто
|
||||||
|
```
|
||||||
|
|
||||||
|
Требования: Go 1.27+ и доступ к приватному Go-модулю
|
||||||
|
[`forge-toolkit`](https://git.totmin.ru/en2zmax/forge-toolkit)
|
||||||
|
(`export GOPRIVATE=git.totmin.ru`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Лицензия
|
||||||
|
|
||||||
|
Apache-2.0 — см. [`LICENSE`](LICENSE).
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
# Безопасность
|
||||||
|
|
||||||
|
`forge-tools-ssh` намеренно устроен так, чтобы AI-ассистент не мог выйти за
|
||||||
|
рамки, заданные оператором. Если вы нашли способ обойти эти рамки — сообщите,
|
||||||
|
пожалуйста, команде разработки, не открывая публичный issue.
|
||||||
|
|
||||||
|
## Модель угроз
|
||||||
|
|
||||||
|
- **Хосты.** При заданном `allowed_hosts` подключение к любому хосту вне списка
|
||||||
|
отклоняется. Отсутствие или порча конфига не открывают доступ.
|
||||||
|
- **Учётные данные.** Ассистент передаёт только имя профиля; хост, ключ и адрес
|
||||||
|
берутся из конфига. Произвольные ключи и пароли из модели не используются.
|
||||||
|
- **Команды.** Пользовательские значения экранируются перед попаданием в shell.
|
||||||
|
Инструмент `run` эмитит permission-паттерны для внешнего движка одобрения.
|
||||||
|
- **Файлы.** Запись проверяет синтаксис для известных форматов; чтение ограничено
|
||||||
|
по размеру.
|
||||||
|
|
||||||
|
## Известные ограничения
|
||||||
|
|
||||||
|
- **Host key не проверяется** (`ssh.InsecureIgnoreHostKey`). Канал не защищён от
|
||||||
|
подмены сервера на уровне SSH. Рассчитано на доверенную сеть; при
|
||||||
|
необходимости включайте строгую проверку host key на уровне инфраструктуры.
|
||||||
|
- **Путь внутри удалённой системы не ограничивается**: границей служат права
|
||||||
|
Unix-пользователя, под которым выполнено подключение. Заводите отдельного
|
||||||
|
пользователя с минимально необходимыми правами.
|
||||||
|
|
||||||
|
## Гигиена репозитория
|
||||||
|
|
||||||
|
- Реальные ключи, адреса и пароли в репозиторий не коммитятся: только
|
||||||
|
`ssh.json.example` с подстановками `${VAR}`.
|
||||||
|
- Приватные ключи и рабочие данные исключены через `.gitignore` (`/data/`).
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
module forge-tools-ssh
|
||||||
|
|
||||||
|
go 1.27.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/BurntSushi/toml v1.6.0 // indirect
|
||||||
|
github.com/google/jsonschema-go v0.4.3 // indirect
|
||||||
|
github.com/kr/fs v0.1.0 // 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
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
git.totmin.ru/en2zmax/forge-toolkit v0.1.0
|
||||||
|
github.com/modelcontextprotocol/go-sdk v1.8.0
|
||||||
|
github.com/pkg/sftp v1.13.11
|
||||||
|
golang.org/x/crypto v0.57.0
|
||||||
|
)
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
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/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||||
|
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
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/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
|
||||||
|
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
|
||||||
|
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/pkg/sftp v1.13.11 h1:0N92SLTB8JqASJB14ZLHHzFnBV8mG9zw4K7jghEFWuE=
|
||||||
|
github.com/pkg/sftp v1.13.11/go.mod h1:uNkH9roSXglNJqM+glJJi+TQXQUm0fXFWqCFmT8hsN0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
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/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
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/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||||
|
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||||
|
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/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
|
||||||
|
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
|
||||||
|
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=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
package ssh
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pkg/sftp"
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client represents a single SSH connection with state tracking.
|
||||||
|
type Client struct {
|
||||||
|
alias string
|
||||||
|
conn *ssh.Client
|
||||||
|
sftp *sftp.Client
|
||||||
|
cwd string
|
||||||
|
mu sync.Mutex
|
||||||
|
creds Credentials
|
||||||
|
}
|
||||||
|
|
||||||
|
// Credentials holds SSH connection parameters.
|
||||||
|
type Credentials struct {
|
||||||
|
Host string
|
||||||
|
Port int
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
PrivateKey ssh.Signer
|
||||||
|
Via string
|
||||||
|
|
||||||
|
// Target is an optional destination host routed through a PAM gateway.
|
||||||
|
// When set, the SSH username sent to the gateway becomes
|
||||||
|
// "Username@Target" and authentication switches to multi-stage
|
||||||
|
// keyboard-interactive.
|
||||||
|
Target string
|
||||||
|
// TargetPassword is the password of the Target server account,
|
||||||
|
// requested by the PAM gateway as a second authentication stage.
|
||||||
|
TargetPassword string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient creates a new SSH client.
|
||||||
|
func NewClient(ctx context.Context, alias string, creds Credentials, jumpClient *Client) (*Client, error) {
|
||||||
|
client := &Client{
|
||||||
|
alias: alias,
|
||||||
|
creds: creds,
|
||||||
|
cwd: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := client.connect(ctx, jumpClient); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// connect establishes the SSH connection. ctx отменяем: уважается и при
|
||||||
|
// TCP-dial, и при пробе «pwd» (иначе полу-готовый хост, принявший TCP, но
|
||||||
|
// не отдающий шелл, мог бы подвесить вызов без срока — см. исходный
|
||||||
|
// зависонный кейс).
|
||||||
|
func (c *Client) connect(ctx context.Context, jumpClient *Client) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close stale SFTP client before closing the connection
|
||||||
|
if c.sftp != nil {
|
||||||
|
c.sftp.Close()
|
||||||
|
c.sftp = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.conn != nil {
|
||||||
|
c.conn.Close()
|
||||||
|
c.conn = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PAM gateway routing: the gateway parses "user@target" from the SSH
|
||||||
|
// username and proxies the session to the target host. The plain
|
||||||
|
// username is kept in Credentials for reconnect and logging.
|
||||||
|
user := c.creds.Username
|
||||||
|
if c.creds.Target != "" {
|
||||||
|
user = fmt.Sprintf("%s@%s", c.creds.Username, c.creds.Target)
|
||||||
|
}
|
||||||
|
|
||||||
|
config := &ssh.ClientConfig{
|
||||||
|
User: user,
|
||||||
|
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
var authMethods []ssh.AuthMethod
|
||||||
|
if c.creds.PrivateKey != nil {
|
||||||
|
authMethods = append(authMethods, ssh.PublicKeys(c.creds.PrivateKey))
|
||||||
|
}
|
||||||
|
if c.creds.Target != "" {
|
||||||
|
// SafeInspect PAM authenticates in two stages:
|
||||||
|
// 1. keyboard-interactive with the PAM gateway password (the server
|
||||||
|
// replies with partial success),
|
||||||
|
// 2. the standard "password" method with the target server password.
|
||||||
|
// The Go SSH client tries each AuthMethod in order on partial success,
|
||||||
|
// so we supply both methods in the order above.
|
||||||
|
authMethods = append(authMethods, pamKeyboardInteractive(c.creds))
|
||||||
|
if c.creds.TargetPassword != "" {
|
||||||
|
authMethods = append(authMethods, ssh.Password(c.creds.TargetPassword))
|
||||||
|
}
|
||||||
|
} else if c.creds.Password != "" {
|
||||||
|
authMethods = append(authMethods, ssh.Password(c.creds.Password))
|
||||||
|
}
|
||||||
|
if len(authMethods) == 0 {
|
||||||
|
return errors.New("no authentication method provided (key or password required)")
|
||||||
|
}
|
||||||
|
config.Auth = authMethods
|
||||||
|
|
||||||
|
addr := fmt.Sprintf("%s:%d", c.creds.Host, c.creds.Port)
|
||||||
|
|
||||||
|
var conn *ssh.Client
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if jumpClient != nil {
|
||||||
|
jumpConn := jumpClient.conn
|
||||||
|
if jumpConn == nil {
|
||||||
|
return errors.New("jump host not connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
netConn, err := dialWithCtx(ctx, func() (net.Conn, error) {
|
||||||
|
return jumpConn.Dial("tcp", addr)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to dial through jump host: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ncc, chans, reqs, err := ssh.NewClientConn(netConn, addr, config)
|
||||||
|
if err != nil {
|
||||||
|
netConn.Close()
|
||||||
|
return fmt.Errorf("failed to create client connection through jump: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
conn = ssh.NewClient(ncc, chans, reqs)
|
||||||
|
} else {
|
||||||
|
// net.Dialer.DialContext вместо ssh.Dial: уважает ctx (отмену
|
||||||
|
// задачи), сохраняя 30-секундный лимит на установку соединения.
|
||||||
|
raw, err := (&net.Dialer{Timeout: 30 * time.Second}).DialContext(ctx, "tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to connect: %w", err)
|
||||||
|
}
|
||||||
|
ncc, chans, reqs, err := ssh.NewClientConn(raw, addr, config)
|
||||||
|
if err != nil {
|
||||||
|
raw.Close()
|
||||||
|
return fmt.Errorf("failed to create client connection: %w", err)
|
||||||
|
}
|
||||||
|
conn = ssh.NewClient(ncc, chans, reqs)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.conn = conn
|
||||||
|
|
||||||
|
// Проба «pwd» с дедлайном: соединение установлено, но хост может не
|
||||||
|
// отдать шелл - ограничиваем ожидание, чтобы не подвесить вызов.
|
||||||
|
probeCtx, probeCancel := context.WithTimeout(ctx, 15*time.Second)
|
||||||
|
defer probeCancel()
|
||||||
|
output, err := c.runRaw(probeCtx, "pwd")
|
||||||
|
if err != nil {
|
||||||
|
c.cwd = "~"
|
||||||
|
} else {
|
||||||
|
c.cwd = strings.TrimSpace(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("ssh: connected %s@%s (%s)", c.creds.Username, c.creds.Host, c.alias)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// dialWithCtx выполняет сетевой dial с уважением к отмене ctx: если ctx
|
||||||
|
// отменён раньше, чем dial вернул соединение, канал разрывается. jump-
|
||||||
|
// диал не имеет ctx-версии, поэтому оборачиваем в select.
|
||||||
|
func dialWithCtx(ctx context.Context, dial func() (net.Conn, error)) (net.Conn, error) {
|
||||||
|
type res struct {
|
||||||
|
conn net.Conn
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
ch := make(chan res, 1)
|
||||||
|
go func() {
|
||||||
|
c, err := dial()
|
||||||
|
ch <- res{c, err}
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
case r := <-ch:
|
||||||
|
return r.conn, r.err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pamKeyboardInteractive returns an ssh.AuthMethod for the first stage of a
|
||||||
|
// SafeInspect PAM gateway. The gateway presents a keyboard-interactive
|
||||||
|
// challenge asking for the PAM user's password; after the server signals
|
||||||
|
// partial success, the target server password is supplied by the following
|
||||||
|
// "password" AuthMethod. Challenge rounds that carry instructions only (no
|
||||||
|
// questions) are answered with an empty response.
|
||||||
|
func pamKeyboardInteractive(creds Credentials) ssh.AuthMethod {
|
||||||
|
return ssh.KeyboardInteractive(func(user, instruction string, questions []string, echos []bool) ([]string, error) {
|
||||||
|
if len(questions) == 0 {
|
||||||
|
return []string{}, nil
|
||||||
|
}
|
||||||
|
answers := make([]string, len(questions))
|
||||||
|
for i := range answers {
|
||||||
|
answers[i] = creds.Password
|
||||||
|
}
|
||||||
|
return answers, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// runRaw executes a command without CWD handling. Команда ограничена ctx
|
||||||
|
// (дедлайном): уважается при отмене задачи/превышении лимита, иначе
|
||||||
|
// полу-ответивший хост мог бы подвесить соединение без срока.
|
||||||
|
func (c *Client) runRaw(ctx context.Context, cmd string) (string, error) {
|
||||||
|
session, err := c.conn.NewSession()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create session: %w", err)
|
||||||
|
}
|
||||||
|
defer session.Close()
|
||||||
|
|
||||||
|
type out struct {
|
||||||
|
s string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
ch := make(chan out, 1)
|
||||||
|
go func() {
|
||||||
|
o, e := session.CombinedOutput(cmd)
|
||||||
|
ch <- out{string(o), e}
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
_ = session.Close() // разрывает блокирующий вызов в горутине
|
||||||
|
return "", ctx.Err()
|
||||||
|
case r := <-ch:
|
||||||
|
return r.s, r.err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run executes a command with CWD tracking.
|
||||||
|
func (c *Client) Run(ctx context.Context, cmd string) (*RunResult, error) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
if c.conn == nil {
|
||||||
|
return nil, errors.New("not connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
delimiter := fmt.Sprintf("___MCP_PWD_%d___", time.Now().UnixNano())
|
||||||
|
wrappedCmd := fmt.Sprintf(
|
||||||
|
`cd %q && %s; __EXIT__=$?; echo ""; echo "%s"; pwd; exit $__EXIT__`,
|
||||||
|
c.cwd, strings.TrimRight(cmd, " \t\r\n"), delimiter,
|
||||||
|
)
|
||||||
|
|
||||||
|
session, err := c.conn.NewSession()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create session: %w", err)
|
||||||
|
}
|
||||||
|
defer session.Close()
|
||||||
|
|
||||||
|
stdout, _ := session.StdoutPipe()
|
||||||
|
stderr, _ := session.StderrPipe()
|
||||||
|
|
||||||
|
if err := session.Start(wrappedCmd); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to start command: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
type readResult struct {
|
||||||
|
stdout []byte
|
||||||
|
stderr []byte
|
||||||
|
}
|
||||||
|
resultChan := make(chan readResult, 1)
|
||||||
|
|
||||||
|
const maxStdout = 10 * 1024 * 1024 // 10 MB
|
||||||
|
const maxStderr = 1 * 1024 * 1024 // 1 MB
|
||||||
|
go func() {
|
||||||
|
stdoutBytes, _ := io.ReadAll(io.LimitReader(stdout, maxStdout))
|
||||||
|
stderrBytes, _ := io.ReadAll(io.LimitReader(stderr, maxStderr))
|
||||||
|
resultChan <- readResult{stdout: stdoutBytes, stderr: stderrBytes}
|
||||||
|
}()
|
||||||
|
|
||||||
|
var res readResult
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
_ = session.Signal(ssh.SIGKILL)
|
||||||
|
_ = session.Close() // Unblock io.ReadAll by closing pipes
|
||||||
|
// Wait briefly for the reader goroutine to finish
|
||||||
|
select {
|
||||||
|
case <-resultChan:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
}
|
||||||
|
return nil, ctx.Err()
|
||||||
|
case res = <-resultChan:
|
||||||
|
}
|
||||||
|
|
||||||
|
var exitCode int
|
||||||
|
if err := session.Wait(); err != nil {
|
||||||
|
if exitErr, ok := err.(*ssh.ExitError); ok {
|
||||||
|
exitCode = exitErr.ExitStatus()
|
||||||
|
} else {
|
||||||
|
return nil, fmt.Errorf("command failed: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stdoutStr := string(res.stdout)
|
||||||
|
cleanOutput := stdoutStr
|
||||||
|
if idx := strings.Index(stdoutStr, delimiter); idx != -1 {
|
||||||
|
cleanOutput = stdoutStr[:idx]
|
||||||
|
remaining := strings.TrimSpace(stdoutStr[idx+len(delimiter):])
|
||||||
|
if remaining != "" {
|
||||||
|
c.cwd = remaining
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &RunResult{
|
||||||
|
Stdout: strings.TrimSpace(cleanOutput),
|
||||||
|
Stderr: strings.TrimSpace(string(res.stderr)),
|
||||||
|
ExitCode: exitCode,
|
||||||
|
CWD: c.cwd,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunResult contains command execution result.
|
||||||
|
type RunResult struct {
|
||||||
|
Stdout string
|
||||||
|
Stderr string
|
||||||
|
ExitCode int
|
||||||
|
CWD string
|
||||||
|
}
|
||||||
|
|
||||||
|
// SFTP returns the SFTP client.
|
||||||
|
func (c *Client) SFTP() (*sftp.Client, error) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
if c.conn == nil {
|
||||||
|
return nil, errors.New("not connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.sftp != nil {
|
||||||
|
return c.sftp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
sftpClient, err := sftp.NewClient(c.conn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create SFTP client: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.sftp = sftpClient
|
||||||
|
return c.sftp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the connection.
|
||||||
|
func (c *Client) Close() error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
if c.sftp != nil {
|
||||||
|
c.sftp.Close()
|
||||||
|
c.sftp = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.conn != nil {
|
||||||
|
err := c.conn.Close()
|
||||||
|
c.conn = nil
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alias returns the connection alias.
|
||||||
|
func (c *Client) Alias() string {
|
||||||
|
return c.alias
|
||||||
|
}
|
||||||
|
|
||||||
|
// CWD returns the current working directory.
|
||||||
|
func (c *Client) CWD() string {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
return c.cwd
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconnect attempts to reconnect.
|
||||||
|
func (c *Client) Reconnect(ctx context.Context, jumpClient *Client) error {
|
||||||
|
log.Printf("ssh: reconnecting %s", c.alias)
|
||||||
|
return c.connect(ctx, jumpClient)
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
// Package ssh provides SSH connection management for the MCP server.
|
||||||
|
package ssh
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/pem"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// DevKeyPath - локальный путь по умолчанию для запуска вне forge
|
||||||
|
// (без SSH_MCP_KEY_PATH). Производственный запуск всегда передаёт
|
||||||
|
// SSH_MCP_KEY_PATH от forge (см. cmd/serve/wiring.go): либо системный
|
||||||
|
// ключ data/_system/ssh/id_ed25519, либо пер-агентный ssh_key_dir.
|
||||||
|
DevKeyPath = "./data/_system/ssh/id_ed25519"
|
||||||
|
)
|
||||||
|
|
||||||
|
// KeyManager handles SSH key generation and loading.
|
||||||
|
type KeyManager struct {
|
||||||
|
keyPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewKeyManager creates a new KeyManager. If keyPath is empty, the system
|
||||||
|
// default is used (SSH_MCP_KEY_PATH env, otherwise local ./data/_system/ssh).
|
||||||
|
func NewKeyManager(keyPath string) *KeyManager {
|
||||||
|
if keyPath == "" {
|
||||||
|
keyPath = getDefaultKeyPath()
|
||||||
|
}
|
||||||
|
return &KeyManager{keyPath: keyPath}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getDefaultKeyPath returns the appropriate path. Honors SSH_MCP_KEY_PATH if
|
||||||
|
// set (forge всегда так делает); иначе локальный дефолт под единой схемой
|
||||||
|
// данных (data/_system/ssh). Автодетект окружения убран: никаких жёстко
|
||||||
|
// зашитых /data - путь всегда приходит от оператора через env.
|
||||||
|
func getDefaultKeyPath() string {
|
||||||
|
if p := os.Getenv("SSH_MCP_KEY_PATH"); p != "" {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
return DevKeyPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureKey ensures the system key exists, generating if necessary.
|
||||||
|
func (km *KeyManager) EnsureKey() error {
|
||||||
|
keyDir := filepath.Dir(km.keyPath)
|
||||||
|
|
||||||
|
// Check if directory exists
|
||||||
|
stat, err := os.Stat(keyDir)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
// Directory doesn't exist - create it (0700; единственные личные данные).
|
||||||
|
if err := os.MkdirAll(keyDir, 0700); err != nil {
|
||||||
|
return fmt.Errorf("failed to create key directory %s: %w", keyDir, err)
|
||||||
|
}
|
||||||
|
log.Printf("ssh-key: created directory %s", keyDir)
|
||||||
|
} else if err != nil {
|
||||||
|
return fmt.Errorf("failed to access key directory %s: %w", keyDir, err)
|
||||||
|
} else if !stat.IsDir() {
|
||||||
|
return fmt.Errorf("key path %s exists but is not a directory", keyDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test write permissions by attempting to create a temp file
|
||||||
|
testFile := filepath.Join(keyDir, ".write_test")
|
||||||
|
if err := os.WriteFile(testFile, []byte("test"), 0600); err != nil {
|
||||||
|
return fmt.Errorf("key directory %s is not writable: %w", keyDir, err)
|
||||||
|
}
|
||||||
|
os.Remove(testFile)
|
||||||
|
|
||||||
|
if _, err := os.Stat(km.keyPath); os.IsNotExist(err) {
|
||||||
|
log.Printf("ssh-key: generating new key at %s", km.keyPath)
|
||||||
|
return km.generateKey()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateKey creates a new Ed25519 key pair.
|
||||||
|
func (km *KeyManager) generateKey() error {
|
||||||
|
pubKey, privKey, err := ed25519.GenerateKey(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to generate key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
privKeyBytes, err := ssh.MarshalPrivateKey(privKey, "ssh-mcp")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal private key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(km.keyPath, pem.EncodeToMemory(privKeyBytes), 0600); err != nil {
|
||||||
|
return fmt.Errorf("failed to write private key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sshPubKey, err := ssh.NewPublicKey(pubKey)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create SSH public key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add "SSH-MCP" comment to public key for identification
|
||||||
|
pubKeyBytes := []byte(fmt.Sprintf("%s %s SSH-MCP\n",
|
||||||
|
sshPubKey.Type(),
|
||||||
|
base64.StdEncoding.EncodeToString(sshPubKey.Marshal())))
|
||||||
|
|
||||||
|
if err := os.WriteFile(km.keyPath+".pub", pubKeyBytes, 0644); err != nil {
|
||||||
|
return fmt.Errorf("failed to write public key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("ssh-key: generated successfully")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadPrivateKey loads the private key from disk.
|
||||||
|
func (km *KeyManager) LoadPrivateKey() (ssh.Signer, error) {
|
||||||
|
keyBytes, err := os.ReadFile(km.keyPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read private key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
signer, err := ssh.ParsePrivateKey(keyBytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse private key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return signer, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPublicKey returns the public key string.
|
||||||
|
func (km *KeyManager) GetPublicKey() (string, error) {
|
||||||
|
pubKeyBytes, err := os.ReadFile(km.keyPath + ".pub")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to read public key: %w", err)
|
||||||
|
}
|
||||||
|
return string(pubKeyBytes), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,710 @@
|
|||||||
|
package ssh
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.totmin.ru/en2zmax/forge-toolkit/configreload"
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Manager manages multiple SSH connections for a single session.
|
||||||
|
type Manager struct {
|
||||||
|
connections map[string]*Client
|
||||||
|
primary string
|
||||||
|
keyManager *KeyManager
|
||||||
|
|
||||||
|
// policy - текущая (закэшированная) per-agent политика подключений.
|
||||||
|
// nil = политика не настроена: ad-hoc режим как раньше (без ограничений).
|
||||||
|
policy *Policy
|
||||||
|
// policyLoader - live-reload политики из <FORGE_TENANT_CONFIG>/ssh.json
|
||||||
|
// (см. configreload). nil, если FORGE_TENANT_CONFIG не задан (легаси) или
|
||||||
|
// задана статическая политика через SetPolicy (тесты).
|
||||||
|
policyLoader *configreload.Loader[*Policy]
|
||||||
|
|
||||||
|
mu sync.RWMutex
|
||||||
|
aliasLocks map[string]*sync.Mutex
|
||||||
|
reconnectFails map[string]time.Time // alias -> last failed reconnect time
|
||||||
|
dockerCache map[string]*bool // alias -> docker available (nil = unknown)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewManager creates a new SSH connection manager. Если задан
|
||||||
|
// FORGE_TENANT_CONFIG/ssh.json - политика подхватывается через live-reload
|
||||||
|
// (configreload по контент-хэшу): правки ssh.json применяются к следующему
|
||||||
|
// Connect без рестарта.
|
||||||
|
func NewManager(keyPath string) *Manager {
|
||||||
|
mgr := &Manager{
|
||||||
|
connections: make(map[string]*Client),
|
||||||
|
keyManager: NewKeyManager(keyPath),
|
||||||
|
aliasLocks: make(map[string]*sync.Mutex),
|
||||||
|
reconnectFails: make(map[string]time.Time),
|
||||||
|
dockerCache: make(map[string]*bool),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := mgr.keyManager.EnsureKey(); err != nil {
|
||||||
|
log.Printf("manager: key setup warning: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if path, ok := PolicyPathFromEnv(); ok {
|
||||||
|
mgr.policyLoader = configreload.New(path, parsePolicy)
|
||||||
|
}
|
||||||
|
|
||||||
|
return mgr
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPolicy задаёт per-agent политику подключений вручную (тесты).
|
||||||
|
// Отключает live-reload (loader == nil), чтобы тесты были детерминированы.
|
||||||
|
func (m *Manager) SetPolicy(p *Policy) {
|
||||||
|
_ = p.normalize()
|
||||||
|
m.policy = p
|
||||||
|
m.policyLoader = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// currentPolicy возвращает актуальную политику, перечитав её при изменении
|
||||||
|
// содержимого ssh.json (live-reload). Нет loader'а → статическая policy.
|
||||||
|
// Ошибки: ErrNotFound → (nil, err) (нет политики = ad-hoc без ограничений);
|
||||||
|
// парсинг-ошибка → (lastGood, err) (сохраняем рабочую, но сообщаем).
|
||||||
|
func (m *Manager) currentPolicy() (*Policy, error) {
|
||||||
|
if m.policyLoader == nil {
|
||||||
|
return m.policy, nil
|
||||||
|
}
|
||||||
|
return m.policyLoader.Get()
|
||||||
|
}
|
||||||
|
|
||||||
|
// getAliasLock returns a per-alias lock.
|
||||||
|
func (m *Manager) getAliasLock(alias string) *sync.Mutex {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
if lock, ok := m.aliasLocks[alias]; ok {
|
||||||
|
return lock
|
||||||
|
}
|
||||||
|
|
||||||
|
lock := &sync.Mutex{}
|
||||||
|
m.aliasLocks[alias] = lock
|
||||||
|
return lock
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateAlias creates a unique alias and reserves it.
|
||||||
|
func (m *Manager) generateAlias(username, host string) string {
|
||||||
|
base := fmt.Sprintf("%s@%s", username, host)
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
if _, exists := m.connections[base]; !exists {
|
||||||
|
m.connections[base] = nil // Reserve
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 2; i < 100; i++ {
|
||||||
|
candidate := fmt.Sprintf("%s-%d", base, i)
|
||||||
|
if _, exists := m.connections[candidate]; !exists {
|
||||||
|
m.connections[candidate] = nil // Reserve
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback (might collide but highly unlikely to fill 100 slots)
|
||||||
|
final := fmt.Sprintf("%s-%d", base, 100)
|
||||||
|
m.connections[final] = nil
|
||||||
|
return final
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConnectOptions contains options for SSH connection.
|
||||||
|
type ConnectOptions struct {
|
||||||
|
Host string
|
||||||
|
Port int
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
PrivateKeyPath string
|
||||||
|
Alias string
|
||||||
|
Via string
|
||||||
|
// Target is an optional destination host routed through a PAM gateway
|
||||||
|
// (SafeInspect). When set, the gateway receives "Username@Target" and
|
||||||
|
// TargetPassword is used as the second authentication stage.
|
||||||
|
Target string
|
||||||
|
TargetPassword string
|
||||||
|
// Profile - имя профиля из ssh.json (см. policy.go). Если задан, креды
|
||||||
|
// (Host/Username/Port/Key/Via/Target) берутся из политики, а не от
|
||||||
|
// модели. Модель выбирает только имя; адрес и ключ задаёт оператор.
|
||||||
|
Profile string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect establishes an SSH connection and returns the alias. Применяет
|
||||||
|
// per-agent политику (см. policy.go): профиль резолвит креды, а host
|
||||||
|
// обязан пройти allowlist. Модель не может подключиться вне разрешённого.
|
||||||
|
func (m *Manager) Connect(ctx context.Context, opts ConnectOptions) (alias string, err error) {
|
||||||
|
if opts.Port == 0 {
|
||||||
|
opts.Port = 22
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live-reload политики: перечитываем ssh.json по контент-хэшу.
|
||||||
|
// ErrNotFound → нет политики (ad-hoc без ограничений). Парсинг-ошибка →
|
||||||
|
// last-good (если был) + предупреждение; без last-good — fail-closed.
|
||||||
|
pol, perr := m.currentPolicy()
|
||||||
|
if perr != nil {
|
||||||
|
if !errors.Is(perr, configreload.ErrNotFound) && pol == nil {
|
||||||
|
return "", fmt.Errorf("ssh: per-agent policy unavailable: %w", perr)
|
||||||
|
}
|
||||||
|
log.Printf("ssh: policy warning: %v", perr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Резолв профиля: платформа объявляет креды, модель выбирает по имени.
|
||||||
|
if opts.Profile != "" {
|
||||||
|
if pol == nil {
|
||||||
|
return "", errors.New("ssh: profile requested but no per-agent policy configured")
|
||||||
|
}
|
||||||
|
pr, ok := pol.Resolve(opts.Profile)
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("ssh: profile %q not found in policy", opts.Profile)
|
||||||
|
}
|
||||||
|
opts.Host = pr.Host
|
||||||
|
opts.Username = pr.Username
|
||||||
|
if pr.Port != 0 {
|
||||||
|
opts.Port = pr.Port
|
||||||
|
}
|
||||||
|
if opts.PrivateKeyPath == "" {
|
||||||
|
opts.PrivateKeyPath = pr.KeyPath
|
||||||
|
}
|
||||||
|
if opts.Via == "" {
|
||||||
|
opts.Via = pr.Via
|
||||||
|
}
|
||||||
|
if opts.Target == "" {
|
||||||
|
opts.Target = pr.Target
|
||||||
|
if opts.TargetPassword == "" {
|
||||||
|
opts.TargetPassword = pr.TargetPass
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Креды обязательны (профиль дал их, либо модель прислала host+username).
|
||||||
|
if opts.Host == "" || opts.Username == "" {
|
||||||
|
return "", errors.New("ssh: host and username are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allowlist: вне списка - deny (fail-closed). Пустой список = allow all.
|
||||||
|
if pol != nil && !pol.Allow(opts.Host) {
|
||||||
|
return "", fmt.Errorf("ssh: host %q is not in allowed_hosts of per-agent policy", opts.Host)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default key: если ни профиль, ни вызов не задали ключ - берём
|
||||||
|
// DefaultKeyPath политики, иначе "" (системный ключ KeyManager).
|
||||||
|
if pol != nil && opts.PrivateKeyPath == "" {
|
||||||
|
opts.PrivateKeyPath = pol.DefaultKeyPath
|
||||||
|
}
|
||||||
|
|
||||||
|
var reserved bool
|
||||||
|
if opts.Alias == "" {
|
||||||
|
// Include the PAM target (if any) so distinct targets routed through
|
||||||
|
// the same gateway get distinct auto-generated aliases.
|
||||||
|
aliasBase := opts.Username
|
||||||
|
if opts.Target != "" {
|
||||||
|
aliasBase = opts.Username + "@" + opts.Target
|
||||||
|
}
|
||||||
|
opts.Alias = m.generateAlias(aliasBase, opts.Host)
|
||||||
|
reserved = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.Via == opts.Alias {
|
||||||
|
return "", errors.New("'via' cannot be the same as 'alias'")
|
||||||
|
}
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
existing, exists := m.connections[opts.Alias]
|
||||||
|
if exists {
|
||||||
|
if existing != nil {
|
||||||
|
m.mu.Unlock()
|
||||||
|
if existing.creds.Host == opts.Host && existing.creds.Username == opts.Username && existing.creds.Target == opts.Target {
|
||||||
|
return opts.Alias, nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("alias '%s' already exists for %s@%s", opts.Alias, existing.creds.Username, existing.creds.Host)
|
||||||
|
}
|
||||||
|
// Existing is nil (reserved)
|
||||||
|
if !reserved {
|
||||||
|
m.mu.Unlock()
|
||||||
|
return "", fmt.Errorf("alias '%s' is currently connecting/reserved", opts.Alias)
|
||||||
|
}
|
||||||
|
// It's our reservation, proceed
|
||||||
|
} else {
|
||||||
|
// New explicit alias
|
||||||
|
m.connections[opts.Alias] = nil // Reserve
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
// Defer cleanup of reservation on error
|
||||||
|
defer func() {
|
||||||
|
if err != nil {
|
||||||
|
m.mu.Lock()
|
||||||
|
// Only remove if it's still nil (failed to connect)
|
||||||
|
if c, ok := m.connections[opts.Alias]; ok && c == nil {
|
||||||
|
delete(m.connections, opts.Alias)
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
creds := Credentials{
|
||||||
|
Host: opts.Host,
|
||||||
|
Port: opts.Port,
|
||||||
|
Username: opts.Username,
|
||||||
|
Password: opts.Password,
|
||||||
|
Via: opts.Via,
|
||||||
|
Target: opts.Target,
|
||||||
|
TargetPassword: opts.TargetPassword,
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.PrivateKeyPath != "" {
|
||||||
|
keyBytes, err := os.ReadFile(opts.PrivateKeyPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to read private key: %w", err)
|
||||||
|
}
|
||||||
|
signer, err := ssh.ParsePrivateKey(keyBytes)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to parse private key: %w", err)
|
||||||
|
}
|
||||||
|
creds.PrivateKey = signer
|
||||||
|
} else if opts.Password == "" {
|
||||||
|
signer, err := m.keyManager.LoadPrivateKey()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("no auth provided and system key unavailable: %w", err)
|
||||||
|
}
|
||||||
|
creds.PrivateKey = signer
|
||||||
|
log.Printf("ssh: using system key for %s", opts.Alias)
|
||||||
|
}
|
||||||
|
|
||||||
|
var jumpClient *Client
|
||||||
|
if opts.Via != "" {
|
||||||
|
m.mu.RLock()
|
||||||
|
jumpClient = m.connections[opts.Via]
|
||||||
|
m.mu.RUnlock()
|
||||||
|
if jumpClient == nil {
|
||||||
|
return "", fmt.Errorf("jump host '%s' not connected", opts.Via)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := NewClient(ctx, opts.Alias, creds, jumpClient)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
m.connections[opts.Alias] = client
|
||||||
|
if m.primary == "" {
|
||||||
|
m.primary = opts.Alias
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
return opts.Alias, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disconnect closes one or all connections.
|
||||||
|
func (m *Manager) Disconnect(alias string) (string, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
if alias == "" {
|
||||||
|
count := 0
|
||||||
|
for a, client := range m.connections {
|
||||||
|
if client != nil {
|
||||||
|
client.Close()
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
delete(m.connections, a)
|
||||||
|
delete(m.aliasLocks, a)
|
||||||
|
delete(m.reconnectFails, a)
|
||||||
|
delete(m.dockerCache, a)
|
||||||
|
}
|
||||||
|
m.primary = ""
|
||||||
|
return fmt.Sprintf("Disconnected all (%d) connections", count), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
client, ok := m.connections[alias]
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("no connection with alias '%s'", alias)
|
||||||
|
}
|
||||||
|
|
||||||
|
if client != nil {
|
||||||
|
client.Close()
|
||||||
|
}
|
||||||
|
delete(m.connections, alias)
|
||||||
|
delete(m.aliasLocks, alias)
|
||||||
|
delete(m.reconnectFails, alias)
|
||||||
|
delete(m.dockerCache, alias)
|
||||||
|
|
||||||
|
if m.primary == alias {
|
||||||
|
m.primary = ""
|
||||||
|
for a, c := range m.connections {
|
||||||
|
if c != nil {
|
||||||
|
m.primary = a
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("Disconnected '%s'", alias), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveTarget returns the target alias.
|
||||||
|
func (m *Manager) resolveTarget(target string) (string, error) {
|
||||||
|
if target != "" && target != "primary" {
|
||||||
|
m.mu.RLock()
|
||||||
|
_, ok := m.connections[target]
|
||||||
|
m.mu.RUnlock()
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("no connection with alias '%s'", target)
|
||||||
|
}
|
||||||
|
return target, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
m.mu.RLock()
|
||||||
|
primary := m.primary
|
||||||
|
m.mu.RUnlock()
|
||||||
|
|
||||||
|
if primary == "" {
|
||||||
|
return "", errors.New("no active connection")
|
||||||
|
}
|
||||||
|
return primary, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run executes a command on the target connection.
|
||||||
|
func (m *Manager) Run(ctx context.Context, cmd, target string) (*RunResult, error) {
|
||||||
|
alias, err := m.resolveTarget(target)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
lock := m.getAliasLock(alias)
|
||||||
|
lock.Lock()
|
||||||
|
defer lock.Unlock()
|
||||||
|
|
||||||
|
m.mu.RLock()
|
||||||
|
client := m.connections[alias]
|
||||||
|
m.mu.RUnlock()
|
||||||
|
|
||||||
|
if client == nil {
|
||||||
|
return nil, fmt.Errorf("connection '%s' not found", alias)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := client.Run(ctx, cmd)
|
||||||
|
if err != nil {
|
||||||
|
if isConnectionError(err) {
|
||||||
|
// Check reconnect backoff
|
||||||
|
m.mu.RLock()
|
||||||
|
lastFail := m.reconnectFails[alias]
|
||||||
|
m.mu.RUnlock()
|
||||||
|
if time.Since(lastFail) < 5*time.Second {
|
||||||
|
return nil, fmt.Errorf("connection lost (reconnect backoff): %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("ssh: connection lost for %s, reconnecting", alias)
|
||||||
|
if reconnErr := client.Reconnect(ctx, m.getJumpClient(client.creds.Via)); reconnErr != nil {
|
||||||
|
m.mu.Lock()
|
||||||
|
m.reconnectFails[alias] = time.Now()
|
||||||
|
m.mu.Unlock()
|
||||||
|
return nil, fmt.Errorf("reconnect failed: %w", reconnErr)
|
||||||
|
}
|
||||||
|
// Clear backoff on success
|
||||||
|
m.mu.Lock()
|
||||||
|
delete(m.reconnectFails, alias)
|
||||||
|
m.mu.Unlock()
|
||||||
|
return client.Run(ctx, cmd)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getJumpClient returns the jump client.
|
||||||
|
func (m *Manager) getJumpClient(via string) *Client {
|
||||||
|
if via == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
return m.connections[via]
|
||||||
|
}
|
||||||
|
|
||||||
|
// isConnectionError checks if error indicates lost connection.
|
||||||
|
func isConnectionError(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type-safe checks first
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
var netErr *net.OpError
|
||||||
|
if errors.As(err, &netErr) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// String matching for SSH-specific errors
|
||||||
|
errStr := err.Error()
|
||||||
|
return strings.Contains(errStr, "connection reset") ||
|
||||||
|
strings.Contains(errStr, "broken pipe") ||
|
||||||
|
strings.Contains(errStr, "connection refused") ||
|
||||||
|
strings.Contains(errStr, "use of closed network connection")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute runs a command and returns formatted output.
|
||||||
|
func (m *Manager) Execute(ctx context.Context, cmd, target string) (string, error) {
|
||||||
|
result, err := m.Run(ctx, cmd, target)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
var output strings.Builder
|
||||||
|
if result.Stdout != "" {
|
||||||
|
output.WriteString(result.Stdout)
|
||||||
|
}
|
||||||
|
if result.Stderr != "" {
|
||||||
|
if output.Len() > 0 {
|
||||||
|
output.WriteString("\n")
|
||||||
|
}
|
||||||
|
output.WriteString(result.Stderr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if output.Len() == 0 {
|
||||||
|
return "(No output)", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.ExitCode != 0 {
|
||||||
|
fmt.Fprintf(&output, "\n[Exit Code: %d]", result.ExitCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truncate if too long
|
||||||
|
const maxBytes = 51200
|
||||||
|
outputStr := output.String()
|
||||||
|
if len(outputStr) > maxBytes {
|
||||||
|
outputStr = outputStr[:maxBytes] + "\n... [Output truncated]"
|
||||||
|
}
|
||||||
|
|
||||||
|
return outputStr, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolvePath resolves a path to an absolute path using the connection's CWD.
|
||||||
|
// No path restrictions — the connected user's OS permissions are the only boundary.
|
||||||
|
func (m *Manager) resolvePath(path, alias string) string {
|
||||||
|
m.mu.RLock()
|
||||||
|
client := m.connections[alias]
|
||||||
|
m.mu.RUnlock()
|
||||||
|
|
||||||
|
cwd := "/"
|
||||||
|
if client != nil {
|
||||||
|
cwd = client.CWD()
|
||||||
|
}
|
||||||
|
|
||||||
|
if !filepath.IsAbs(path) {
|
||||||
|
path = filepath.Join(cwd, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
return filepath.Clean(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadFile reads a file.
|
||||||
|
func (m *Manager) ReadFile(ctx context.Context, path, target string) (string, error) {
|
||||||
|
alias, err := m.resolveTarget(target)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved := m.resolvePath(path, alias)
|
||||||
|
|
||||||
|
lock := m.getAliasLock(alias)
|
||||||
|
lock.Lock()
|
||||||
|
defer lock.Unlock()
|
||||||
|
|
||||||
|
m.mu.RLock()
|
||||||
|
client := m.connections[alias]
|
||||||
|
m.mu.RUnlock()
|
||||||
|
|
||||||
|
if client == nil {
|
||||||
|
return "", fmt.Errorf("connection '%s' is no longer active", alias)
|
||||||
|
}
|
||||||
|
|
||||||
|
sftpClient, err := client.SFTP()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := sftpClient.Open(resolved)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to open file: %w", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
// Check file size before reading to prevent OOM
|
||||||
|
const maxReadSize = 10 * 1024 * 1024 // 10 MB
|
||||||
|
stat, err := file.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to stat file: %w", err)
|
||||||
|
}
|
||||||
|
if stat.Size() > maxReadSize {
|
||||||
|
return "", fmt.Errorf("file too large (%d bytes, max %d bytes); use 'run' with head/tail to read portions", stat.Size(), maxReadSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := io.ReadAll(file)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to read file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(content), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteFile writes content to a file.
|
||||||
|
func (m *Manager) WriteFile(ctx context.Context, path, content, target string) error {
|
||||||
|
alias, err := m.resolveTarget(target)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved := m.resolvePath(path, alias)
|
||||||
|
|
||||||
|
lock := m.getAliasLock(alias)
|
||||||
|
lock.Lock()
|
||||||
|
defer lock.Unlock()
|
||||||
|
|
||||||
|
m.mu.RLock()
|
||||||
|
client := m.connections[alias]
|
||||||
|
m.mu.RUnlock()
|
||||||
|
|
||||||
|
if client == nil {
|
||||||
|
return fmt.Errorf("connection '%s' is no longer active", alias)
|
||||||
|
}
|
||||||
|
|
||||||
|
sftpClient, err := client.SFTP()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := sftpClient.Create(resolved)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create file: %w", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
_, err = file.Write([]byte(content))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to write file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListDir lists directory contents.
|
||||||
|
func (m *Manager) ListDir(ctx context.Context, path, target string) ([]FileInfo, error) {
|
||||||
|
alias, err := m.resolveTarget(target)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved := m.resolvePath(path, alias)
|
||||||
|
|
||||||
|
lock := m.getAliasLock(alias)
|
||||||
|
lock.Lock()
|
||||||
|
defer lock.Unlock()
|
||||||
|
|
||||||
|
m.mu.RLock()
|
||||||
|
client := m.connections[alias]
|
||||||
|
m.mu.RUnlock()
|
||||||
|
|
||||||
|
if client == nil {
|
||||||
|
return nil, fmt.Errorf("connection '%s' is no longer active", alias)
|
||||||
|
}
|
||||||
|
|
||||||
|
sftpClient, err := client.SFTP()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := sftpClient.ReadDir(resolved)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to list directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var files []FileInfo
|
||||||
|
for _, entry := range entries {
|
||||||
|
ftype := "file"
|
||||||
|
if entry.IsDir() {
|
||||||
|
ftype = "dir"
|
||||||
|
}
|
||||||
|
files = append(files, FileInfo{
|
||||||
|
Name: entry.Name(),
|
||||||
|
Type: ftype,
|
||||||
|
Size: entry.Size(),
|
||||||
|
Permissions: entry.Mode().String(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return files, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileInfo represents file metadata.
|
||||||
|
type FileInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
Permissions string `json:"permissions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPublicKey returns the system's public SSH key.
|
||||||
|
func (m *Manager) GetPublicKey() (string, error) {
|
||||||
|
return m.keyManager.GetPublicKey()
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsDockerAvailable checks if Docker is available on the target, with per-alias caching.
|
||||||
|
func (m *Manager) IsDockerAvailable(ctx context.Context, target string) (bool, error) {
|
||||||
|
alias, err := m.resolveTarget(target)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
m.mu.RLock()
|
||||||
|
cached := m.dockerCache[alias]
|
||||||
|
m.mu.RUnlock()
|
||||||
|
|
||||||
|
if cached != nil {
|
||||||
|
return *cached, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
output, err := m.Execute(ctx, "command -v docker >/dev/null 2>&1 && echo 'ok' || echo 'missing'", target)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
available := strings.Contains(output, "ok")
|
||||||
|
m.mu.Lock()
|
||||||
|
m.dockerCache[alias] = &available
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
return available, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes all connections.
|
||||||
|
func (m *Manager) Close() {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
for _, client := range m.connections {
|
||||||
|
if client != nil {
|
||||||
|
client.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.connections = make(map[string]*Client)
|
||||||
|
m.aliasLocks = make(map[string]*sync.Mutex)
|
||||||
|
m.reconnectFails = make(map[string]time.Time)
|
||||||
|
m.dockerCache = make(map[string]*bool)
|
||||||
|
m.primary = ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
// Package ssh: per-tenant политика подключений (контракт PLAN.md
|
||||||
|
// "Per-tenant forge-tools"). Файл <FORGE_TENANT_CONFIG>/ssh.json декларирует
|
||||||
|
// именованные профили, на которые агент ссылается по алиасу, и allowlist
|
||||||
|
// хостов, валидирующий ad-hoc-подключения. Цель - "платформа объявляет,
|
||||||
|
// модель ссылается": LLM не должен сам выбирать host/ключ; это делает
|
||||||
|
// оператор, а агент выбирает только из разрешённого.
|
||||||
|
package ssh
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.totmin.ru/en2zmax/forge-toolkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Profile - одно именованное подключение, на которое агент ссылается по
|
||||||
|
// Alias. Значения Host/Username/KeyPath задаёт оператор; модель их не вводит.
|
||||||
|
type Profile struct {
|
||||||
|
Alias string `json:"alias"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
// KeyPath - private key для подключения; может быть ${VAR} или пустым
|
||||||
|
// (тогда берётся DefaultKeyPath либо системный ключ KeyManager).
|
||||||
|
KeyPath string `json:"key"`
|
||||||
|
// Via - jump-host алиас для туннелирования; Target - PAM-шлюз.
|
||||||
|
Via string `json:"via"`
|
||||||
|
Target string `json:"target"`
|
||||||
|
TargetPass string `json:"target_password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Policy - декларативная per-agent политика подключений.
|
||||||
|
type Policy struct {
|
||||||
|
Profiles []Profile `json:"profiles"`
|
||||||
|
// AllowedHosts - glob-паттерны разрешённых хостов ("10.0.*",
|
||||||
|
// "*.internal"). Пустой список = allow all (обратная совместимость с
|
||||||
|
// ad-hoc режимом без конфига). Наличие списка включает жалостную
|
||||||
|
// проверку: host вне списка - deny.
|
||||||
|
AllowedHosts []string `json:"allowed_hosts"`
|
||||||
|
// DefaultKeyPath - ключ по умолчанию, если ни профиль, ни вызов не
|
||||||
|
// указали private_key_path. Пусто = системный ключ (SSH_MCP_KEY_PATH).
|
||||||
|
DefaultKeyPath string `json:"default_key_path"`
|
||||||
|
|
||||||
|
byAlias map[string]Profile
|
||||||
|
hostGlob []*regexp.Regexp
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadPolicy читает <path>/ssh.json, раскрывает ${VAR} и валидирует.
|
||||||
|
// Отсутствие файла - nil, nil (политика не настроена -> ad-hoc без
|
||||||
|
// ограничений). Битый файл - ошибка (fail-closed).
|
||||||
|
func LoadPolicy(path string) (*Policy, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("read ssh policy %s: %w", path, err)
|
||||||
|
}
|
||||||
|
return parsePolicy(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsePolicy разбирает готовые байты ssh.json (${VAR} + валидация).
|
||||||
|
// Используется и LoadPolicy, и configreload.Loader (live-reload, см.
|
||||||
|
// manager.go): loader передаёт сырые байты файла на каждый Get.
|
||||||
|
func parsePolicy(data []byte) (*Policy, error) {
|
||||||
|
p := &Policy{}
|
||||||
|
if err := json.Unmarshal(toolkit.Expand(data), p); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse ssh policy: %w", err)
|
||||||
|
}
|
||||||
|
if err := p.normalize(); err != nil {
|
||||||
|
return nil, fmt.Errorf("ssh policy: %w", err)
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Policy) normalize() error {
|
||||||
|
p.byAlias = make(map[string]Profile, len(p.Profiles))
|
||||||
|
for _, pr := range p.Profiles {
|
||||||
|
if pr.Alias == "" || pr.Host == "" || pr.Username == "" {
|
||||||
|
return fmt.Errorf("profile must have alias, host and username")
|
||||||
|
}
|
||||||
|
if _, dup := p.byAlias[pr.Alias]; dup {
|
||||||
|
return fmt.Errorf("duplicate profile alias %q", pr.Alias)
|
||||||
|
}
|
||||||
|
p.byAlias[pr.Alias] = pr
|
||||||
|
}
|
||||||
|
for _, h := range p.AllowedHosts {
|
||||||
|
re, err := globToRegex(h)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("allowed_hosts pattern %q: %w", h, err)
|
||||||
|
}
|
||||||
|
p.hostGlob = append(p.hostGlob, re)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve возвращает профиль по алиасу.
|
||||||
|
func (p *Policy) Resolve(alias string) (Profile, bool) {
|
||||||
|
if p == nil {
|
||||||
|
return Profile{}, false
|
||||||
|
}
|
||||||
|
pr, ok := p.byAlias[alias]
|
||||||
|
return pr, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow сообщает, разрешён ли хост. Без allowed_hosts - всегда true
|
||||||
|
// (ad-hoc сохранён как раньше); иначе host обязан матчить хоть один glob.
|
||||||
|
func (p *Policy) Allow(host string) bool {
|
||||||
|
if p == nil || len(p.hostGlob) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, re := range p.hostGlob {
|
||||||
|
if re.MatchString(host) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeyPath возвращает эффективный путь к ключу для профиля: из профиля,
|
||||||
|
// иначе DefaultKeyPath, иначе "" (системный ключ KeyManager).
|
||||||
|
func (p *Policy) KeyPath(pr Profile) string {
|
||||||
|
if pr.KeyPath != "" {
|
||||||
|
return pr.KeyPath
|
||||||
|
}
|
||||||
|
if p != nil {
|
||||||
|
return p.DefaultKeyPath
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// globToRegex превращает glob-паттерн хоста в regex с полным совпадением.
|
||||||
|
func globToRegex(pattern string) (*regexp.Regexp, error) {
|
||||||
|
re, err := regexp.Compile("^" + globToRe(pattern) + "$")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return re, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func globToRe(pattern string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
prev := rune(0)
|
||||||
|
for _, ch := range pattern {
|
||||||
|
switch ch {
|
||||||
|
case '*':
|
||||||
|
if prev != '*' {
|
||||||
|
b.WriteString(".*")
|
||||||
|
}
|
||||||
|
case '?':
|
||||||
|
b.WriteByte('.')
|
||||||
|
default:
|
||||||
|
b.WriteString(regexp.QuoteMeta(string(ch)))
|
||||||
|
}
|
||||||
|
prev = ch
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// PolicyPathFromEnv - путь к per-agent ssh.json из FORGE_TENANT_CONFIG.
|
||||||
|
func PolicyPathFromEnv() (string, bool) {
|
||||||
|
dir := os.Getenv("FORGE_TENANT_CONFIG")
|
||||||
|
if dir == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return filepath.Join(dir, "ssh.json"), true
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package ssh
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writePolicy(t *testing.T, content string) string {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
p := filepath.Join(dir, "ssh.json")
|
||||||
|
if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
|
||||||
|
t.Fatalf("write policy: %v", err)
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadPolicy_MissingFileNil(t *testing.T) {
|
||||||
|
p, err := LoadPolicy(filepath.Join(t.TempDir(), "nope.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("missing file: %v", err)
|
||||||
|
}
|
||||||
|
if p != nil {
|
||||||
|
t.Fatalf("expected nil policy for missing file, got %+v", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadPolicy_ExpandsVars(t *testing.T) {
|
||||||
|
t.Setenv("FORGE_SSH_KEY", "/keys/agent")
|
||||||
|
p, err := LoadPolicy(writePolicy(t, `{
|
||||||
|
"profiles":[{"alias":"prod","host":"10.0.1.5","username":"deploy","key":"${FORGE_SSH_KEY}"}],
|
||||||
|
"allowed_hosts":["10.0.*"],
|
||||||
|
"default_key_path":"${FORGE_SSH_KEY}"
|
||||||
|
}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load: %v", err)
|
||||||
|
}
|
||||||
|
pr, ok := p.Resolve("prod")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("profile prod not resolved")
|
||||||
|
}
|
||||||
|
if pr.Host != "10.0.1.5" || pr.KeyPath != "/keys/agent" {
|
||||||
|
t.Fatalf("profile = %+v", pr)
|
||||||
|
}
|
||||||
|
if p.DefaultKeyPath != "/keys/agent" {
|
||||||
|
t.Fatalf("default_key = %q", p.DefaultKeyPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPolicy_Allowlist(t *testing.T) {
|
||||||
|
p, err := LoadPolicy(writePolicy(t, `{"allowed_hosts":["10.0.*","*.internal"]}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load: %v", err)
|
||||||
|
}
|
||||||
|
if !p.Allow("10.0.1.5") || !p.Allow("db.internal") {
|
||||||
|
t.Fatal("expected allowed hosts to pass")
|
||||||
|
}
|
||||||
|
if p.Allow("192.168.0.1") || p.Allow("evil.com") {
|
||||||
|
t.Fatal("expected non-allowed hosts to be denied")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPolicy_NoAllowlistAllowsAll(t *testing.T) {
|
||||||
|
p, err := LoadPolicy(writePolicy(t, `{"profiles":[{"alias":"a","host":"h","username":"u"}]}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load: %v", err)
|
||||||
|
}
|
||||||
|
if !p.Allow("anything.example") {
|
||||||
|
t.Fatal("empty allowlist should allow all (backward compat)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// testManager изолирует генерацию ключа в temp-каталог, чтобы тесты не
|
||||||
|
// создавали ./data/id_ed25519 в модуле.
|
||||||
|
func testManager(t *testing.T) *Manager {
|
||||||
|
t.Helper()
|
||||||
|
t.Setenv("SSH_MCP_KEY_PATH", filepath.Join(t.TempDir(), "id_ed25519"))
|
||||||
|
return NewManager("")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConnect_ProfileResolution_DeniesUnknown(t *testing.T) {
|
||||||
|
m := testManager(t)
|
||||||
|
m.SetPolicy(&Policy{
|
||||||
|
Profiles: []Profile{{Alias: "prod", Host: "10.0.1.5", Username: "deploy"}},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Неизвестный профиль не должен уходить в сеть.
|
||||||
|
_, err := m.Connect(context.Background(), ConnectOptions{Profile: "nope"})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "not found") {
|
||||||
|
t.Fatalf("expected profile-not-found error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConnect_Allowlist_DeniesHost(t *testing.T) {
|
||||||
|
m := testManager(t)
|
||||||
|
m.SetPolicy(&Policy{AllowedHosts: []string{"10.0.*"}})
|
||||||
|
|
||||||
|
// Host вне allowlist - deny до dial.
|
||||||
|
_, err := m.Connect(context.Background(), ConnectOptions{Host: "evil.example", Username: "x"})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "not in allowed_hosts") {
|
||||||
|
t.Fatalf("expected allowlist deny, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConnect_ProfileWithoutPolicy_Denies(t *testing.T) {
|
||||||
|
m := testManager(t)
|
||||||
|
_, err := m.Connect(context.Background(), ConnectOptions{Profile: "prod"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when profile used without policy")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "per-agent policy") {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"forge-tools-ssh/internal/ssh"
|
||||||
|
|
||||||
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
)
|
||||||
|
|
||||||
|
var mgr *ssh.Manager
|
||||||
|
|
||||||
|
// SetManager binds the single SSH Manager for the process.
|
||||||
|
// forge держит 1 MCP-подпроцесс/агента (stdio), поэтому менеджер один.
|
||||||
|
func SetManager(m *ssh.Manager) {
|
||||||
|
mgr = m
|
||||||
|
}
|
||||||
|
|
||||||
|
// manager возвращает одиночный SSH Manager процесса.
|
||||||
|
func manager() *ssh.Manager {
|
||||||
|
return mgr
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerCoreTools регистрирует базовые SSH-инструменты.
|
||||||
|
func registerCoreTools(s *mcp.Server) {
|
||||||
|
// connect
|
||||||
|
s.AddTool(&mcp.Tool{
|
||||||
|
Name: "connect",
|
||||||
|
Description: "Establish an SSH connection to a remote host. Используй profile (имя из ssh.json), чтобы подключиться по предопределённому профилю; либо host+username для ad-hoc (host должен пройти allowlist политики).",
|
||||||
|
InputSchema: schema(map[string]any{
|
||||||
|
"profile": strProps("Имя профиля из per-agent ssh.json (креды задаёт оператор)", false),
|
||||||
|
"host": strProps("Hostname or IP address", false),
|
||||||
|
"username": strProps("SSH username", false),
|
||||||
|
"port": intProps("SSH port (default: 22)", false),
|
||||||
|
"password": strProps("SSH password (optional if using key)", false),
|
||||||
|
"private_key_path": strProps("Path to private key file", false),
|
||||||
|
"alias": strProps("Connection alias (auto-generated if not provided)", false),
|
||||||
|
"via": strProps("Jump host alias for tunneling", false),
|
||||||
|
"target": strProps("Optional destination host routed through a PAM gateway (SafeInspect). The gateway receives 'username@target' and proxies the session.", false),
|
||||||
|
"target_password": strProps("Password of the target server account, requested by the PAM gateway as a second authentication stage", false),
|
||||||
|
}, nil),
|
||||||
|
}, connectHandler)
|
||||||
|
|
||||||
|
// disconnect
|
||||||
|
s.AddTool(&mcp.Tool{
|
||||||
|
Name: "disconnect",
|
||||||
|
Description: "Close an SSH connection",
|
||||||
|
InputSchema: schema(map[string]any{
|
||||||
|
"alias": strProps("Connection alias to disconnect (all if empty)", false),
|
||||||
|
}, nil),
|
||||||
|
}, disconnectHandler)
|
||||||
|
|
||||||
|
// run
|
||||||
|
registerPatternTool(s, &mcp.Tool{
|
||||||
|
Name: "run",
|
||||||
|
Description: "Execute a shell command on the remote host. Use timeout for long-running tasks.",
|
||||||
|
InputSchema: schema(map[string]any{
|
||||||
|
"command": strProps("Shell command to execute", true),
|
||||||
|
"target": strProps("Connection alias (default: primary)", false),
|
||||||
|
"timeout": intProps("Command timeout in seconds (default: 120)", false),
|
||||||
|
}, []string{"command"}),
|
||||||
|
}, runPatternsFn, runHandler)
|
||||||
|
|
||||||
|
// identity
|
||||||
|
s.AddTool(&mcp.Tool{
|
||||||
|
Name: "identity",
|
||||||
|
Description: "Get the server's public SSH key for authorized_keys",
|
||||||
|
InputSchema: schema(nil, nil),
|
||||||
|
}, identityHandler)
|
||||||
|
|
||||||
|
// info
|
||||||
|
s.AddTool(&mcp.Tool{
|
||||||
|
Name: "info",
|
||||||
|
Description: "Get remote system information (OS, kernel, hostname)",
|
||||||
|
InputSchema: schema(map[string]any{
|
||||||
|
"target": strProps("Connection alias (default: primary)", false),
|
||||||
|
}, nil),
|
||||||
|
}, infoHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func connectHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
m := manager()
|
||||||
|
if m == nil {
|
||||||
|
return errorResult("no SSH manager initialized"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
args := requestArgs(req)
|
||||||
|
opts := ssh.ConnectOptions{
|
||||||
|
Host: getString(args, "host", ""),
|
||||||
|
Username: getString(args, "username", ""),
|
||||||
|
Port: getInt(args, "port", 22),
|
||||||
|
Password: getString(args, "password", ""),
|
||||||
|
PrivateKeyPath: getString(args, "private_key_path", ""),
|
||||||
|
Alias: getString(args, "alias", ""),
|
||||||
|
Via: getString(args, "via", ""),
|
||||||
|
Target: getString(args, "target", ""),
|
||||||
|
TargetPassword: getString(args, "target_password", ""),
|
||||||
|
Profile: getString(args, "profile", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
resultAlias, err := m.Connect(ctx, opts)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
dest := opts.Host
|
||||||
|
if opts.Target != "" {
|
||||||
|
dest = fmt.Sprintf("%s via PAM -> %s", opts.Host, opts.Target)
|
||||||
|
}
|
||||||
|
return textResult(fmt.Sprintf("Connected to %s@%s (alias: %s)", opts.Username, dest, resultAlias)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func disconnectHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
m := manager()
|
||||||
|
if m == nil {
|
||||||
|
return errorResult("no SSH manager initialized"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
alias := getString(requestArgs(req), "alias", "")
|
||||||
|
msg, err := m.Disconnect(alias)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
return textResult(msg), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
m := manager()
|
||||||
|
if m == nil {
|
||||||
|
return errorResult("no SSH manager initialized"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
args := requestArgs(req)
|
||||||
|
command := getString(args, "command", "")
|
||||||
|
if command == "" {
|
||||||
|
return errorResult("command is required"), nil
|
||||||
|
}
|
||||||
|
target := getString(args, "target", "primary")
|
||||||
|
timeout := getInt(args, "timeout", 120)
|
||||||
|
|
||||||
|
if timeout > 0 {
|
||||||
|
var cancel context.CancelFunc
|
||||||
|
ctx, cancel = context.WithTimeout(ctx, time.Duration(timeout)*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
output, err := m.Execute(ctx, command, target)
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() == context.DeadlineExceeded {
|
||||||
|
return errorResult(fmt.Sprintf("Command timed out after %ds", timeout)), nil
|
||||||
|
}
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
return textResult(output), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func identityHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
m := manager()
|
||||||
|
if m == nil {
|
||||||
|
return errorResult("no SSH manager initialized"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
pubKey, err := m.GetPublicKey()
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
formatted := fmt.Sprintf("SSH Public Key:\n\n```\n%s```\n\nAdd this to ~/.ssh/authorized_keys on remote servers.", pubKey)
|
||||||
|
return textResult(formatted), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func infoHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
m := manager()
|
||||||
|
if m == nil {
|
||||||
|
return errorResult("no SSH manager initialized"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
target := getString(requestArgs(req), "target", "primary")
|
||||||
|
cmd := `echo "Hostname: $(hostname)"; echo "OS: $(cat /etc/os-release 2>/dev/null | grep PRETTY_NAME | cut -d'"' -f2 || uname -s)"; echo "Kernel: $(uname -r)"; echo "Arch: $(uname -m)"; echo "Shell: $SHELL"`
|
||||||
|
output, err := m.Execute(ctx, cmd, target)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
return textResult(output), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
)
|
||||||
|
|
||||||
|
func registerDBTools(s *mcp.Server) {
|
||||||
|
// db_query
|
||||||
|
s.AddTool(&mcp.Tool{
|
||||||
|
Name: "db_query",
|
||||||
|
Description: "Execute SQL/CQL/MongoDB query inside a database container",
|
||||||
|
InputSchema: schema(map[string]any{
|
||||||
|
"container": strProps("Docker container name running the database", true),
|
||||||
|
"db_type": strProps("Database engine type", true, "postgres", "mysql", "scylladb", "cassandra", "mongodb"),
|
||||||
|
"query": strProps("Query to execute", true),
|
||||||
|
"database": strProps("Database/keyspace name", false),
|
||||||
|
"username": strProps("Database username", false),
|
||||||
|
"password": strProps("Database password", false),
|
||||||
|
"timeout": intProps("Query timeout in seconds (default: 60)", false),
|
||||||
|
"target": strProps("Connection alias (default: primary)", false),
|
||||||
|
}, []string{"container", "db_type", "query"}),
|
||||||
|
}, dbQueryHandler)
|
||||||
|
|
||||||
|
// db_schema
|
||||||
|
s.AddTool(&mcp.Tool{
|
||||||
|
Name: "db_schema",
|
||||||
|
Description: "Get database schema (tables/collections list)",
|
||||||
|
InputSchema: schema(map[string]any{
|
||||||
|
"container": strProps("Docker container name", true),
|
||||||
|
"db_type": strProps("Database engine type", true, "postgres", "mysql", "scylladb", "cassandra", "mongodb"),
|
||||||
|
"database": strProps("Database/keyspace name", false),
|
||||||
|
"username": strProps("Database username", false),
|
||||||
|
"password": strProps("Database password", false),
|
||||||
|
"target": strProps("Connection alias (default: primary)", false),
|
||||||
|
}, []string{"container", "db_type"}),
|
||||||
|
}, dbSchemaHandler)
|
||||||
|
|
||||||
|
// list_db_containers
|
||||||
|
s.AddTool(&mcp.Tool{
|
||||||
|
Name: "list_db_containers",
|
||||||
|
Description: "Find Docker containers that look like databases",
|
||||||
|
InputSchema: schema(map[string]any{
|
||||||
|
"target": strProps("Connection alias (default: primary)", false),
|
||||||
|
}, nil),
|
||||||
|
}, listDBContainersHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func dbQueryHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
m := manager()
|
||||||
|
if m == nil {
|
||||||
|
return errorResult("no SSH manager initialized"), nil
|
||||||
|
}
|
||||||
|
args := requestArgs(req)
|
||||||
|
container, _ := requireString(args, "container")
|
||||||
|
dbType, _ := requireString(args, "db_type")
|
||||||
|
query, _ := requireString(args, "query")
|
||||||
|
database := getString(args, "database", "")
|
||||||
|
username := getString(args, "username", "")
|
||||||
|
password := getString(args, "password", "")
|
||||||
|
timeout := getInt(args, "timeout", 60)
|
||||||
|
target := getString(args, "target", "primary")
|
||||||
|
|
||||||
|
if err := checkDockerAvailable(ctx, m, target); err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var cmd string
|
||||||
|
switch dbType {
|
||||||
|
case "postgres":
|
||||||
|
user := "postgres"
|
||||||
|
if username != "" {
|
||||||
|
user = username
|
||||||
|
}
|
||||||
|
db := database
|
||||||
|
if db == "" {
|
||||||
|
db = "postgres"
|
||||||
|
}
|
||||||
|
cmd = fmt.Sprintf("docker exec -e PGPASSWORD=%s %s timeout %d psql -U %s -d %s -c %s 2>&1",
|
||||||
|
shellQuote(password), shellQuote(container), timeout, shellQuote(user), shellQuote(db), shellQuote(query))
|
||||||
|
|
||||||
|
case "mysql":
|
||||||
|
user := "root"
|
||||||
|
if username != "" {
|
||||||
|
user = username
|
||||||
|
}
|
||||||
|
cmd = fmt.Sprintf("docker exec %s timeout %d mysql -u%s", shellQuote(container), timeout, shellQuote(user))
|
||||||
|
if password != "" {
|
||||||
|
cmd += fmt.Sprintf(" -p%s", shellQuote(password))
|
||||||
|
}
|
||||||
|
if database != "" {
|
||||||
|
cmd += fmt.Sprintf(" %s", shellQuote(database))
|
||||||
|
}
|
||||||
|
cmd += fmt.Sprintf(" -e %s 2>&1", shellQuote(query))
|
||||||
|
|
||||||
|
case "scylladb", "cassandra":
|
||||||
|
cmd = fmt.Sprintf("docker exec %s timeout %d cqlsh", shellQuote(container), timeout)
|
||||||
|
if username != "" {
|
||||||
|
cmd += fmt.Sprintf(" -u %s", shellQuote(username))
|
||||||
|
}
|
||||||
|
if password != "" {
|
||||||
|
cmd += fmt.Sprintf(" -p %s", shellQuote(password))
|
||||||
|
}
|
||||||
|
cmd += fmt.Sprintf(" -e %s 2>&1", shellQuote(query))
|
||||||
|
|
||||||
|
case "mongodb":
|
||||||
|
db := database
|
||||||
|
if db == "" {
|
||||||
|
db = "admin"
|
||||||
|
}
|
||||||
|
cmd = fmt.Sprintf("docker exec %s timeout %d mongosh --quiet %s", shellQuote(container), timeout, shellQuote(db))
|
||||||
|
if username != "" && password != "" {
|
||||||
|
cmd += fmt.Sprintf(" -u %s -p %s --authenticationDatabase admin", shellQuote(username), shellQuote(password))
|
||||||
|
}
|
||||||
|
cmd += fmt.Sprintf(" --eval %s 2>&1", shellQuote(query))
|
||||||
|
|
||||||
|
default:
|
||||||
|
return errorResult(fmt.Sprintf("Unsupported database type: %s. Supported: postgres, mysql, scylladb, cassandra, mongodb", dbType)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
output, err := m.Execute(ctx, cmd, target)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
return textResult(output), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func dbSchemaHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
m := manager()
|
||||||
|
if m == nil {
|
||||||
|
return errorResult("no SSH manager initialized"), nil
|
||||||
|
}
|
||||||
|
args := requestArgs(req)
|
||||||
|
container, _ := requireString(args, "container")
|
||||||
|
dbType, _ := requireString(args, "db_type")
|
||||||
|
database := getString(args, "database", "")
|
||||||
|
username := getString(args, "username", "")
|
||||||
|
password := getString(args, "password", "")
|
||||||
|
target := getString(args, "target", "primary")
|
||||||
|
|
||||||
|
if err := checkDockerAvailable(ctx, m, target); err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var query string
|
||||||
|
switch dbType {
|
||||||
|
case "postgres":
|
||||||
|
query = "\\dt"
|
||||||
|
case "mysql":
|
||||||
|
query = "SHOW TABLES;"
|
||||||
|
case "scylladb", "cassandra":
|
||||||
|
if database != "" {
|
||||||
|
query = fmt.Sprintf("DESCRIBE KEYSPACE %s;", database)
|
||||||
|
} else {
|
||||||
|
query = "DESCRIBE KEYSPACES;"
|
||||||
|
}
|
||||||
|
case "mongodb":
|
||||||
|
query = "db.getCollectionNames()"
|
||||||
|
default:
|
||||||
|
return errorResult(fmt.Sprintf("Unsupported database type: %s", dbType)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var cmd string
|
||||||
|
switch dbType {
|
||||||
|
case "postgres":
|
||||||
|
user := "postgres"
|
||||||
|
if username != "" {
|
||||||
|
user = username
|
||||||
|
}
|
||||||
|
db := database
|
||||||
|
if db == "" {
|
||||||
|
db = "postgres"
|
||||||
|
}
|
||||||
|
cmd = fmt.Sprintf("docker exec -e PGPASSWORD=%s %s psql -U %s -d %s -c %s 2>&1",
|
||||||
|
shellQuote(password), shellQuote(container), shellQuote(user), shellQuote(db), shellQuote(query))
|
||||||
|
case "mysql":
|
||||||
|
user := "root"
|
||||||
|
if username != "" {
|
||||||
|
user = username
|
||||||
|
}
|
||||||
|
cmd = fmt.Sprintf("docker exec %s mysql -u%s", shellQuote(container), shellQuote(user))
|
||||||
|
if password != "" {
|
||||||
|
cmd += fmt.Sprintf(" -p%s", shellQuote(password))
|
||||||
|
}
|
||||||
|
if database != "" {
|
||||||
|
cmd += fmt.Sprintf(" %s", shellQuote(database))
|
||||||
|
}
|
||||||
|
cmd += fmt.Sprintf(" -e %s 2>&1", shellQuote(query))
|
||||||
|
case "scylladb", "cassandra":
|
||||||
|
cmd = fmt.Sprintf("docker exec %s cqlsh", shellQuote(container))
|
||||||
|
if username != "" {
|
||||||
|
cmd += fmt.Sprintf(" -u %s", shellQuote(username))
|
||||||
|
}
|
||||||
|
if password != "" {
|
||||||
|
cmd += fmt.Sprintf(" -p %s", shellQuote(password))
|
||||||
|
}
|
||||||
|
cmd += fmt.Sprintf(" -e %s 2>&1", shellQuote(query))
|
||||||
|
case "mongodb":
|
||||||
|
db := database
|
||||||
|
if db == "" {
|
||||||
|
db = "admin"
|
||||||
|
}
|
||||||
|
cmd = fmt.Sprintf("docker exec %s mongosh --quiet %s", shellQuote(container), shellQuote(db))
|
||||||
|
if username != "" && password != "" {
|
||||||
|
cmd += fmt.Sprintf(" -u %s -p %s --authenticationDatabase admin", shellQuote(username), shellQuote(password))
|
||||||
|
}
|
||||||
|
cmd += fmt.Sprintf(" --eval %s 2>&1", shellQuote(query))
|
||||||
|
}
|
||||||
|
|
||||||
|
output, err := m.Execute(ctx, cmd, target)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
return textResult(output), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func listDBContainersHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
m := manager()
|
||||||
|
if m == nil {
|
||||||
|
return errorResult("no SSH manager initialized"), nil
|
||||||
|
}
|
||||||
|
target := getString(requestArgs(req), "target", "primary")
|
||||||
|
|
||||||
|
if err := checkDockerAvailable(ctx, m, target); err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := `docker ps --format '{{.Names}}|{{.Image}}' | while read line; do
|
||||||
|
name=$(echo "$line" | cut -d'|' -f1)
|
||||||
|
image=$(echo "$line" | cut -d'|' -f2)
|
||||||
|
case "$image" in
|
||||||
|
*postgres*) echo "$name|$image|postgres" ;;
|
||||||
|
*mysql*|*mariadb*) echo "$name|$image|mysql" ;;
|
||||||
|
*scylla*) echo "$name|$image|scylladb" ;;
|
||||||
|
*cassandra*) echo "$name|$image|cassandra" ;;
|
||||||
|
*mongo*) echo "$name|$image|mongodb" ;;
|
||||||
|
*redis*) echo "$name|$image|redis" ;;
|
||||||
|
esac
|
||||||
|
done 2>/dev/null`
|
||||||
|
|
||||||
|
output, err := m.Execute(ctx, cmd, target)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
if trimOutput(output) == "" {
|
||||||
|
return textResult("No database containers found"), nil
|
||||||
|
}
|
||||||
|
return textResult(output), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// registerDiskTools добавляет точные инструменты диска: по конкретному пути
|
||||||
|
// (безпотерьный ответ "сколько места на X") и по всем точкам монтирования.
|
||||||
|
// Отличается от ssh__usage, который исторически отдавал только корень /.
|
||||||
|
func registerDiskTools(s *mcp.Server) {
|
||||||
|
s.AddTool(&mcp.Tool{
|
||||||
|
Name: "disk_usage",
|
||||||
|
Description: "Get free/used space for the filesystem that contains the given path (df equivalent). Resolves the MOUND, not just root: returns the mount point, device, size, used, available and use% for the volume holding <path>. Use for 'how much space/free on <path>'.",
|
||||||
|
InputSchema: schema(map[string]any{
|
||||||
|
"path": strProps("Absolute path; reports the filesystem that contains it", true),
|
||||||
|
"target": strProps("Connection alias (default: primary)", false),
|
||||||
|
}, []string{"path"}),
|
||||||
|
}, diskUsageHandler)
|
||||||
|
|
||||||
|
s.AddTool(&mcp.Tool{
|
||||||
|
Name: "disk_usage_all",
|
||||||
|
Description: "Get free/used space for ALL mounted filesystems (df over every mount point) with a marker on partitions that are >=80% used. Use for an overview of disk pressure.",
|
||||||
|
InputSchema: schema(map[string]any{
|
||||||
|
"target": strProps("Connection alias (default: primary)", false),
|
||||||
|
}, nil),
|
||||||
|
}, diskUsageAllHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func diskUsageHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
m := manager()
|
||||||
|
if m == nil {
|
||||||
|
return errorResult("no SSH manager initialized"), nil
|
||||||
|
}
|
||||||
|
args := requestArgs(req)
|
||||||
|
path, err := requireString(args, "path")
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
target := getString(args, "target", "primary")
|
||||||
|
|
||||||
|
// df -hP <path> reports the FS containing <path> (mount point), not just
|
||||||
|
// root. shellQuote guards the path; if the path doesn't exist yet (e.g., a
|
||||||
|
// not-yet-mounted volume) we degrade to the full mount list rather than fail.
|
||||||
|
cmd := diskUsageCmd(path)
|
||||||
|
output, err := m.Execute(ctx, cmd, target)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
return textResult(output), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func diskUsageAllHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
m := manager()
|
||||||
|
if m == nil {
|
||||||
|
return errorResult("no SSH manager initialized"), nil
|
||||||
|
}
|
||||||
|
target := getString(requestArgs(req), "target", "primary")
|
||||||
|
output, err := m.Execute(ctx, "df -hP 2>/dev/null", target)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
return textResult(highlightHighUsage(output)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// diskUsageCmd собирает команду "место на томе, содержащем path".
|
||||||
|
func diskUsageCmd(path string) string {
|
||||||
|
return fmt.Sprintf("df -hP %s 2>/dev/null || df -hP 2>/dev/null", shellQuote(path))
|
||||||
|
}
|
||||||
|
|
||||||
|
// highlightHighUsage помечает строки df с заполненностью >=80% (иначе
|
||||||
|
// "почти полный" раздел выпадал у ssh__diagnose_system из-за фильтра >90%).
|
||||||
|
// Парсим в Go, а не awk: тестируемо и не зависит от полей awk.
|
||||||
|
func highlightHighUsage(dfOut string) string {
|
||||||
|
if strings.TrimSpace(dfOut) == "" {
|
||||||
|
return dfOut
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
for _, line := range strings.Split(dfOut, "\n") {
|
||||||
|
b.WriteString(line)
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
// POSIX df -hP: колонка 5 (индекс 4) — "Use%" вроде "84%".
|
||||||
|
if len(fields) >= 5 {
|
||||||
|
if pct, err := strconv.Atoi(strings.TrimSuffix(fields[4], "%")); err == nil && pct >= 80 {
|
||||||
|
b.WriteString(" << USE>=80%")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.WriteByte('\n')
|
||||||
|
}
|
||||||
|
return strings.TrimRight(b.String(), "\n")
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestDiskUsageCmd — команда точного disk_usage обязана квотировать путь и
|
||||||
|
// не путать "место на /opt" с корнем (df по конкретному пути, не "df -h /").
|
||||||
|
func TestDiskUsageCmd(t *testing.T) {
|
||||||
|
cmd := diskUsageCmd("/opt")
|
||||||
|
for _, want := range []string{"df -hP", "'/opt'"} {
|
||||||
|
if !strings.Contains(cmd, want) {
|
||||||
|
t.Errorf("diskUsageCmd(/opt) = %q: missing %q", cmd, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(cmd, "df -h /") {
|
||||||
|
t.Errorf("diskUsageCmd(/opt) = %q: must not fall back to root-only 'df -h /'", cmd)
|
||||||
|
}
|
||||||
|
// Апостроф/спецсимвол в пути не должен ломать квотирование (инъекция).
|
||||||
|
cmd2 := diskUsageCmd("/opt/x' ;rm -rf /")
|
||||||
|
if !strings.Contains(cmd2, shellQuote("/opt/x' ;rm -rf /")) {
|
||||||
|
t.Errorf("diskUsageCmd does not shellQuote path: %q", cmd2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHighlightHighUsage — регрессия на "почти полный" раздел: >=80% должен
|
||||||
|
// получить маркер, а <80% и заголовок — нет (раньше фильтр >90% скрывал 84%).
|
||||||
|
func TestHighlightHighUsage(t *testing.T) {
|
||||||
|
in := "Filesystem 1024-blocks Used Available Capacity Mounted-on\n" +
|
||||||
|
"/dev/mapper/opt-opt 128G 102G 20G 84% /opt\n" +
|
||||||
|
"/dev/sda1 30G 25G 3.6G 84% /\n"
|
||||||
|
out := highlightHighUsage(in)
|
||||||
|
if !strings.Contains(out, "/opt") {
|
||||||
|
t.Fatalf("highlightHighUsage dropped /opt: %q", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "<< USE>=80%") {
|
||||||
|
t.Errorf("highlightHighUsage should mark >=80%% partition: %q", out)
|
||||||
|
}
|
||||||
|
// Колонка 5 у /opt — "84%"; маркер должен стоять на этой строке.
|
||||||
|
var marked bool
|
||||||
|
for _, line := range strings.Split(out, "\n") {
|
||||||
|
if strings.Contains(line, "/opt") && strings.Contains(line, "84%") {
|
||||||
|
if !strings.Contains(line, "<< USE>=80%") {
|
||||||
|
t.Errorf("84%% partition not marked on its own line: %q", line)
|
||||||
|
}
|
||||||
|
marked = true
|
||||||
|
}
|
||||||
|
if strings.Contains(line, "Filesystem") && strings.Contains(line, "<< USE>=80%") {
|
||||||
|
t.Errorf("header must not be marked: %q", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !marked {
|
||||||
|
t.Errorf("the /opt line was not marked: %q", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"forge-tools-ssh/internal/ssh"
|
||||||
|
|
||||||
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
)
|
||||||
|
|
||||||
|
func registerDockerTools(s *mcp.Server) {
|
||||||
|
// docker_ps
|
||||||
|
s.AddTool(&mcp.Tool{
|
||||||
|
Name: "docker_ps",
|
||||||
|
Description: "List Docker containers",
|
||||||
|
InputSchema: schema(map[string]any{
|
||||||
|
"all": boolProps("Show all containers (default: only running)", false),
|
||||||
|
"target": strProps("Connection alias (default: primary)", false),
|
||||||
|
}, nil),
|
||||||
|
}, dockerPsHandler)
|
||||||
|
|
||||||
|
// docker_logs
|
||||||
|
s.AddTool(&mcp.Tool{
|
||||||
|
Name: "docker_logs",
|
||||||
|
Description: "Get logs from a Docker container",
|
||||||
|
InputSchema: schema(map[string]any{
|
||||||
|
"container": strProps("Container name or ID", true),
|
||||||
|
"lines": intProps("Number of lines (default: 50)", false),
|
||||||
|
"target": strProps("Connection alias (default: primary)", false),
|
||||||
|
}, []string{"container"}),
|
||||||
|
}, dockerLogsHandler)
|
||||||
|
|
||||||
|
// docker_op
|
||||||
|
s.AddTool(&mcp.Tool{
|
||||||
|
Name: "docker_op",
|
||||||
|
Description: "Start, stop, or restart a Docker container",
|
||||||
|
InputSchema: schema(map[string]any{
|
||||||
|
"container": strProps("Container name or ID", true),
|
||||||
|
"action": strProps("Action to perform on the container", true, "start", "stop", "restart"),
|
||||||
|
"target": strProps("Connection alias (default: primary)", false),
|
||||||
|
}, []string{"container", "action"}),
|
||||||
|
}, dockerOpHandler)
|
||||||
|
|
||||||
|
// docker_ip
|
||||||
|
s.AddTool(&mcp.Tool{
|
||||||
|
Name: "docker_ip",
|
||||||
|
Description: "Get IP address(es) of a Docker container",
|
||||||
|
InputSchema: schema(map[string]any{
|
||||||
|
"container": strProps("Container name", true),
|
||||||
|
"target": strProps("Connection alias (default: primary)", false),
|
||||||
|
}, []string{"container"}),
|
||||||
|
}, dockerIPHandler)
|
||||||
|
|
||||||
|
// docker_find_by_ip
|
||||||
|
s.AddTool(&mcp.Tool{
|
||||||
|
Name: "docker_find_by_ip",
|
||||||
|
Description: "Find which Docker container has a specific IP",
|
||||||
|
InputSchema: schema(map[string]any{
|
||||||
|
"ip": strProps("IP address to search", true),
|
||||||
|
"target": strProps("Connection alias (default: primary)", false),
|
||||||
|
}, []string{"ip"}),
|
||||||
|
}, dockerFindByIPHandler)
|
||||||
|
|
||||||
|
// docker_networks
|
||||||
|
s.AddTool(&mcp.Tool{
|
||||||
|
Name: "docker_networks",
|
||||||
|
Description: "List all Docker networks and their containers",
|
||||||
|
InputSchema: schema(map[string]any{
|
||||||
|
"target": strProps("Connection alias (default: primary)", false),
|
||||||
|
}, nil),
|
||||||
|
}, dockerNetworksHandler)
|
||||||
|
|
||||||
|
// docker_cp_from
|
||||||
|
s.AddTool(&mcp.Tool{
|
||||||
|
Name: "docker_cp_from",
|
||||||
|
Description: "Copy file from Docker container to host",
|
||||||
|
InputSchema: schema(map[string]any{
|
||||||
|
"container": strProps("Container name", true),
|
||||||
|
"container_path": strProps("Path inside container", true),
|
||||||
|
"host_path": strProps("Destination path on host", true),
|
||||||
|
"target": strProps("Connection alias (default: primary)", false),
|
||||||
|
}, []string{"container", "container_path", "host_path"}),
|
||||||
|
}, dockerCpFromHandler)
|
||||||
|
|
||||||
|
// docker_cp_to
|
||||||
|
s.AddTool(&mcp.Tool{
|
||||||
|
Name: "docker_cp_to",
|
||||||
|
Description: "Copy file from host to Docker container",
|
||||||
|
InputSchema: schema(map[string]any{
|
||||||
|
"host_path": strProps("Source path on host", true),
|
||||||
|
"container": strProps("Container name", true),
|
||||||
|
"container_path": strProps("Destination path inside container", true),
|
||||||
|
"target": strProps("Connection alias (default: primary)", false),
|
||||||
|
}, []string{"host_path", "container", "container_path"}),
|
||||||
|
}, dockerCpToHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func dockerPsHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
m := manager()
|
||||||
|
if m == nil {
|
||||||
|
return errorResult("no SSH manager initialized"), nil
|
||||||
|
}
|
||||||
|
args := requestArgs(req)
|
||||||
|
all := getBool(args, "all", false)
|
||||||
|
target := getString(args, "target", "primary")
|
||||||
|
|
||||||
|
if err := checkDockerAvailable(ctx, m, target); err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
flag := ""
|
||||||
|
if all {
|
||||||
|
flag = "-a"
|
||||||
|
}
|
||||||
|
cmd := fmt.Sprintf("docker ps %s --format 'table {{.ID}}\t{{.Image}}\t{{.Status}}\t{{.Names}}'", flag)
|
||||||
|
output, err := m.Execute(ctx, cmd, target)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
return textResult(output), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func dockerLogsHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
m := manager()
|
||||||
|
if m == nil {
|
||||||
|
return errorResult("no SSH manager initialized"), nil
|
||||||
|
}
|
||||||
|
args := requestArgs(req)
|
||||||
|
container, _ := requireString(args, "container")
|
||||||
|
lines := getInt(args, "lines", 50)
|
||||||
|
target := getString(args, "target", "primary")
|
||||||
|
|
||||||
|
if err := checkDockerAvailable(ctx, m, target); err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
cmd := fmt.Sprintf("docker logs --tail %d %s 2>&1", lines, shellQuote(container))
|
||||||
|
output, err := m.Execute(ctx, cmd, target)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
return textResult(output), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func dockerOpHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
m := manager()
|
||||||
|
if m == nil {
|
||||||
|
return errorResult("no SSH manager initialized"), nil
|
||||||
|
}
|
||||||
|
args := requestArgs(req)
|
||||||
|
container, _ := requireString(args, "container")
|
||||||
|
action, _ := requireString(args, "action")
|
||||||
|
target := getString(args, "target", "primary")
|
||||||
|
|
||||||
|
if action != "start" && action != "stop" && action != "restart" {
|
||||||
|
return errorResult("Invalid action. Use: start, stop, restart"), nil
|
||||||
|
}
|
||||||
|
if err := checkDockerAvailable(ctx, m, target); err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
cmd := fmt.Sprintf("docker %s %s 2>&1", shellQuote(action), shellQuote(container))
|
||||||
|
output, err := m.Execute(ctx, cmd, target)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
return textResult(fmt.Sprintf("%s: %s\n%s", action, container, output)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func dockerIPHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
m := manager()
|
||||||
|
if m == nil {
|
||||||
|
return errorResult("no SSH manager initialized"), nil
|
||||||
|
}
|
||||||
|
args := requestArgs(req)
|
||||||
|
container, _ := requireString(args, "container")
|
||||||
|
target := getString(args, "target", "primary")
|
||||||
|
|
||||||
|
if err := checkDockerAvailable(ctx, m, target); err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
cmd := fmt.Sprintf("docker inspect --format '{{range $net, $conf := .NetworkSettings.Networks}}{{$net}}:{{$conf.IPAddress}}|{{end}}' %s 2>/dev/null", shellQuote(container))
|
||||||
|
output, err := m.Execute(ctx, cmd, target)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
return textResult(fmt.Sprintf("Container: %s\nNetworks: %s", container, output)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func dockerFindByIPHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
m := manager()
|
||||||
|
if m == nil {
|
||||||
|
return errorResult("no SSH manager initialized"), nil
|
||||||
|
}
|
||||||
|
args := requestArgs(req)
|
||||||
|
ip, _ := requireString(args, "ip")
|
||||||
|
target := getString(args, "target", "primary")
|
||||||
|
|
||||||
|
if err := checkDockerAvailable(ctx, m, target); err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
cmd := fmt.Sprintf(`docker ps -q | xargs -I {} docker inspect --format '{{.Name}}|{{range $net, $conf := .NetworkSettings.Networks}}{{$net}}:{{$conf.IPAddress}},{{end}}' {} 2>/dev/null | grep %s`, shellQuote(ip))
|
||||||
|
output, err := m.Execute(ctx, cmd, target)
|
||||||
|
if err != nil {
|
||||||
|
return textResult(fmt.Sprintf("No container found with IP: %s", ip)), nil
|
||||||
|
}
|
||||||
|
return textResult(output), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func dockerNetworksHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
m := manager()
|
||||||
|
if m == nil {
|
||||||
|
return errorResult("no SSH manager initialized"), nil
|
||||||
|
}
|
||||||
|
target := getString(requestArgs(req), "target", "primary")
|
||||||
|
|
||||||
|
if err := checkDockerAvailable(ctx, m, target); err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
cmd := "docker network ls --format '{{.Name}} ({{.Driver}})'"
|
||||||
|
output, err := m.Execute(ctx, cmd, target)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
return textResult(output), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func dockerCpFromHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
m := manager()
|
||||||
|
if m == nil {
|
||||||
|
return errorResult("no SSH manager initialized"), nil
|
||||||
|
}
|
||||||
|
args := requestArgs(req)
|
||||||
|
container, _ := requireString(args, "container")
|
||||||
|
containerPath, _ := requireString(args, "container_path")
|
||||||
|
hostPath, _ := requireString(args, "host_path")
|
||||||
|
target := getString(args, "target", "primary")
|
||||||
|
|
||||||
|
if err := checkDockerAvailable(ctx, m, target); err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
cmd := fmt.Sprintf("docker cp %s:%s %s 2>&1", shellQuote(container), shellQuote(containerPath), shellQuote(hostPath))
|
||||||
|
output, err := m.Execute(ctx, cmd, target)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
if containsString(output, "Error") || containsString(output, "No such") {
|
||||||
|
return errorResult(output), nil
|
||||||
|
}
|
||||||
|
return textResult(fmt.Sprintf("Copied %s:%s to %s", container, containerPath, hostPath)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func dockerCpToHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
m := manager()
|
||||||
|
if m == nil {
|
||||||
|
return errorResult("no SSH manager initialized"), nil
|
||||||
|
}
|
||||||
|
args := requestArgs(req)
|
||||||
|
hostPath, _ := requireString(args, "host_path")
|
||||||
|
container, _ := requireString(args, "container")
|
||||||
|
containerPath, _ := requireString(args, "container_path")
|
||||||
|
target := getString(args, "target", "primary")
|
||||||
|
|
||||||
|
if err := checkDockerAvailable(ctx, m, target); err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
cmd := fmt.Sprintf("docker cp %s %s:%s 2>&1", shellQuote(hostPath), shellQuote(container), shellQuote(containerPath))
|
||||||
|
output, err := m.Execute(ctx, cmd, target)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
if containsString(output, "Error") || containsString(output, "No such") {
|
||||||
|
return errorResult(output), nil
|
||||||
|
}
|
||||||
|
return textResult(fmt.Sprintf("Copied %s to %s:%s", hostPath, container, containerPath)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkDockerAvailable(ctx context.Context, m *ssh.Manager, target string) error {
|
||||||
|
available, err := m.IsDockerAvailable(ctx, target)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !available {
|
||||||
|
return fmt.Errorf("docker command not found on target")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
)
|
||||||
|
|
||||||
|
func registerFileTools(s *mcp.Server) {
|
||||||
|
// read
|
||||||
|
s.AddTool(&mcp.Tool{
|
||||||
|
Name: "read",
|
||||||
|
Description: "Read the contents of a remote file",
|
||||||
|
InputSchema: schema(map[string]any{
|
||||||
|
"path": strProps("File path to read", true),
|
||||||
|
"target": strProps("Connection alias (default: primary)", false),
|
||||||
|
}, []string{"path"}),
|
||||||
|
}, readHandler)
|
||||||
|
|
||||||
|
// write
|
||||||
|
s.AddTool(&mcp.Tool{
|
||||||
|
Name: "write",
|
||||||
|
Description: "Write content to a remote file. Validates syntax BEFORE writing for known file types (JSON, YAML, TOML, XML, INI, Dockerfile). Validation is server-side with zero remote dependencies. Set skip_validate=true to bypass.",
|
||||||
|
InputSchema: schema(map[string]any{
|
||||||
|
"path": strProps("File path to write", true),
|
||||||
|
"content": strProps("Content to write", true),
|
||||||
|
"skip_validate": boolProps("Skip syntax validation before write (default: false)", false),
|
||||||
|
"target": strProps("Connection alias (default: primary)", false),
|
||||||
|
}, []string{"path", "content"}),
|
||||||
|
}, writeHandler)
|
||||||
|
|
||||||
|
// edit
|
||||||
|
s.AddTool(&mcp.Tool{
|
||||||
|
Name: "edit",
|
||||||
|
Description: `Powerful sed-like file editor. Supports multiple operations on any file type (YAML, JSON, conf, etc).
|
||||||
|
|
||||||
|
Operations (set via 'operation' parameter):
|
||||||
|
replace — Find and replace text (default). Exact literal match.
|
||||||
|
regex — Regex find and replace (sed-style). Use capture groups \1, \2, etc.
|
||||||
|
insert — Insert text at a specific line number (pushes existing content down).
|
||||||
|
append — Append text after a line matching a pattern, or at end of file if no pattern.
|
||||||
|
prepend — Prepend text before a line matching a pattern, or at start of file if no pattern.
|
||||||
|
delete — Delete lines matching a pattern or a line range.
|
||||||
|
replace_line — Replace entire line(s) matching a pattern with new text.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
operation=replace, old_text="port: 80", new_text="port: 443"
|
||||||
|
operation=regex, pattern="timeout:\\s*\\d+", replacement="timeout: 30"
|
||||||
|
operation=insert, line=5, content="new line here"
|
||||||
|
operation=append, pattern="\\[section\\]", content="key = value"
|
||||||
|
operation=delete, pattern="^#.*comment"
|
||||||
|
operation=delete, start_line=10, end_line=15
|
||||||
|
operation=replace_line, pattern="^server_name.*", content="server_name example.com;"
|
||||||
|
`,
|
||||||
|
InputSchema: schema(map[string]any{
|
||||||
|
"path": strProps("File path to edit", true),
|
||||||
|
"operation": strProps("Edit operation (default: replace)", false, "replace", "regex", "insert", "append", "prepend", "delete", "replace_line"),
|
||||||
|
"old_text": strProps("Text to find (for 'replace' operation)", false),
|
||||||
|
"new_text": strProps("Replacement text (for 'replace' operation)", false),
|
||||||
|
"pattern": strProps("Regex pattern (for regex/append/prepend/delete/replace_line operations)", false),
|
||||||
|
"replacement": strProps("Replacement string with \\1 \\2 backrefs (for 'regex' operation)", false),
|
||||||
|
"content": strProps("Content to insert/append/prepend/replace_line", false),
|
||||||
|
"line": intProps("Line number for 'insert' operation (1-based)", false),
|
||||||
|
"start_line": intProps("Start line for range delete (1-based, inclusive)", false),
|
||||||
|
"end_line": intProps("End line for range delete (1-based, inclusive)", false),
|
||||||
|
"global": boolProps("Replace all occurrences (default: false for replace, true for regex)", false),
|
||||||
|
"target": strProps("Connection alias (default: primary)", false),
|
||||||
|
}, []string{"path"}),
|
||||||
|
}, editHandler)
|
||||||
|
|
||||||
|
// validate
|
||||||
|
s.AddTool(&mcp.Tool{
|
||||||
|
Name: "validate",
|
||||||
|
Description: `Validate file syntax server-side (zero remote host dependencies). Auto-detects type from extension.
|
||||||
|
|
||||||
|
Supported formats:
|
||||||
|
.json — JSON syntax
|
||||||
|
.yaml, .yml — YAML syntax (multi-document)
|
||||||
|
.toml — TOML syntax
|
||||||
|
.xml, .svg, .xhtml — XML well-formedness
|
||||||
|
.ini, .cfg, .conf — INI key=value structure
|
||||||
|
.env — Dotenv KEY=VALUE format
|
||||||
|
Dockerfile — Instruction validation
|
||||||
|
|
||||||
|
All validation runs on the MCP server using Go parsers. No python3, jq, or other tools needed on the remote host.`,
|
||||||
|
InputSchema: schema(map[string]any{
|
||||||
|
"path": strProps("File path to validate", true),
|
||||||
|
"type": strProps("Force file type (auto-detected from extension if omitted)", false, "json", "yaml", "toml", "xml", "ini", "env", "dockerfile"),
|
||||||
|
"target": strProps("Connection alias (default: primary)", false),
|
||||||
|
}, []string{"path"}),
|
||||||
|
}, validateHandler)
|
||||||
|
|
||||||
|
// list_dir
|
||||||
|
s.AddTool(&mcp.Tool{
|
||||||
|
Name: "list_dir",
|
||||||
|
Description: "List contents of a remote directory",
|
||||||
|
InputSchema: schema(map[string]any{
|
||||||
|
"path": strProps("Directory path to list", true),
|
||||||
|
"target": strProps("Connection alias (default: primary)", false),
|
||||||
|
}, []string{"path"}),
|
||||||
|
}, listDirHandler)
|
||||||
|
|
||||||
|
// sync
|
||||||
|
s.AddTool(&mcp.Tool{
|
||||||
|
Name: "sync",
|
||||||
|
Description: "Stream a file directly between two remote nodes",
|
||||||
|
InputSchema: schema(map[string]any{
|
||||||
|
"source_node": strProps("Source connection alias", true),
|
||||||
|
"source_path": strProps("Source file path", true),
|
||||||
|
"dest_node": strProps("Destination connection alias", true),
|
||||||
|
"dest_path": strProps("Destination file path", true),
|
||||||
|
}, []string{"source_node", "source_path", "dest_node", "dest_path"}),
|
||||||
|
}, syncHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
m := manager()
|
||||||
|
if m == nil {
|
||||||
|
return errorResult("no SSH manager initialized"), nil
|
||||||
|
}
|
||||||
|
path, _ := requireString(requestArgs(req), "path")
|
||||||
|
target := getString(requestArgs(req), "target", "primary")
|
||||||
|
content, err := m.ReadFile(ctx, path, target)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
return textResult(content), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
m := manager()
|
||||||
|
if m == nil {
|
||||||
|
return errorResult("no SSH manager initialized"), nil
|
||||||
|
}
|
||||||
|
args := requestArgs(req)
|
||||||
|
path, _ := requireString(args, "path")
|
||||||
|
content, _ := requireString(args, "content")
|
||||||
|
skipValidate := getBool(args, "skip_validate", false)
|
||||||
|
target := getString(args, "target", "primary")
|
||||||
|
|
||||||
|
if !skipValidate {
|
||||||
|
if fileType := detectFileType(path); fileType != "" {
|
||||||
|
if result := ValidateContent(content, fileType); result != nil && !result.Valid {
|
||||||
|
return errorResult(fmt.Sprintf(
|
||||||
|
"Syntax validation failed — file NOT written.\n%s\n\nFix the errors above or set skip_validate=true to force write.",
|
||||||
|
result.FormatResult(path))), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.WriteFile(ctx, path, content, target); err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := fmt.Sprintf("Successfully wrote %d bytes to %s", len(content), path)
|
||||||
|
if !skipValidate {
|
||||||
|
if fileType := detectFileType(path); fileType != "" {
|
||||||
|
msg += fmt.Sprintf("\n✓ Syntax (%s): OK", fileType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return textResult(msg), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func editHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
m := manager()
|
||||||
|
if m == nil {
|
||||||
|
return errorResult("no SSH manager initialized"), nil
|
||||||
|
}
|
||||||
|
args := requestArgs(req)
|
||||||
|
path, _ := requireString(args, "path")
|
||||||
|
operation := getString(args, "operation", "replace")
|
||||||
|
target := getString(args, "target", "primary")
|
||||||
|
|
||||||
|
var cmd string
|
||||||
|
switch operation {
|
||||||
|
case "replace":
|
||||||
|
oldText := getString(args, "old_text", "")
|
||||||
|
newText := getString(args, "new_text", "")
|
||||||
|
if oldText == "" {
|
||||||
|
return errorResult("'old_text' is required for replace operation"), nil
|
||||||
|
}
|
||||||
|
globalFlag := ""
|
||||||
|
if getBool(args, "global", false) {
|
||||||
|
globalFlag = "g"
|
||||||
|
}
|
||||||
|
expr := fmt.Sprintf("s/%s/%s/%s",
|
||||||
|
sedEscapeLiteral(oldText), sedEscapeReplacement(newText), globalFlag)
|
||||||
|
cmd = sedInPlace("", expr, path)
|
||||||
|
|
||||||
|
case "regex":
|
||||||
|
pattern := getString(args, "pattern", "")
|
||||||
|
replacement := getString(args, "replacement", "")
|
||||||
|
if pattern == "" {
|
||||||
|
return errorResult("'pattern' is required for regex operation"), nil
|
||||||
|
}
|
||||||
|
globalFlag := "g"
|
||||||
|
if !getBool(args, "global", true) {
|
||||||
|
globalFlag = ""
|
||||||
|
}
|
||||||
|
expr := fmt.Sprintf("s/%s/%s/%s",
|
||||||
|
sedEscapePattern(pattern), sedEscapeReplacement(replacement), globalFlag)
|
||||||
|
cmd = sedInPlace("-E", expr, path)
|
||||||
|
|
||||||
|
case "insert":
|
||||||
|
lineNum := getInt(args, "line", 0)
|
||||||
|
content := getString(args, "content", "")
|
||||||
|
if lineNum <= 0 {
|
||||||
|
return errorResult("'line' (positive integer) is required for insert operation"), nil
|
||||||
|
}
|
||||||
|
if content == "" {
|
||||||
|
return errorResult("'content' is required for insert operation"), nil
|
||||||
|
}
|
||||||
|
expr := fmt.Sprintf("%di\\%s", lineNum, sedEscapeInsertText(content))
|
||||||
|
cmd = sedInPlace("", expr, path)
|
||||||
|
|
||||||
|
case "append":
|
||||||
|
content := getString(args, "content", "")
|
||||||
|
pattern := getString(args, "pattern", "")
|
||||||
|
if content == "" {
|
||||||
|
return errorResult("'content' is required for append operation"), nil
|
||||||
|
}
|
||||||
|
if pattern != "" {
|
||||||
|
expr := fmt.Sprintf("/%s/a\\%s",
|
||||||
|
sedEscapePattern(pattern), sedEscapeInsertText(content))
|
||||||
|
cmd = sedInPlace("", expr, path)
|
||||||
|
} else {
|
||||||
|
cmd = fmt.Sprintf("printf '\\n%%s' %s >> %s 2>&1",
|
||||||
|
shellQuote(content), shellQuote(path))
|
||||||
|
}
|
||||||
|
|
||||||
|
case "prepend":
|
||||||
|
content := getString(args, "content", "")
|
||||||
|
pattern := getString(args, "pattern", "")
|
||||||
|
if content == "" {
|
||||||
|
return errorResult("'content' is required for prepend operation"), nil
|
||||||
|
}
|
||||||
|
if pattern != "" {
|
||||||
|
expr := fmt.Sprintf("/%s/i\\%s",
|
||||||
|
sedEscapePattern(pattern), sedEscapeInsertText(content))
|
||||||
|
cmd = sedInPlace("", expr, path)
|
||||||
|
} else {
|
||||||
|
expr := fmt.Sprintf("1i\\%s", sedEscapeInsertText(content))
|
||||||
|
cmd = sedInPlace("", expr, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
case "delete":
|
||||||
|
pattern := getString(args, "pattern", "")
|
||||||
|
startLine := getInt(args, "start_line", 0)
|
||||||
|
endLine := getInt(args, "end_line", 0)
|
||||||
|
if pattern != "" {
|
||||||
|
expr := fmt.Sprintf("/%s/d", sedEscapePattern(pattern))
|
||||||
|
cmd = sedInPlace("", expr, path)
|
||||||
|
} else if startLine > 0 && endLine > 0 {
|
||||||
|
expr := fmt.Sprintf("%d,%dd", startLine, endLine)
|
||||||
|
cmd = sedInPlace("", expr, path)
|
||||||
|
} else if startLine > 0 {
|
||||||
|
expr := fmt.Sprintf("%dd", startLine)
|
||||||
|
cmd = sedInPlace("", expr, path)
|
||||||
|
} else {
|
||||||
|
return errorResult("'pattern' or 'start_line' is required for delete operation"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
case "replace_line":
|
||||||
|
pattern := getString(args, "pattern", "")
|
||||||
|
content := getString(args, "content", "")
|
||||||
|
if pattern == "" {
|
||||||
|
return errorResult("'pattern' is required for replace_line operation"), nil
|
||||||
|
}
|
||||||
|
expr := fmt.Sprintf("s/%s/%s/",
|
||||||
|
sedEscapePattern(pattern), sedEscapeReplacement(content))
|
||||||
|
cmd = sedInPlace("-E", expr, path)
|
||||||
|
|
||||||
|
default:
|
||||||
|
return errorResult(fmt.Sprintf(
|
||||||
|
"Unknown operation: '%s'. Supported: replace, regex, insert, append, prepend, delete, replace_line", operation)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
output, err := m.Execute(ctx, cmd, target)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := ""
|
||||||
|
if output == "(No output)" || strings.TrimSpace(output) == "" {
|
||||||
|
msg = fmt.Sprintf("Successfully applied '%s' operation to %s", operation, path)
|
||||||
|
} else {
|
||||||
|
msg = output
|
||||||
|
}
|
||||||
|
|
||||||
|
if fileType := detectFileType(path); fileType != "" {
|
||||||
|
if updated, readErr := m.ReadFile(ctx, path, target); readErr == nil {
|
||||||
|
if result := ValidateContent(updated, fileType); result != nil {
|
||||||
|
if result.Valid {
|
||||||
|
msg += fmt.Sprintf("\n✓ Syntax (%s): OK", fileType)
|
||||||
|
} else {
|
||||||
|
msg += fmt.Sprintf("\n\n⚠ Syntax (%s): BROKEN after edit\n%s",
|
||||||
|
fileType, result.FormatResult(path))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return textResult(msg), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func listDirHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
m := manager()
|
||||||
|
if m == nil {
|
||||||
|
return errorResult("no SSH manager initialized"), nil
|
||||||
|
}
|
||||||
|
path, _ := requireString(requestArgs(req), "path")
|
||||||
|
target := getString(requestArgs(req), "target", "primary")
|
||||||
|
files, err := m.ListDir(ctx, path, target)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
jsonBytes, err := json.MarshalIndent(files, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return errorResult("Failed to format directory listing"), nil
|
||||||
|
}
|
||||||
|
return textResult(string(jsonBytes)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func syncHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
m := manager()
|
||||||
|
if m == nil {
|
||||||
|
return errorResult("no SSH manager initialized"), nil
|
||||||
|
}
|
||||||
|
args := requestArgs(req)
|
||||||
|
sourceNode, _ := requireString(args, "source_node")
|
||||||
|
sourcePath, _ := requireString(args, "source_path")
|
||||||
|
destNode, _ := requireString(args, "dest_node")
|
||||||
|
destPath, _ := requireString(args, "dest_path")
|
||||||
|
|
||||||
|
content, err := m.ReadFile(ctx, sourcePath, sourceNode)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(fmt.Sprintf("Failed to read from source: %v", err)), nil
|
||||||
|
}
|
||||||
|
if err := m.WriteFile(ctx, destPath, content, destNode); err != nil {
|
||||||
|
return errorResult(fmt.Sprintf("Failed to write to destination: %v", err)), nil
|
||||||
|
}
|
||||||
|
return textResult(fmt.Sprintf("Successfully synced %d bytes from %s to %s", len(content), sourceNode, destNode)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
m := manager()
|
||||||
|
if m == nil {
|
||||||
|
return errorResult("no SSH manager initialized"), nil
|
||||||
|
}
|
||||||
|
args := requestArgs(req)
|
||||||
|
path, _ := requireString(args, "path")
|
||||||
|
forceType := getString(args, "type", "")
|
||||||
|
target := getString(args, "target", "primary")
|
||||||
|
|
||||||
|
fileType := forceType
|
||||||
|
if fileType == "" {
|
||||||
|
fileType = detectFileType(path)
|
||||||
|
}
|
||||||
|
if fileType == "" {
|
||||||
|
return errorResult(fmt.Sprintf(
|
||||||
|
"Cannot detect file type for '%s'. Use the 'type' parameter to specify: json, yaml, toml, xml, ini, env, dockerfile", path)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := m.ReadFile(ctx, path, target)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
result := ValidateContent(content, fileType)
|
||||||
|
if result == nil {
|
||||||
|
return errorResult(fmt.Sprintf("No server-side validator for type '%s'", fileType)), nil
|
||||||
|
}
|
||||||
|
return textResult(result.FormatResult(path)), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
)
|
||||||
|
|
||||||
|
func registerMonitoringTools(s *mcp.Server) {
|
||||||
|
// usage
|
||||||
|
s.AddTool(&mcp.Tool{
|
||||||
|
Name: "usage",
|
||||||
|
Description: "Get CPU/RAM/Disk usage summary",
|
||||||
|
InputSchema: schema(map[string]any{
|
||||||
|
"target": strProps("Connection alias (default: primary)", false),
|
||||||
|
}, nil),
|
||||||
|
}, usageHandler)
|
||||||
|
|
||||||
|
// ps
|
||||||
|
s.AddTool(&mcp.Tool{
|
||||||
|
Name: "ps",
|
||||||
|
Description: "List top processes sorted by CPU or memory",
|
||||||
|
InputSchema: schema(map[string]any{
|
||||||
|
"sort_by": strProps("Sort field (default: cpu)", false, "cpu", "mem"),
|
||||||
|
"limit": intProps("Number of processes to show (default: 10)", false),
|
||||||
|
"target": strProps("Connection alias (default: primary)", false),
|
||||||
|
}, nil),
|
||||||
|
}, psHandler)
|
||||||
|
|
||||||
|
// logs
|
||||||
|
s.AddTool(&mcp.Tool{
|
||||||
|
Name: "logs",
|
||||||
|
Description: "Read the tail of a log file",
|
||||||
|
InputSchema: schema(map[string]any{
|
||||||
|
"path": strProps("Path to log file", true),
|
||||||
|
"lines": intProps("Number of lines to read (default: 50, max: 500)", false),
|
||||||
|
"grep": strProps("Optional filter pattern", false),
|
||||||
|
"target": strProps("Connection alias (default: primary)", false),
|
||||||
|
}, []string{"path"}),
|
||||||
|
}, logsHandler)
|
||||||
|
|
||||||
|
// journal_read
|
||||||
|
s.AddTool(&mcp.Tool{
|
||||||
|
Name: "journal_read",
|
||||||
|
Description: "Read system logs (journalctl/syslog)",
|
||||||
|
InputSchema: schema(map[string]any{
|
||||||
|
"service": strProps("Service name to filter (e.g., nginx, sshd)", false),
|
||||||
|
"since": strProps("Time filter (e.g., '1 hour ago')", false),
|
||||||
|
"lines": intProps("Number of lines (default: 100, max: 500)", false),
|
||||||
|
"priority": strProps("Minimum log priority level", false, "emerg", "alert", "crit", "err", "warning", "notice", "info", "debug"),
|
||||||
|
"target": strProps("Connection alias (default: primary)", false),
|
||||||
|
}, nil),
|
||||||
|
}, journalReadHandler)
|
||||||
|
|
||||||
|
// dmesg_read
|
||||||
|
s.AddTool(&mcp.Tool{
|
||||||
|
Name: "dmesg_read",
|
||||||
|
Description: "Read kernel ring buffer (dmesg)",
|
||||||
|
InputSchema: schema(map[string]any{
|
||||||
|
"grep": strProps("Optional pattern to filter messages", false),
|
||||||
|
"lines": intProps("Number of lines (default: 100)", false),
|
||||||
|
"target": strProps("Connection alias (default: primary)", false),
|
||||||
|
}, nil),
|
||||||
|
}, dmesgReadHandler)
|
||||||
|
|
||||||
|
// diagnose_system
|
||||||
|
s.AddTool(&mcp.Tool{
|
||||||
|
Name: "diagnose_system",
|
||||||
|
Description: "One-click SRE health check: load, OOM, disk, failed services",
|
||||||
|
InputSchema: schema(map[string]any{
|
||||||
|
"target": strProps("Connection alias (default: primary)", false),
|
||||||
|
}, nil),
|
||||||
|
}, diagnoseHandler)
|
||||||
|
|
||||||
|
// list_services
|
||||||
|
s.AddTool(&mcp.Tool{
|
||||||
|
Name: "list_services",
|
||||||
|
Description: "List system services (systemd/OpenRC)",
|
||||||
|
InputSchema: schema(map[string]any{
|
||||||
|
"failed_only": boolProps("Show only failed services", false),
|
||||||
|
"target": strProps("Connection alias (default: primary)", false),
|
||||||
|
}, nil),
|
||||||
|
}, listServicesHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func usageHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
m := manager()
|
||||||
|
if m == nil {
|
||||||
|
return errorResult("no SSH manager initialized"), nil
|
||||||
|
}
|
||||||
|
target := getString(requestArgs(req), "target", "primary")
|
||||||
|
cmd := `
|
||||||
|
echo "=== LOAD AVERAGE ==="
|
||||||
|
uptime 2>/dev/null
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== MEMORY ==="
|
||||||
|
free -h 2>/dev/null || top -l 1 -s 0 2>/dev/null | grep -i phys || cat /proc/meminfo 2>/dev/null | head -5
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== DISK (all mounts) ==="
|
||||||
|
df -hP 2>/dev/null
|
||||||
|
`
|
||||||
|
output, err := m.Execute(ctx, cmd, target)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
return textResult(output), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func psHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
m := manager()
|
||||||
|
if m == nil {
|
||||||
|
return errorResult("no SSH manager initialized"), nil
|
||||||
|
}
|
||||||
|
args := requestArgs(req)
|
||||||
|
sortBy := getString(args, "sort_by", "cpu")
|
||||||
|
limit := getInt(args, "limit", 10)
|
||||||
|
target := getString(args, "target", "primary")
|
||||||
|
|
||||||
|
if limit > 50 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
sortCol := "3"
|
||||||
|
if sortBy == "mem" {
|
||||||
|
sortCol = "4"
|
||||||
|
}
|
||||||
|
cmd := fmt.Sprintf("ps -eo pid,user,%%cpu,%%mem,comm | awk 'NR==1{print} NR>1{print | \"sort -k%s -rn\"}' | head -n %d", sortCol, limit+1)
|
||||||
|
output, err := m.Execute(ctx, cmd, target)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
return textResult(output), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func logsHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
m := manager()
|
||||||
|
if m == nil {
|
||||||
|
return errorResult("no SSH manager initialized"), nil
|
||||||
|
}
|
||||||
|
args := requestArgs(req)
|
||||||
|
path, _ := requireString(args, "path")
|
||||||
|
lines := getInt(args, "lines", 50)
|
||||||
|
grep := getString(args, "grep", "")
|
||||||
|
target := getString(args, "target", "primary")
|
||||||
|
|
||||||
|
if lines > 500 {
|
||||||
|
lines = 500
|
||||||
|
}
|
||||||
|
cmd := fmt.Sprintf("tail -n %d %s", lines, shellQuote(path))
|
||||||
|
if grep != "" {
|
||||||
|
cmd += fmt.Sprintf(" | grep %s", shellQuote(grep))
|
||||||
|
}
|
||||||
|
output, err := m.Execute(ctx, cmd, target)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
return textResult(output), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func journalReadHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
m := manager()
|
||||||
|
if m == nil {
|
||||||
|
return errorResult("no SSH manager initialized"), nil
|
||||||
|
}
|
||||||
|
args := requestArgs(req)
|
||||||
|
service := getString(args, "service", "")
|
||||||
|
since := getString(args, "since", "")
|
||||||
|
lines := getInt(args, "lines", 100)
|
||||||
|
priority := getString(args, "priority", "")
|
||||||
|
target := getString(args, "target", "primary")
|
||||||
|
|
||||||
|
if lines > 500 {
|
||||||
|
lines = 500
|
||||||
|
}
|
||||||
|
checkCmd := "command -v journalctl >/dev/null 2>&1 && echo 'systemd' || echo 'syslog'"
|
||||||
|
checkOutput, err := m.Execute(ctx, checkCmd, target)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var cmd string
|
||||||
|
if containsString(checkOutput, "systemd") {
|
||||||
|
cmd = "journalctl --no-pager"
|
||||||
|
if service != "" {
|
||||||
|
cmd += fmt.Sprintf(" -u %s", shellQuote(service))
|
||||||
|
}
|
||||||
|
if since != "" {
|
||||||
|
cmd += fmt.Sprintf(" --since %s", shellQuote(since))
|
||||||
|
}
|
||||||
|
if priority != "" {
|
||||||
|
cmd += fmt.Sprintf(" -p %s", shellQuote(priority))
|
||||||
|
}
|
||||||
|
cmd += fmt.Sprintf(" -n %d 2>/dev/null", lines)
|
||||||
|
} else {
|
||||||
|
cmd = fmt.Sprintf("cat /var/log/syslog /var/log/messages /var/log/system.log 2>/dev/null | tail -n %d", lines)
|
||||||
|
if service != "" {
|
||||||
|
cmd += fmt.Sprintf(" | grep -i %s", shellQuote(service))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
output, err := m.Execute(ctx, cmd, target)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
return textResult(output), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func dmesgReadHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
m := manager()
|
||||||
|
if m == nil {
|
||||||
|
return errorResult("no SSH manager initialized"), nil
|
||||||
|
}
|
||||||
|
args := requestArgs(req)
|
||||||
|
grep := getString(args, "grep", "")
|
||||||
|
lines := getInt(args, "lines", 100)
|
||||||
|
target := getString(args, "target", "primary")
|
||||||
|
|
||||||
|
if lines > 500 {
|
||||||
|
lines = 500
|
||||||
|
}
|
||||||
|
cmd := "dmesg --time-format iso 2>/dev/null || dmesg 2>/dev/null"
|
||||||
|
if grep != "" {
|
||||||
|
cmd += fmt.Sprintf(" | grep -i %s", shellQuote(grep))
|
||||||
|
}
|
||||||
|
cmd += fmt.Sprintf(" | tail -n %d", lines)
|
||||||
|
|
||||||
|
output, err := m.Execute(ctx, cmd, target)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
return textResult(output), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func diagnoseHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
m := manager()
|
||||||
|
if m == nil {
|
||||||
|
return errorResult("no SSH manager initialized"), nil
|
||||||
|
}
|
||||||
|
target := getString(requestArgs(req), "target", "primary")
|
||||||
|
cmd := `
|
||||||
|
echo "=== SYSTEM HEALTH DIAGNOSTIC ==="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "--- LOAD AVERAGE ---"
|
||||||
|
LOAD=$(uptime 2>/dev/null | awk -F'load average[s]?: ' '{print $2}' | awk -F'[, ]' '{print $1}')
|
||||||
|
CPUS=$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || getconf _NPROCESSORS_ONLN 2>/dev/null || echo 1)
|
||||||
|
echo "Load (1min): $LOAD (CPUs: $CPUS)"
|
||||||
|
if echo "$LOAD $CPUS" | awk '{if ($1 > $2 * 2) exit 0; else exit 1}'; then
|
||||||
|
echo "WARNING: High load detected!"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "--- TOP CPU CONSUMERS ---"
|
||||||
|
ps -eo pid,user,%cpu,%mem,comm | awk 'NR==1{print} NR>1{print | "sort -k3 -rn"}' | head -n 6
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "--- OOM EVENTS ---"
|
||||||
|
OOM=$(dmesg 2>/dev/null | grep -i 'out of memory' | tail -n 3)
|
||||||
|
if [ -n "$OOM" ]; then
|
||||||
|
echo "$OOM"
|
||||||
|
echo "WARNING: OOM events found!"
|
||||||
|
else
|
||||||
|
echo "No OOM events in dmesg"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "--- DISK PRESSURE (>=80%) ---"
|
||||||
|
df -hP 2>/dev/null | awk 'NR==1{print; next} {c=$5; sub(/%/,"",c); m=(c+0>=80 ? " <<ALERT" : ""); print $0 m}'
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "--- FAILED SERVICES ---"
|
||||||
|
if command -v systemctl >/dev/null 2>&1; then
|
||||||
|
FAILED=$(systemctl --failed --no-legend --no-pager 2>/dev/null | head -n 5)
|
||||||
|
if [ -n "$FAILED" ]; then
|
||||||
|
echo "$FAILED"
|
||||||
|
else
|
||||||
|
echo "No failed services"
|
||||||
|
fi
|
||||||
|
elif command -v rc-status >/dev/null 2>&1; then
|
||||||
|
rc-status --crashed 2>/dev/null | head -n 5 || echo "No crashed services"
|
||||||
|
else
|
||||||
|
echo "Init system not detected"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== END DIAGNOSTIC ==="
|
||||||
|
`
|
||||||
|
output, err := m.Execute(ctx, cmd, target)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
return textResult(output), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func listServicesHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
m := manager()
|
||||||
|
if m == nil {
|
||||||
|
return errorResult("no SSH manager initialized"), nil
|
||||||
|
}
|
||||||
|
args := requestArgs(req)
|
||||||
|
failedOnly := getBool(args, "failed_only", false)
|
||||||
|
target := getString(args, "target", "primary")
|
||||||
|
|
||||||
|
var cmd string
|
||||||
|
if failedOnly {
|
||||||
|
cmd = `if command -v systemctl >/dev/null 2>&1; then systemctl --failed --no-pager 2>/dev/null; elif command -v rc-status >/dev/null 2>&1; then rc-status --crashed 2>/dev/null; elif command -v launchctl >/dev/null 2>&1; then launchctl list 2>/dev/null | head -50; else echo "No supported init system detected"; fi`
|
||||||
|
} else {
|
||||||
|
cmd = `if command -v systemctl >/dev/null 2>&1; then systemctl list-units --type=service --no-pager 2>/dev/null | head -50; elif command -v rc-status >/dev/null 2>&1; then rc-status 2>/dev/null; elif command -v launchctl >/dev/null 2>&1; then launchctl list 2>/dev/null | head -50; elif [ -x /usr/sbin/service ]; then service -e 2>/dev/null | head -50; else echo "No supported init system detected"; fi`
|
||||||
|
}
|
||||||
|
output, err := m.Execute(ctx, cmd, target)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
return textResult(output), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
)
|
||||||
|
|
||||||
|
func registerNetworkTools(s *mcp.Server) {
|
||||||
|
// net_stat
|
||||||
|
s.AddTool(&mcp.Tool{
|
||||||
|
Name: "net_stat",
|
||||||
|
Description: "Check listening ports (ss/netstat)",
|
||||||
|
InputSchema: schema(map[string]any{
|
||||||
|
"port": intProps("Filter by specific port", false),
|
||||||
|
"target": strProps("Connection alias (default: primary)", false),
|
||||||
|
}, nil),
|
||||||
|
}, netStatHandler)
|
||||||
|
|
||||||
|
// search_files
|
||||||
|
s.AddTool(&mcp.Tool{
|
||||||
|
Name: "search_files",
|
||||||
|
Description: "Find files using POSIX find",
|
||||||
|
InputSchema: schema(map[string]any{
|
||||||
|
"pattern": strProps("File name pattern (supports wildcards)", true),
|
||||||
|
"path": strProps("Search path (default: /)", false),
|
||||||
|
"max_depth": intProps("Maximum directory depth", false),
|
||||||
|
"type": strProps("Filter by type: f (file), d (directory)", false, "f", "d"),
|
||||||
|
"target": strProps("Connection alias (default: primary)", false),
|
||||||
|
}, []string{"pattern"}),
|
||||||
|
}, searchFilesHandler)
|
||||||
|
|
||||||
|
// search_text
|
||||||
|
s.AddTool(&mcp.Tool{
|
||||||
|
Name: "search_text",
|
||||||
|
Description: "Search text in files using grep",
|
||||||
|
InputSchema: schema(map[string]any{
|
||||||
|
"pattern": strProps("Search pattern", true),
|
||||||
|
"path": strProps("File or directory path", true),
|
||||||
|
"recursive": boolProps("Search recursively", false),
|
||||||
|
"ignore_case": boolProps("Case-insensitive search", false),
|
||||||
|
"context": intProps("Lines of context around matches", false),
|
||||||
|
"target": strProps("Connection alias (default: primary)", false),
|
||||||
|
}, []string{"pattern", "path"}),
|
||||||
|
}, searchTextHandler)
|
||||||
|
|
||||||
|
// package_manage
|
||||||
|
s.AddTool(&mcp.Tool{
|
||||||
|
Name: "package_manage",
|
||||||
|
Description: "Install/remove/check packages (apt, apk, dnf, yum)",
|
||||||
|
InputSchema: schema(map[string]any{
|
||||||
|
"action": strProps("Package management action", true, "install", "remove", "check", "list"),
|
||||||
|
"package": strProps("Package name (required for install/remove/check)", false),
|
||||||
|
"target": strProps("Connection alias (default: primary)", false),
|
||||||
|
}, []string{"action"}),
|
||||||
|
}, packageManageHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func netStatHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
m := manager()
|
||||||
|
if m == nil {
|
||||||
|
return errorResult("no SSH manager initialized"), nil
|
||||||
|
}
|
||||||
|
args := requestArgs(req)
|
||||||
|
port := getInt(args, "port", 0)
|
||||||
|
target := getString(args, "target", "primary")
|
||||||
|
|
||||||
|
var cmd string
|
||||||
|
if port > 0 {
|
||||||
|
cmd = fmt.Sprintf("ss -tlnp 2>/dev/null | grep ':%d ' || netstat -an 2>/dev/null | grep -i listen | grep '[\\.: ]%d '", port, port)
|
||||||
|
} else {
|
||||||
|
cmd = "ss -tlnp 2>/dev/null || netstat -an 2>/dev/null | grep -i listen"
|
||||||
|
}
|
||||||
|
output, err := m.Execute(ctx, cmd, target)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
return textResult(output), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func searchFilesHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
m := manager()
|
||||||
|
if m == nil {
|
||||||
|
return errorResult("no SSH manager initialized"), nil
|
||||||
|
}
|
||||||
|
args := requestArgs(req)
|
||||||
|
pattern, _ := requireString(args, "pattern")
|
||||||
|
path := getString(args, "path", "/")
|
||||||
|
maxDepth := getInt(args, "max_depth", 0)
|
||||||
|
fileType := getString(args, "type", "")
|
||||||
|
target := getString(args, "target", "primary")
|
||||||
|
|
||||||
|
cmd := fmt.Sprintf("find %s", shellQuote(path))
|
||||||
|
if maxDepth > 0 {
|
||||||
|
cmd += fmt.Sprintf(" -maxdepth %d", maxDepth)
|
||||||
|
}
|
||||||
|
if fileType == "f" || fileType == "d" {
|
||||||
|
cmd += fmt.Sprintf(" -type %s", fileType)
|
||||||
|
}
|
||||||
|
cmd += fmt.Sprintf(" -name %s 2>/dev/null | head -100", shellQuote(pattern))
|
||||||
|
|
||||||
|
output, err := m.Execute(ctx, cmd, target)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
return textResult(output), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func searchTextHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
m := manager()
|
||||||
|
if m == nil {
|
||||||
|
return errorResult("no SSH manager initialized"), nil
|
||||||
|
}
|
||||||
|
args := requestArgs(req)
|
||||||
|
pattern, _ := requireString(args, "pattern")
|
||||||
|
path, _ := requireString(args, "path")
|
||||||
|
recursive := getBool(args, "recursive", false)
|
||||||
|
ignoreCase := getBool(args, "ignore_case", false)
|
||||||
|
ctxLines := getInt(args, "context", 0)
|
||||||
|
target := getString(args, "target", "primary")
|
||||||
|
|
||||||
|
cmd := "grep"
|
||||||
|
if recursive {
|
||||||
|
cmd += " -r"
|
||||||
|
}
|
||||||
|
if ignoreCase {
|
||||||
|
cmd += " -i"
|
||||||
|
}
|
||||||
|
if ctxLines > 0 {
|
||||||
|
cmd += fmt.Sprintf(" -C %d", ctxLines)
|
||||||
|
}
|
||||||
|
cmd += fmt.Sprintf(" -n %s %s 2>/dev/null | head -100", shellQuote(pattern), shellQuote(path))
|
||||||
|
|
||||||
|
output, err := m.Execute(ctx, cmd, target)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
return textResult(output), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func packageManageHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
m := manager()
|
||||||
|
if m == nil {
|
||||||
|
return errorResult("no SSH manager initialized"), nil
|
||||||
|
}
|
||||||
|
args := requestArgs(req)
|
||||||
|
action, _ := requireString(args, "action")
|
||||||
|
pkg := getString(args, "package", "")
|
||||||
|
target := getString(args, "target", "primary")
|
||||||
|
|
||||||
|
if action != "list" && pkg == "" {
|
||||||
|
return errorResult("Package name required for this action"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
detectCmd := `
|
||||||
|
if command -v apt-get >/dev/null 2>&1; then echo "apt"
|
||||||
|
elif command -v apk >/dev/null 2>&1; then echo "apk"
|
||||||
|
elif command -v dnf >/dev/null 2>&1; then echo "dnf"
|
||||||
|
elif command -v yum >/dev/null 2>&1; then echo "yum"
|
||||||
|
else echo "unknown"
|
||||||
|
fi`
|
||||||
|
|
||||||
|
pkgMgr, err := m.Execute(ctx, detectCmd, target)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
pkgMgr = trimOutput(pkgMgr)
|
||||||
|
|
||||||
|
var cmd string
|
||||||
|
switch pkgMgr {
|
||||||
|
case "apt":
|
||||||
|
switch action {
|
||||||
|
case "install":
|
||||||
|
cmd = fmt.Sprintf("apt-get update && apt-get install -y %s", shellQuote(pkg))
|
||||||
|
case "remove":
|
||||||
|
cmd = fmt.Sprintf("apt-get remove -y %s", shellQuote(pkg))
|
||||||
|
case "check":
|
||||||
|
cmd = fmt.Sprintf("dpkg -s %s 2>/dev/null", shellQuote(pkg))
|
||||||
|
case "list":
|
||||||
|
cmd = "dpkg -l | head -50"
|
||||||
|
}
|
||||||
|
case "apk":
|
||||||
|
switch action {
|
||||||
|
case "install":
|
||||||
|
cmd = fmt.Sprintf("apk add %s", shellQuote(pkg))
|
||||||
|
case "remove":
|
||||||
|
cmd = fmt.Sprintf("apk del %s", shellQuote(pkg))
|
||||||
|
case "check":
|
||||||
|
cmd = fmt.Sprintf("apk info %s 2>/dev/null", shellQuote(pkg))
|
||||||
|
case "list":
|
||||||
|
cmd = "apk list --installed | head -50"
|
||||||
|
}
|
||||||
|
case "dnf", "yum":
|
||||||
|
switch action {
|
||||||
|
case "install":
|
||||||
|
cmd = fmt.Sprintf("%s install -y %s", pkgMgr, shellQuote(pkg))
|
||||||
|
case "remove":
|
||||||
|
cmd = fmt.Sprintf("%s remove -y %s", pkgMgr, shellQuote(pkg))
|
||||||
|
case "check":
|
||||||
|
cmd = fmt.Sprintf("rpm -qi %s 2>/dev/null", shellQuote(pkg))
|
||||||
|
case "list":
|
||||||
|
cmd = fmt.Sprintf("%s list installed 2>/dev/null | head -50", pkgMgr)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return errorResult("No supported package manager found"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
output, err := m.Execute(ctx, cmd, target)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
return textResult(output), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
// patterns.go — доменные forge-probe эмиттеры ssh-модуля. Механика probe
|
||||||
|
// (wrapProbe/registerPatternTool/isProbe) — в forge-tools/toolkit.
|
||||||
|
|
||||||
|
// runPatternsFn - patternsFn для инструмента run: эмитирует по паттерну на
|
||||||
|
// каждую команду в shell-цепочке (см. shellSplitPatterns).
|
||||||
|
func runPatternsFn(args map[string]any) (patterns, always []string) {
|
||||||
|
command := getString(args, "command", "")
|
||||||
|
if command == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return shellSplitPatterns(command)
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// Package tools implements MCP tool handlers for the forge-tools-ssh server.
|
||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"forge-tools-ssh/internal/ssh"
|
||||||
|
|
||||||
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RegisterAll регистрирует все 45 tool-инструмента SSH-сервера
|
||||||
|
// (core 5 + files 6 + monitoring 7 + disk 2 + network 4 + docker 8 + db 3 + voip 10).
|
||||||
|
// m — одиночный SSH Manager процесса (forge: 1 подпроцесс/агента).
|
||||||
|
func RegisterAll(s *mcp.Server, m *ssh.Manager) {
|
||||||
|
SetManager(m)
|
||||||
|
registerCoreTools(s)
|
||||||
|
registerFileTools(s)
|
||||||
|
registerMonitoringTools(s)
|
||||||
|
registerDiskTools(s)
|
||||||
|
registerNetworkTools(s)
|
||||||
|
registerDockerTools(s)
|
||||||
|
registerDBTools(s)
|
||||||
|
registerVoIPTools(s)
|
||||||
|
}
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// shellSplitPatterns разбивает shell-команду на отдельные "команды" и
|
||||||
|
// возвращает permission-паттерн на каждую + безопасный префикс "always".
|
||||||
|
//
|
||||||
|
// Зачем: ядро forge не умеет парсить shell, поэтому безопасность rule-
|
||||||
|
// matching (напр. "ssh__run": {"rm -rf *": deny}) обеспечивает тул - он
|
||||||
|
// режет цепочки `cmd1 && cmd2`, `cmd1; cmd2`, `cmd1 | cmd2`, редиректы и
|
||||||
|
// подстановки `$(...)`/backtick/`sh -c` на отдельные команды, чтобы
|
||||||
|
// деструктив не "прятался" за безопасным префиксом (opencode делает то же
|
||||||
|
// самое в tool/shell.ts, расщепляя AST).
|
||||||
|
//
|
||||||
|
// Возврат nil, если команда не может быть разобрана (несбалансированные
|
||||||
|
// кавычки/скобки) - тогда ядро трактует как "паттернов нет" и решает по
|
||||||
|
// default (ask - безопасно). Это консервативный отказ на неоднозначность.
|
||||||
|
func shellSplitPatterns(command string) (patterns, always []string) {
|
||||||
|
segs, ok := splitTopLevel(command)
|
||||||
|
if !ok {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
for _, seg := range segs {
|
||||||
|
seg = strings.TrimSpace(seg)
|
||||||
|
if seg == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
patterns = append(patterns, seg)
|
||||||
|
always = append(always, safeAlwaysPrefix(seg))
|
||||||
|
// Вложенные команды: $(...), backtick, sh -c '...', python -c, etc.
|
||||||
|
for _, inner := range extractNested(seg) {
|
||||||
|
inner = strings.TrimSpace(inner)
|
||||||
|
if inner == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
patterns = append(patterns, inner)
|
||||||
|
always = append(always, safeAlwaysPrefix(inner))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return patterns, always
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitTopLevel разбивает строку на сегменты по command-разделителям
|
||||||
|
// `;`, `&&`, `||`, `|`, `|&`, `&`, перевод строки - на верхнем уровне
|
||||||
|
// (вне кавычек и $()-скобок). Возвращает ok=false при несбалансированных
|
||||||
|
// кавычках/скобках.
|
||||||
|
func splitTopLevel(s string) ([]string, bool) {
|
||||||
|
var segs []string
|
||||||
|
var b strings.Builder
|
||||||
|
inSingle := false
|
||||||
|
inDouble := false
|
||||||
|
escaped := false
|
||||||
|
depth := 0
|
||||||
|
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
c := s[i]
|
||||||
|
if escaped {
|
||||||
|
b.WriteByte(c)
|
||||||
|
escaped = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c == '\\' {
|
||||||
|
b.WriteByte(c)
|
||||||
|
escaped = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if inSingle {
|
||||||
|
b.WriteByte(c)
|
||||||
|
if c == '\'' {
|
||||||
|
inSingle = false
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if inDouble {
|
||||||
|
b.WriteByte(c)
|
||||||
|
if c == '"' {
|
||||||
|
inDouble = false
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch c {
|
||||||
|
case '\'':
|
||||||
|
b.WriteByte(c)
|
||||||
|
inSingle = true
|
||||||
|
case '"':
|
||||||
|
b.WriteByte(c)
|
||||||
|
inDouble = true
|
||||||
|
case '(':
|
||||||
|
depth++
|
||||||
|
b.WriteByte(c)
|
||||||
|
case ')':
|
||||||
|
if depth == 0 {
|
||||||
|
return nil, false // несбалансированная скобка
|
||||||
|
}
|
||||||
|
depth--
|
||||||
|
b.WriteByte(c)
|
||||||
|
case ';', '\n':
|
||||||
|
if depth == 0 {
|
||||||
|
segs = append(segs, b.String())
|
||||||
|
b.Reset()
|
||||||
|
} else {
|
||||||
|
b.WriteByte(c)
|
||||||
|
}
|
||||||
|
case '&':
|
||||||
|
if depth == 0 {
|
||||||
|
// && - разделитель; одиночный & - тоже разделитель
|
||||||
|
// (background), поглощаем возможный второй &
|
||||||
|
if i+1 < len(s) && s[i+1] == '&' {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
segs = append(segs, b.String())
|
||||||
|
b.Reset()
|
||||||
|
} else {
|
||||||
|
b.WriteByte(c)
|
||||||
|
}
|
||||||
|
case '|':
|
||||||
|
if depth == 0 {
|
||||||
|
// |& или | - разделитель
|
||||||
|
if i+1 < len(s) && s[i+1] == '&' {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
segs = append(segs, b.String())
|
||||||
|
b.Reset()
|
||||||
|
} else {
|
||||||
|
b.WriteByte(c)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
b.WriteByte(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if inSingle || inDouble || depth != 0 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
segs = append(segs, b.String())
|
||||||
|
return segs, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractNested извлекает вложенные команды из сегмента: содержимое
|
||||||
|
// `$(...)`, backtick-подстановки и аргументы `-c`/`-e` для
|
||||||
|
// sh/bash/python/node. Это позволяет deny-правилам ловить деструктив,
|
||||||
|
// спрятанный внутри подстановки или внешнего интерпретатора.
|
||||||
|
func extractNested(seg string) []string {
|
||||||
|
var out []string
|
||||||
|
out = append(out, extractDollarParen(seg)...)
|
||||||
|
out = append(out, extractBackticks(seg)...)
|
||||||
|
out = append(out, extractInterpreterArg(seg)...)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractDollarParen извлекает содержимое `$( ... )` с учётом вложенности
|
||||||
|
// и кавычек.
|
||||||
|
func extractDollarParen(s string) []string {
|
||||||
|
var out []string
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
if i+1 < len(s) && s[i] == '$' && s[i+1] == '(' {
|
||||||
|
end, inner, ok := matchBalanced(s, i+1, '(', ')')
|
||||||
|
if !ok {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
out = append(out, inner)
|
||||||
|
i = end - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractBackticks извлекает содержимое “ ` ... ` “.
|
||||||
|
func extractBackticks(s string) []string {
|
||||||
|
var out []string
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
if s[i] == '`' {
|
||||||
|
end := strings.IndexByte(s[i+1:], '`')
|
||||||
|
if end < 0 {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
out = append(out, s[i+1:i+1+end])
|
||||||
|
i += end + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractInterpreterArg извлекает строку-аргумент у интерпретаторов,
|
||||||
|
// исполняющих код: sh -c '...', bash -c '...', python -c '...',
|
||||||
|
// node -e '...'. Это отдельная команда (её содержимое), которую нельзя
|
||||||
|
// отождествлять с безопасным префиксом `sh -c`.
|
||||||
|
func extractInterpreterArg(s string) []string {
|
||||||
|
// Паттерн: <bin> -c <quote>...<quote> или <bin> -e <quote>...<quote>
|
||||||
|
idx := strings.Index(s, " -c ")
|
||||||
|
if idx < 0 {
|
||||||
|
idx = strings.Index(s, " -e ")
|
||||||
|
}
|
||||||
|
if idx < 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rest := s[idx+4:]
|
||||||
|
rest = strings.TrimSpace(rest)
|
||||||
|
if rest == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Разбираем аргумент: кавычки '...' или "..."
|
||||||
|
var inner string
|
||||||
|
switch rest[0] {
|
||||||
|
case '\'':
|
||||||
|
end := strings.IndexByte(rest[1:], '\'')
|
||||||
|
if end < 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
inner = rest[1 : 1+end]
|
||||||
|
case '"':
|
||||||
|
end := strings.IndexByte(rest[1:], '"')
|
||||||
|
if end < 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
inner = rest[1 : 1+end]
|
||||||
|
default:
|
||||||
|
// Без кавычек - берём до конца слова (грубая эвристика).
|
||||||
|
end := strings.IndexAny(rest, " \t;|&")
|
||||||
|
if end < 0 {
|
||||||
|
end = len(rest)
|
||||||
|
}
|
||||||
|
inner = rest[:end]
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(inner) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []string{inner}
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchBalanced находит закрывающую скобку для открывающей на позиции
|
||||||
|
// open (open - индекс символа '('), уважая кавычки и вложенность.
|
||||||
|
// Возвращает (endIndex, innerContent, ok), где endIndex - индекс ')'.
|
||||||
|
func matchBalanced(s string, open int, openCh, closeCh byte) (int, string, bool) {
|
||||||
|
depth := 0
|
||||||
|
inSingle := false
|
||||||
|
inDouble := false
|
||||||
|
escaped := false
|
||||||
|
for i := open; i < len(s); i++ {
|
||||||
|
c := s[i]
|
||||||
|
if escaped {
|
||||||
|
escaped = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c == '\\' {
|
||||||
|
escaped = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if inSingle {
|
||||||
|
if c == '\'' {
|
||||||
|
inSingle = false
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if inDouble {
|
||||||
|
if c == '"' {
|
||||||
|
inDouble = false
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch c {
|
||||||
|
case '\'':
|
||||||
|
inSingle = true
|
||||||
|
case '"':
|
||||||
|
inDouble = true
|
||||||
|
case openCh:
|
||||||
|
depth++
|
||||||
|
case closeCh:
|
||||||
|
depth--
|
||||||
|
if depth == 0 {
|
||||||
|
return i, s[open+1 : i], true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// safeAlwaysPrefix строит безопасный префикс "always" для команды:
|
||||||
|
// "<команда>[ <подкоманда>] *". Подкоманда учитывается только для ПРОСТЫХ
|
||||||
|
// команд (без кавычек/подстановок/редиректов/разделителей) и если второй
|
||||||
|
// токен не флаг и не путь. Так одобрение `git status` запоминается как
|
||||||
|
// `git status *` (будущие status-вызовы без вопроса), а `cat /etc/hosts`
|
||||||
|
// и `echo $(rm -rf /)` - как `cat *` / `echo *` (без подкоманды: это
|
||||||
|
// аргументы/подстановки, а не подкоманды). Консервативно: при
|
||||||
|
// сомнении оставляем только имя команды.
|
||||||
|
func safeAlwaysPrefix(command string) string {
|
||||||
|
tokens := strings.Fields(command)
|
||||||
|
if len(tokens) == 0 {
|
||||||
|
return "*"
|
||||||
|
}
|
||||||
|
prefix := []string{tokens[0]}
|
||||||
|
if len(tokens) > 1 && isSimpleCommand(command) {
|
||||||
|
t := tokens[1]
|
||||||
|
if !strings.HasPrefix(t, "-") && !strings.Contains(t, "/") {
|
||||||
|
prefix = append(prefix, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(prefix, " ") + " *"
|
||||||
|
}
|
||||||
|
|
||||||
|
// isSimpleCommand сообщает, является ли сегмент простой командой (без
|
||||||
|
// кавычек, подстановок, редиректов и разделителей). Для таких команд
|
||||||
|
// безопасный префикс может включать подкоманду; для сложных - нет.
|
||||||
|
func isSimpleCommand(s string) bool {
|
||||||
|
return !strings.ContainsAny(s, "`'\"$(;|<>") && !strings.Contains(s, "&")
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestShellSplitPatterns(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
command string
|
||||||
|
patterns []string
|
||||||
|
always []string
|
||||||
|
ok bool // false = ожидаем nil (непарсится -> безопасный отказ)
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "single command",
|
||||||
|
command: "cat /etc/hosts",
|
||||||
|
patterns: []string{"cat /etc/hosts"},
|
||||||
|
always: []string{"cat *"},
|
||||||
|
ok: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "chain with &&",
|
||||||
|
command: "git status && git log",
|
||||||
|
patterns: []string{"git status", "git log"},
|
||||||
|
always: []string{"git status *", "git log *"},
|
||||||
|
ok: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "chain with semicolon hides destructive",
|
||||||
|
command: "cat /etc/hosts; rm -rf /",
|
||||||
|
patterns: []string{"cat /etc/hosts", "rm -rf /"},
|
||||||
|
always: []string{"cat *", "rm *"},
|
||||||
|
ok: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "pipe",
|
||||||
|
command: "ps aux | grep sshd",
|
||||||
|
patterns: []string{"ps aux", "grep sshd"},
|
||||||
|
always: []string{"ps aux *", "grep sshd *"},
|
||||||
|
ok: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "command substitution",
|
||||||
|
command: "echo $(rm -rf /tmp)",
|
||||||
|
patterns: []string{"echo $(rm -rf /tmp)", "rm -rf /tmp"},
|
||||||
|
always: []string{"echo *", "rm *"},
|
||||||
|
ok: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "sh -c hides destructive",
|
||||||
|
command: "sh -c 'rm -rf /'",
|
||||||
|
patterns: []string{"sh -c 'rm -rf /'", "rm -rf /"},
|
||||||
|
always: []string{"sh *", "rm *"},
|
||||||
|
ok: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "quoted semicolon not split",
|
||||||
|
command: "echo 'a;b'",
|
||||||
|
patterns: []string{"echo 'a;b'"},
|
||||||
|
always: []string{"echo *"},
|
||||||
|
ok: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "redirection",
|
||||||
|
command: "echo hi > /etc/foo",
|
||||||
|
patterns: []string{"echo hi > /etc/foo"},
|
||||||
|
always: []string{"echo *"},
|
||||||
|
ok: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unbalanced quote -> nil",
|
||||||
|
command: "echo 'unterminated",
|
||||||
|
patterns: nil,
|
||||||
|
always: nil,
|
||||||
|
ok: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty",
|
||||||
|
command: "",
|
||||||
|
patterns: nil,
|
||||||
|
always: nil,
|
||||||
|
ok: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
p, a := shellSplitPatterns(tt.command)
|
||||||
|
if !tt.ok {
|
||||||
|
if p != nil || a != nil {
|
||||||
|
t.Fatalf("expected nil patterns/always, got %v / %v", p, a)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(p, tt.patterns) {
|
||||||
|
t.Fatalf("patterns = %v, want %v", p, tt.patterns)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(a, tt.always) {
|
||||||
|
t.Fatalf("always = %v, want %v", a, tt.always)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.totmin.ru/en2zmax/forge-toolkit"
|
||||||
|
"git.totmin.ru/en2zmax/forge-toolkit/validate"
|
||||||
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// toolkit.go — тонкие адаптеры к общему слою forge-tools/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 getStringArray(args map[string]any, key string) ([]string, bool) {
|
||||||
|
return toolkit.GetStringArray(args, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getIntArray(args map[string]any, key string) ([]int, bool) {
|
||||||
|
return toolkit.GetIntArray(args, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 arrayStringProps(desc string, required bool) map[string]any {
|
||||||
|
return toolkit.ArrayStringProps(desc, required)
|
||||||
|
}
|
||||||
|
|
||||||
|
func arrayNumberProps(desc string, required bool) map[string]any {
|
||||||
|
return toolkit.ArrayNumberProps(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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidationResult — результат серверной валидации синтаксиса.
|
||||||
|
type ValidationResult = validate.Result
|
||||||
|
|
||||||
|
func ValidateContent(content, fileType string) *ValidationResult {
|
||||||
|
return validate.Content(content, fileType)
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectFileType(path string) string {
|
||||||
|
return validate.DetectFileType(path)
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// shellQuote quotes a string for safe shell use.
|
||||||
|
func shellQuote(s string) string {
|
||||||
|
if s == "" {
|
||||||
|
return "''"
|
||||||
|
}
|
||||||
|
// Simple single-quote escaping
|
||||||
|
escaped := strings.ReplaceAll(s, "'", "'\"'\"'")
|
||||||
|
return "'" + escaped + "'"
|
||||||
|
}
|
||||||
|
|
||||||
|
// containsString checks if s contains substr.
|
||||||
|
func containsString(s, substr string) bool {
|
||||||
|
return strings.Contains(s, substr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// trimOutput trims whitespace from output.
|
||||||
|
func trimOutput(s string) string {
|
||||||
|
return strings.TrimSpace(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sedEscapeLiteral escapes a literal string for use in a sed s/pattern/ context.
|
||||||
|
// Escapes: / \ & . * [ ] ^ $ and newlines.
|
||||||
|
func sedEscapeLiteral(s string) string {
|
||||||
|
replacer := strings.NewReplacer(
|
||||||
|
`\`, `\\`,
|
||||||
|
`/`, `\/`,
|
||||||
|
`&`, `\&`,
|
||||||
|
`.`, `\.`,
|
||||||
|
`*`, `\*`,
|
||||||
|
`[`, `\[`,
|
||||||
|
`]`, `\]`,
|
||||||
|
`^`, `\^`,
|
||||||
|
`$`, `\$`,
|
||||||
|
"\n", `\n`,
|
||||||
|
)
|
||||||
|
return replacer.Replace(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sedEscapePattern escapes a regex pattern for use in sed, only escaping the delimiter.
|
||||||
|
// The pattern is passed as-is for regex matching, only / and newlines are escaped.
|
||||||
|
func sedEscapePattern(s string) string {
|
||||||
|
replacer := strings.NewReplacer(
|
||||||
|
`/`, `\/`,
|
||||||
|
"\n", `\n`,
|
||||||
|
)
|
||||||
|
return replacer.Replace(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sedEscapeReplacement escapes a replacement string for sed s//replacement/ context.
|
||||||
|
// Only escapes: / \ & and newlines (these have special meaning in sed replacements).
|
||||||
|
func sedEscapeReplacement(s string) string {
|
||||||
|
replacer := strings.NewReplacer(
|
||||||
|
`\`, `\\`,
|
||||||
|
`/`, `\/`,
|
||||||
|
`&`, `\&`,
|
||||||
|
"\n", `\n`,
|
||||||
|
)
|
||||||
|
return replacer.Replace(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sedEscapeInsertText escapes text for sed i\ or a\ commands.
|
||||||
|
// Newlines need to be escaped with backslash continuation for multi-line inserts.
|
||||||
|
func sedEscapeInsertText(s string) string {
|
||||||
|
return strings.ReplaceAll(s, "\n", `\n`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sedInPlace builds a portable sed in-place edit command that works on
|
||||||
|
// GNU sed (Linux), BSD sed (macOS/FreeBSD), and BusyBox sed (Alpine).
|
||||||
|
// Uses sed -i.bak + rm for universal portability.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - flags: extra sed flags like "-E", or "" for none
|
||||||
|
// - expr: the sed expression WITHOUT single-quote wrapping (e.g.,
|
||||||
|
// "s/foo/bar/g"). sedInPlace shell-quotes it itself (via shellQuote),
|
||||||
|
// so apostrophes in user content can't break the shell quoting.
|
||||||
|
// - path: the raw file path (will be shell-quoted internally)
|
||||||
|
func sedInPlace(flags, expr, path string) string {
|
||||||
|
quotedExpr := shellQuote(expr)
|
||||||
|
quotedPath := shellQuote(path)
|
||||||
|
quotedBak := shellQuote(path + ".bak")
|
||||||
|
if flags != "" {
|
||||||
|
flags = " " + flags
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("sed -i.bak%s %s %s 2>&1 && rm -f %s",
|
||||||
|
flags, quotedExpr, quotedPath, quotedBak)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sanitizeTsharkValue removes characters that could break tshark display filters.
|
||||||
|
// Allows alphanumeric, dash, dot, @, underscore, plus, colon, and space.
|
||||||
|
func sanitizeTsharkValue(s string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range s {
|
||||||
|
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') ||
|
||||||
|
r == '-' || r == '.' || r == '@' || r == '_' || r == '+' || r == ':' || r == ' ' {
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// sanitizeAlphanumeric validates that a string contains only safe characters.
|
||||||
|
// Allows alphanumeric, dash, dot, and underscore. Used for network interface names,
|
||||||
|
// grep keywords, and other values embedded inside sh -c strings.
|
||||||
|
func sanitizeAlphanumeric(s string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range s {
|
||||||
|
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') ||
|
||||||
|
r == '-' || r == '.' || r == '_' {
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// sanitizeShellInnerPath validates a file path used inside sh -c '...' strings.
|
||||||
|
// Rejects characters that could break out of single-quoted shell context or
|
||||||
|
// enable command injection. Returns error for unsafe paths.
|
||||||
|
func sanitizeShellInnerPath(s string) (string, error) {
|
||||||
|
if s == "" {
|
||||||
|
return "", fmt.Errorf("path cannot be empty")
|
||||||
|
}
|
||||||
|
for _, r := range s {
|
||||||
|
if r == '\'' || r == '`' || r == ';' || r == '&' || r == '|' ||
|
||||||
|
r == '$' || r == '!' || r == '\n' || r == '\r' || r < 32 {
|
||||||
|
return "", fmt.Errorf("invalid characters in path")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestShellQuote covers the standard case and apostrophes: a value
|
||||||
|
// containing ' must still be safely shell-quoted (the '\” trick).
|
||||||
|
func TestShellQuote(t *testing.T) {
|
||||||
|
for _, in := range []string{"", "hello", "don't", "it's a 'test'", "a'b'c"} {
|
||||||
|
got := shellQuote(in)
|
||||||
|
if !strings.HasPrefix(got, "'") || !strings.HasSuffix(got, "'") {
|
||||||
|
t.Errorf("shellQuote(%q) = %q: not single-quoted", in, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSedInPlaceEscapesApostrophe ensures that a replace whose text
|
||||||
|
// contains an apostrophe doesn't break the shell quoting. This runs the
|
||||||
|
// generated command for real against a temp file.
|
||||||
|
func TestSedInPlaceEscapesApostrophe(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := dir + "/app.conf"
|
||||||
|
if err := os.WriteFile(path, []byte("name: don't panic\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expr := "s/don't/can't/"
|
||||||
|
cmd := sedInPlace("", expr, path)
|
||||||
|
|
||||||
|
out, err := exec.Command("sh", "-c", cmd).CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("sed command failed: %v\n%s\ncmd=%s", err, out, cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(content), "can't panic") {
|
||||||
|
t.Errorf("expected replacement with apostrophe, got %q", content)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,609 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// VoIP constants
|
||||||
|
const (
|
||||||
|
SIPUDPPort = 5060
|
||||||
|
SIPTCPPort = 5060
|
||||||
|
SIPTLSPort = 5061
|
||||||
|
RTPPortRange = "50000-60000"
|
||||||
|
DefaultPCAPLimit = 5 * 1024 * 1024
|
||||||
|
)
|
||||||
|
|
||||||
|
func registerVoIPTools(s *mcp.Server) {
|
||||||
|
// voip_discover_containers
|
||||||
|
s.AddTool(&mcp.Tool{
|
||||||
|
Name: "voip_discover_containers",
|
||||||
|
Description: "Find VoIP-related containers by name/image keywords",
|
||||||
|
InputSchema: schema(map[string]any{
|
||||||
|
"keywords": arrayStringProps("Keywords to match (default: gw, media, fs, sbc, sw)", false),
|
||||||
|
"target": strProps("Connection alias (default: primary)", false),
|
||||||
|
}, nil),
|
||||||
|
}, voipDiscoverHandler)
|
||||||
|
|
||||||
|
// voip_sip_capture
|
||||||
|
s.AddTool(&mcp.Tool{
|
||||||
|
Name: "voip_sip_capture",
|
||||||
|
Description: "Capture SIP signaling to PCAP using sngrep inside container",
|
||||||
|
InputSchema: schema(map[string]any{
|
||||||
|
"container": strProps("Container name", true),
|
||||||
|
"duration": intProps("Capture duration in seconds (default: 30)", false),
|
||||||
|
"port": intProps("SIP port to filter (default: 5060)", false),
|
||||||
|
"protocol": strProps("Protocol filter (default: all)", false, "udp", "tcp", "tls"),
|
||||||
|
"target": strProps("Connection alias (default: primary)", false),
|
||||||
|
}, []string{"container"}),
|
||||||
|
}, sipCaptureHandler)
|
||||||
|
|
||||||
|
// voip_call_flow
|
||||||
|
s.AddTool(&mcp.Tool{
|
||||||
|
Name: "voip_call_flow",
|
||||||
|
Description: "Parse SIP call flow from a PCAP file",
|
||||||
|
InputSchema: schema(map[string]any{
|
||||||
|
"container": strProps("Container name", true),
|
||||||
|
"pcap_file": strProps("Path to PCAP file in container", true),
|
||||||
|
"call_id": strProps("Filter by Call-ID", false),
|
||||||
|
"phone_number": strProps("Filter by phone number", false),
|
||||||
|
"summary_only": boolProps("Return summary only, no message details", false),
|
||||||
|
"target": strProps("Connection alias (default: primary)", false),
|
||||||
|
}, []string{"container", "pcap_file"}),
|
||||||
|
}, callFlowHandler)
|
||||||
|
|
||||||
|
// voip_registrations
|
||||||
|
s.AddTool(&mcp.Tool{
|
||||||
|
Name: "voip_registrations",
|
||||||
|
Description: "Extract REGISTER dialogs and outcomes from SIP PCAP",
|
||||||
|
InputSchema: schema(map[string]any{
|
||||||
|
"container": strProps("Container name", true),
|
||||||
|
"pcap_file": strProps("Path to PCAP file", true),
|
||||||
|
"target": strProps("Connection alias (default: primary)", false),
|
||||||
|
}, []string{"container", "pcap_file"}),
|
||||||
|
}, registrationsHandler)
|
||||||
|
|
||||||
|
// voip_call_stats
|
||||||
|
s.AddTool(&mcp.Tool{
|
||||||
|
Name: "voip_call_stats",
|
||||||
|
Description: "Aggregate SIP call statistics from PCAP",
|
||||||
|
InputSchema: schema(map[string]any{
|
||||||
|
"container": strProps("Container name", true),
|
||||||
|
"pcap_file": strProps("Path to PCAP file", true),
|
||||||
|
"target": strProps("Connection alias (default: primary)", false),
|
||||||
|
}, []string{"container", "pcap_file"}),
|
||||||
|
}, callStatsHandler)
|
||||||
|
|
||||||
|
// voip_extract_sdp
|
||||||
|
s.AddTool(&mcp.Tool{
|
||||||
|
Name: "voip_extract_sdp",
|
||||||
|
Description: "Extract SDP (codecs, RTP ports) from SIP messages",
|
||||||
|
InputSchema: schema(map[string]any{
|
||||||
|
"container": strProps("Container name", true),
|
||||||
|
"pcap_file": strProps("Path to PCAP file", true),
|
||||||
|
"call_id": strProps("Filter by specific Call-ID", false),
|
||||||
|
"target": strProps("Connection alias (default: primary)", false),
|
||||||
|
}, []string{"container", "pcap_file"}),
|
||||||
|
}, extractSDPHandler)
|
||||||
|
|
||||||
|
// voip_packet_check
|
||||||
|
s.AddTool(&mcp.Tool{
|
||||||
|
Name: "voip_packet_check",
|
||||||
|
Description: "Quick SIP packet presence check on standard ports",
|
||||||
|
InputSchema: schema(map[string]any{
|
||||||
|
"container": strProps("Container name", true),
|
||||||
|
"duration": intProps("Check duration in seconds (default: 5)", false),
|
||||||
|
"interface": strProps("Network interface (default: any)", false),
|
||||||
|
"target": strProps("Connection alias (default: primary)", false),
|
||||||
|
}, []string{"container"}),
|
||||||
|
}, packetCheckHandler)
|
||||||
|
|
||||||
|
// voip_network_capture
|
||||||
|
s.AddTool(&mcp.Tool{
|
||||||
|
Name: "voip_network_capture",
|
||||||
|
Description: "Capture SIP packets with tcpdump for analysis",
|
||||||
|
InputSchema: schema(map[string]any{
|
||||||
|
"container": strProps("Container name", true),
|
||||||
|
"duration": intProps("Capture duration in seconds (default: 30)", false),
|
||||||
|
"interface": strProps("Network interface (default: any)", false),
|
||||||
|
"target": strProps("Connection alias (default: primary)", false),
|
||||||
|
}, []string{"container"}),
|
||||||
|
}, networkCaptureHandler)
|
||||||
|
|
||||||
|
// voip_rtp_capture
|
||||||
|
s.AddTool(&mcp.Tool{
|
||||||
|
Name: "voip_rtp_capture",
|
||||||
|
Description: "Capture RTP packets to verify media flow",
|
||||||
|
InputSchema: schema(map[string]any{
|
||||||
|
"container": strProps("Container name", true),
|
||||||
|
"duration": intProps("Capture duration in seconds (default: 10)", false),
|
||||||
|
"port_range": strProps("RTP port range (default: 50000-60000)", false),
|
||||||
|
"interface": strProps("Network interface (default: any)", false),
|
||||||
|
"target": strProps("Connection alias (default: primary)", false),
|
||||||
|
}, []string{"container"}),
|
||||||
|
}, rtpCaptureHandler)
|
||||||
|
|
||||||
|
// voip_network_diagnostics
|
||||||
|
s.AddTool(&mcp.Tool{
|
||||||
|
Name: "voip_network_diagnostics",
|
||||||
|
Description: "Run network diagnostics: ping, traceroute, TCP port checks",
|
||||||
|
InputSchema: schema(map[string]any{
|
||||||
|
"host": strProps("Target host for diagnostics", true),
|
||||||
|
"ports": arrayNumberProps("TCP ports to check (default: 5060, 5061)", false),
|
||||||
|
"ping_count": intProps("Number of pings (default: 3)", false),
|
||||||
|
"traceroute": boolProps("Include traceroute (default: true)", false),
|
||||||
|
"timeout": intProps("Timeout in seconds (default: 15)", false),
|
||||||
|
"target": strProps("Connection alias (default: primary)", false),
|
||||||
|
}, []string{"host"}),
|
||||||
|
}, networkDiagnosticsHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func voipDiscoverHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
m := manager()
|
||||||
|
if m == nil {
|
||||||
|
return errorResult("no SSH manager initialized"), nil
|
||||||
|
}
|
||||||
|
args := requestArgs(req)
|
||||||
|
target := getString(args, "target", "primary")
|
||||||
|
|
||||||
|
if err := checkDockerAvailable(ctx, m, target); err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default VoIP keywords
|
||||||
|
defaultKeywords := []string{"gw", "media", "fs", "sbc", "sw", "freeswitch", "asterisk", "kamailio", "opensips", "rtpengine"}
|
||||||
|
keywords := defaultKeywords
|
||||||
|
|
||||||
|
if kws, ok := getStringArray(args, "keywords"); ok {
|
||||||
|
var sanitized []string
|
||||||
|
for _, kw := range kws {
|
||||||
|
if safe := sanitizeAlphanumeric(kw); safe != "" {
|
||||||
|
sanitized = append(sanitized, safe)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(sanitized) > 0 {
|
||||||
|
keywords = sanitized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pattern := strings.Join(keywords, "|")
|
||||||
|
cmd := fmt.Sprintf(`docker ps --format '{{.Names}}|{{.Image}}' | grep -iE %s 2>/dev/null || echo ''`, shellQuote(pattern))
|
||||||
|
|
||||||
|
output, err := m.Execute(ctx, cmd, target)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
if trimOutput(output) == "" {
|
||||||
|
return textResult("No VoIP containers found"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var containers []map[string]string
|
||||||
|
for _, line := range strings.Split(output, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(line, "|", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
containers = append(containers, map[string]string{
|
||||||
|
"name": parts[0],
|
||||||
|
"image": parts[1],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
jsonBytes, _ := json.MarshalIndent(containers, "", " ")
|
||||||
|
return textResult(string(jsonBytes)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sipCaptureHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
m := manager()
|
||||||
|
if m == nil {
|
||||||
|
return errorResult("no SSH manager initialized"), nil
|
||||||
|
}
|
||||||
|
args := requestArgs(req)
|
||||||
|
container, _ := requireString(args, "container")
|
||||||
|
duration := getInt(args, "duration", 30)
|
||||||
|
port := getInt(args, "port", 0)
|
||||||
|
protocol := getString(args, "protocol", "")
|
||||||
|
target := getString(args, "target", "primary")
|
||||||
|
|
||||||
|
if err := checkDockerAvailable(ctx, m, target); err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
checkCmd := fmt.Sprintf("docker exec %s command -v sngrep >/dev/null 2>&1 && echo 'ok' || echo 'missing'", shellQuote(container))
|
||||||
|
checkOutput, err := m.Execute(ctx, checkCmd, target)
|
||||||
|
if err != nil || !containsString(checkOutput, "ok") {
|
||||||
|
return errorResult("sngrep not available in container. Install with: apt-get install sngrep"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
bpfFilter := buildSIPFilter(port, protocol)
|
||||||
|
pcapPath := fmt.Sprintf("/tmp/voip_sip_%d.pcap", time.Now().Unix())
|
||||||
|
|
||||||
|
cmd := fmt.Sprintf("docker exec %s timeout %ds sngrep -N -q -d any -O %s '%s' 2>&1 || true",
|
||||||
|
shellQuote(container), duration, shellQuote(pcapPath), bpfFilter)
|
||||||
|
m.Execute(ctx, cmd, target)
|
||||||
|
|
||||||
|
checkFile := fmt.Sprintf("docker exec %s test -f %s && echo 'exists' || echo 'missing'", shellQuote(container), shellQuote(pcapPath))
|
||||||
|
checkResult, _ := m.Execute(ctx, checkFile, target)
|
||||||
|
fileStatus := "created"
|
||||||
|
if !containsString(checkResult, "exists") {
|
||||||
|
fileStatus = "not created (capture may have failed)"
|
||||||
|
}
|
||||||
|
|
||||||
|
result := map[string]any{
|
||||||
|
"container": container,
|
||||||
|
"pcap_file": pcapPath,
|
||||||
|
"duration": duration,
|
||||||
|
"filter": bpfFilter,
|
||||||
|
"file_status": fileStatus,
|
||||||
|
"message": fmt.Sprintf("SIP capture completed. Use voip_call_flow to analyze %s", pcapPath),
|
||||||
|
}
|
||||||
|
jsonBytes, _ := json.MarshalIndent(result, "", " ")
|
||||||
|
return textResult(string(jsonBytes)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func callFlowHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
m := manager()
|
||||||
|
if m == nil {
|
||||||
|
return errorResult("no SSH manager initialized"), nil
|
||||||
|
}
|
||||||
|
args := requestArgs(req)
|
||||||
|
container, _ := requireString(args, "container")
|
||||||
|
rawPcapFile, _ := requireString(args, "pcap_file")
|
||||||
|
callID := getString(args, "call_id", "")
|
||||||
|
phoneNumber := getString(args, "phone_number", "")
|
||||||
|
summaryOnly := getBool(args, "summary_only", false)
|
||||||
|
target := getString(args, "target", "primary")
|
||||||
|
|
||||||
|
pcapFile, err := sanitizeShellInnerPath(rawPcapFile)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult("invalid pcap_file path"), nil
|
||||||
|
}
|
||||||
|
if err := checkDockerAvailable(ctx, m, target); err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var filter string
|
||||||
|
if callID != "" {
|
||||||
|
filter = fmt.Sprintf("-Y 'sip.Call-ID == \"%s\"'", sanitizeTsharkValue(callID))
|
||||||
|
} else if phoneNumber != "" {
|
||||||
|
filter = fmt.Sprintf("-Y 'sip contains \"%s\"'", sanitizeTsharkValue(phoneNumber))
|
||||||
|
}
|
||||||
|
|
||||||
|
quotedPcap := shellQuote(pcapFile)
|
||||||
|
var cmd string
|
||||||
|
if summaryOnly {
|
||||||
|
cmd = fmt.Sprintf(`docker exec %s sh -c 'if command -v tshark >/dev/null 2>&1; then tshark -r %s -T fields -e frame.time -e ip.src -e ip.dst -e sip.Method -e sip.Status-Code -e sip.Call-ID %s 2>/dev/null | head -100; else sngrep -I %s -q 2>/dev/null | head -50 || echo "No analysis tool available"; fi'`,
|
||||||
|
shellQuote(container), quotedPcap, filter, quotedPcap)
|
||||||
|
} else {
|
||||||
|
cmd = fmt.Sprintf(`docker exec %s sh -c 'if command -v tshark >/dev/null 2>&1; then tshark -r %s -V -Y sip %s 2>/dev/null | head -500; else cat %s 2>/dev/null | strings | grep -E "^(INVITE|REGISTER|BYE|ACK|CANCEL|SIP/2.0)" | head -100 || echo "No analysis tool available"; fi'`,
|
||||||
|
shellQuote(container), quotedPcap, filter, quotedPcap)
|
||||||
|
}
|
||||||
|
|
||||||
|
output, err := m.Execute(ctx, cmd, target)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
return textResult(output), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func registrationsHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
m := manager()
|
||||||
|
if m == nil {
|
||||||
|
return errorResult("no SSH manager initialized"), nil
|
||||||
|
}
|
||||||
|
args := requestArgs(req)
|
||||||
|
container, _ := requireString(args, "container")
|
||||||
|
rawPcapFile, _ := requireString(args, "pcap_file")
|
||||||
|
target := getString(args, "target", "primary")
|
||||||
|
|
||||||
|
pcapFile, err := sanitizeShellInnerPath(rawPcapFile)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult("invalid pcap_file path"), nil
|
||||||
|
}
|
||||||
|
if err := checkDockerAvailable(ctx, m, target); err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
quotedPcap := shellQuote(pcapFile)
|
||||||
|
cmd := fmt.Sprintf(`docker exec %s sh -c 'if command -v tshark >/dev/null 2>&1; then tshark -r %s -Y "sip.Method == REGISTER or (sip.CSeq.method == REGISTER and sip.Status-Code)" -T fields -e frame.time -e sip.from.user -e sip.to.user -e sip.contact.uri -e sip.Status-Code -E header=y 2>/dev/null; else cat %s 2>/dev/null | strings | grep -E "(REGISTER|200 OK|401|403)" | head -50; fi'`,
|
||||||
|
shellQuote(container), quotedPcap, quotedPcap)
|
||||||
|
|
||||||
|
output, err := m.Execute(ctx, cmd, target)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
return textResult(output), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func callStatsHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
m := manager()
|
||||||
|
if m == nil {
|
||||||
|
return errorResult("no SSH manager initialized"), nil
|
||||||
|
}
|
||||||
|
args := requestArgs(req)
|
||||||
|
container, _ := requireString(args, "container")
|
||||||
|
rawPcapFile, _ := requireString(args, "pcap_file")
|
||||||
|
target := getString(args, "target", "primary")
|
||||||
|
|
||||||
|
pcapFile, err := sanitizeShellInnerPath(rawPcapFile)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult("invalid pcap_file path"), nil
|
||||||
|
}
|
||||||
|
if err := checkDockerAvailable(ctx, m, target); err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
quotedPcap := shellQuote(pcapFile)
|
||||||
|
cmd := fmt.Sprintf(`docker exec %s sh -c '
|
||||||
|
if command -v tshark >/dev/null 2>&1; then
|
||||||
|
echo "=== SIP STATISTICS ==="
|
||||||
|
echo ""
|
||||||
|
echo "--- Request Methods ---"
|
||||||
|
tshark -r %s -Y sip.Method -T fields -e sip.Method 2>/dev/null | sort | uniq -c | sort -rn
|
||||||
|
echo ""
|
||||||
|
echo "--- Response Codes ---"
|
||||||
|
tshark -r %s -Y sip.Status-Code -T fields -e sip.Status-Code 2>/dev/null | sort | uniq -c | sort -rn
|
||||||
|
echo ""
|
||||||
|
echo "--- Unique Call-IDs ---"
|
||||||
|
tshark -r %s -Y sip -T fields -e sip.Call-ID 2>/dev/null | sort -u | wc -l | xargs echo "Total calls:"
|
||||||
|
else
|
||||||
|
cat %s 2>/dev/null | strings | grep -oE "^(INVITE|REGISTER|BYE|ACK|CANCEL|OPTIONS|SIP/2.0 [0-9]+)" | sort | uniq -c | sort -rn
|
||||||
|
fi'`, shellQuote(container), quotedPcap, quotedPcap, quotedPcap, quotedPcap)
|
||||||
|
|
||||||
|
output, err := m.Execute(ctx, cmd, target)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
return textResult(output), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractSDPHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
m := manager()
|
||||||
|
if m == nil {
|
||||||
|
return errorResult("no SSH manager initialized"), nil
|
||||||
|
}
|
||||||
|
args := requestArgs(req)
|
||||||
|
container, _ := requireString(args, "container")
|
||||||
|
rawPcapFile, _ := requireString(args, "pcap_file")
|
||||||
|
callID := getString(args, "call_id", "")
|
||||||
|
target := getString(args, "target", "primary")
|
||||||
|
|
||||||
|
pcapFile, err := sanitizeShellInnerPath(rawPcapFile)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult("invalid pcap_file path"), nil
|
||||||
|
}
|
||||||
|
if err := checkDockerAvailable(ctx, m, target); err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var filter string
|
||||||
|
if callID != "" {
|
||||||
|
filter = fmt.Sprintf("-Y 'sip.Call-ID == \"%s\" and sdp'", sanitizeTsharkValue(callID))
|
||||||
|
} else {
|
||||||
|
filter = "-Y 'sdp'"
|
||||||
|
}
|
||||||
|
|
||||||
|
quotedPcap := shellQuote(pcapFile)
|
||||||
|
cmd := fmt.Sprintf(`docker exec %s sh -c 'if command -v tshark >/dev/null 2>&1; then tshark -r %s %s -T fields -e sdp.connection_info -e sdp.media -e sdp.media.port -e sdp.media.format -E header=y 2>/dev/null | head -50; else cat %s 2>/dev/null | strings | grep -E "^(c=|m=|a=rtpmap)" | head -50; fi'`,
|
||||||
|
shellQuote(container), quotedPcap, filter, quotedPcap)
|
||||||
|
|
||||||
|
output, err := m.Execute(ctx, cmd, target)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
return textResult(output), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func packetCheckHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
m := manager()
|
||||||
|
if m == nil {
|
||||||
|
return errorResult("no SSH manager initialized"), nil
|
||||||
|
}
|
||||||
|
args := requestArgs(req)
|
||||||
|
container, _ := requireString(args, "container")
|
||||||
|
duration := getInt(args, "duration", 5)
|
||||||
|
iface := sanitizeAlphanumeric(getString(args, "interface", "any"))
|
||||||
|
target := getString(args, "target", "primary")
|
||||||
|
|
||||||
|
if iface == "" {
|
||||||
|
iface = "any"
|
||||||
|
}
|
||||||
|
if duration < 1 || duration > 300 {
|
||||||
|
duration = 5
|
||||||
|
}
|
||||||
|
if err := checkDockerAvailable(ctx, m, target); err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := fmt.Sprintf(`docker exec %s sh -c 'if command -v tcpdump >/dev/null 2>&1; then timeout %ds tcpdump -i %s -c 20 port 5060 or port 5061 2>&1 | tail -25; else echo "tcpdump not available"; fi'`,
|
||||||
|
shellQuote(container), duration, shellQuote(iface))
|
||||||
|
|
||||||
|
output, err := m.Execute(ctx, cmd, target)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
hasPackets := containsString(output, "UDP") || containsString(output, "TCP") || containsString(output, "SIP")
|
||||||
|
summary := "SIP packets detected: NO"
|
||||||
|
if hasPackets {
|
||||||
|
summary = "SIP packets detected: YES"
|
||||||
|
}
|
||||||
|
return textResult(fmt.Sprintf("%s\n\n%s", summary, output)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func networkCaptureHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
m := manager()
|
||||||
|
if m == nil {
|
||||||
|
return errorResult("no SSH manager initialized"), nil
|
||||||
|
}
|
||||||
|
args := requestArgs(req)
|
||||||
|
container, _ := requireString(args, "container")
|
||||||
|
duration := getInt(args, "duration", 30)
|
||||||
|
iface := sanitizeAlphanumeric(getString(args, "interface", "any"))
|
||||||
|
target := getString(args, "target", "primary")
|
||||||
|
|
||||||
|
if iface == "" {
|
||||||
|
iface = "any"
|
||||||
|
}
|
||||||
|
if duration < 1 || duration > 300 {
|
||||||
|
duration = 30
|
||||||
|
}
|
||||||
|
if err := checkDockerAvailable(ctx, m, target); err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
pcapPath := fmt.Sprintf("/tmp/voip_net_%d.pcap", time.Now().Unix())
|
||||||
|
cmd := fmt.Sprintf(`docker exec %s sh -c 'if command -v tcpdump >/dev/null 2>&1; then timeout %ds tcpdump -i %s -w %s port 5060 or port 5061 2>&1 || true; else echo "tcpdump not available"; fi'`,
|
||||||
|
shellQuote(container), duration, shellQuote(iface), shellQuote(pcapPath))
|
||||||
|
m.Execute(ctx, cmd, target)
|
||||||
|
|
||||||
|
checkFile := fmt.Sprintf("docker exec %s test -f %s && echo 'exists' || echo 'missing'", shellQuote(container), shellQuote(pcapPath))
|
||||||
|
checkResult, _ := m.Execute(ctx, checkFile, target)
|
||||||
|
fileStatus := "created"
|
||||||
|
if !containsString(checkResult, "exists") {
|
||||||
|
fileStatus = "not created (capture may have failed)"
|
||||||
|
}
|
||||||
|
|
||||||
|
result := map[string]any{
|
||||||
|
"container": container,
|
||||||
|
"pcap_file": pcapPath,
|
||||||
|
"duration": duration,
|
||||||
|
"interface": iface,
|
||||||
|
"file_status": fileStatus,
|
||||||
|
"message": "Network capture complete. Analyze with voip_call_flow or copy with docker_cp_from",
|
||||||
|
}
|
||||||
|
jsonBytes, _ := json.MarshalIndent(result, "", " ")
|
||||||
|
return textResult(string(jsonBytes)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func rtpCaptureHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
m := manager()
|
||||||
|
if m == nil {
|
||||||
|
return errorResult("no SSH manager initialized"), nil
|
||||||
|
}
|
||||||
|
args := requestArgs(req)
|
||||||
|
container, _ := requireString(args, "container")
|
||||||
|
duration := getInt(args, "duration", 10)
|
||||||
|
portRange := getString(args, "port_range", RTPPortRange)
|
||||||
|
iface := sanitizeAlphanumeric(getString(args, "interface", "any"))
|
||||||
|
target := getString(args, "target", "primary")
|
||||||
|
|
||||||
|
if iface == "" {
|
||||||
|
iface = "any"
|
||||||
|
}
|
||||||
|
if duration < 1 || duration > 300 {
|
||||||
|
duration = 10
|
||||||
|
}
|
||||||
|
if err := checkDockerAvailable(ctx, m, target); err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
startPort := 50000
|
||||||
|
endPort := 60000
|
||||||
|
ports := strings.Split(portRange, "-")
|
||||||
|
if len(ports) == 2 {
|
||||||
|
if sp, err := strconv.Atoi(strings.TrimSpace(ports[0])); err == nil && sp > 0 && sp <= 65535 {
|
||||||
|
startPort = sp
|
||||||
|
}
|
||||||
|
if ep, err := strconv.Atoi(strings.TrimSpace(ports[1])); err == nil && ep > 0 && ep <= 65535 {
|
||||||
|
endPort = ep
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := fmt.Sprintf(`docker exec %s sh -c 'if command -v tcpdump >/dev/null 2>&1; then timeout %ds tcpdump -i %s -c 100 "udp portrange %d-%d" 2>&1 | tail -20; else echo "tcpdump not available"; fi'`,
|
||||||
|
shellQuote(container), duration, shellQuote(iface), startPort, endPort)
|
||||||
|
|
||||||
|
output, err := m.Execute(ctx, cmd, target)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
hasRTP := containsString(output, "UDP") && containsString(output, "packets")
|
||||||
|
summary := "RTP packets detected: NO"
|
||||||
|
if hasRTP {
|
||||||
|
summary = "RTP packets detected: YES"
|
||||||
|
}
|
||||||
|
return textResult(fmt.Sprintf("%s\nPort range: %s\n\n%s", summary, portRange, output)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func networkDiagnosticsHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
m := manager()
|
||||||
|
if m == nil {
|
||||||
|
return errorResult("no SSH manager initialized"), nil
|
||||||
|
}
|
||||||
|
args := requestArgs(req)
|
||||||
|
host, _ := requireString(args, "host")
|
||||||
|
pingCount := getInt(args, "ping_count", 3)
|
||||||
|
doTraceroute := getBool(args, "traceroute", true)
|
||||||
|
timeout := getInt(args, "timeout", 15)
|
||||||
|
target := getString(args, "target", "primary")
|
||||||
|
|
||||||
|
ports := []int{5060, 5061}
|
||||||
|
if ps, ok := getIntArray(args, "ports"); ok {
|
||||||
|
ports = ps
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
fmt.Fprintf(&sb, "=== NETWORK DIAGNOSTICS: %s ===\n\n", host)
|
||||||
|
|
||||||
|
sb.WriteString("--- PING ---\n")
|
||||||
|
pingCmd := fmt.Sprintf("ping -c %d -W 3 %s 2>&1 || echo 'Ping failed'", pingCount, shellQuote(host))
|
||||||
|
pingOutput, _ := m.Execute(ctx, pingCmd, target)
|
||||||
|
sb.WriteString(pingOutput)
|
||||||
|
sb.WriteString("\n\n")
|
||||||
|
|
||||||
|
if doTraceroute {
|
||||||
|
sb.WriteString("--- TRACEROUTE ---\n")
|
||||||
|
traceCmd := fmt.Sprintf("timeout %ds traceroute -m 15 %s 2>&1 || tracepath %s 2>&1 || echo 'Traceroute not available'",
|
||||||
|
timeout, shellQuote(host), shellQuote(host))
|
||||||
|
traceOutput, _ := m.Execute(ctx, traceCmd, target)
|
||||||
|
sb.WriteString(traceOutput)
|
||||||
|
sb.WriteString("\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString("--- TCP PORT CHECKS ---\n")
|
||||||
|
for _, port := range ports {
|
||||||
|
checkCmd := fmt.Sprintf("timeout 3 bash -c 'echo >/dev/tcp/%s/%d' 2>&1 && echo 'Port %d: OPEN' || echo 'Port %d: CLOSED/FILTERED'",
|
||||||
|
shellQuote(host), port, port, port)
|
||||||
|
checkOutput, _ := m.Execute(ctx, checkCmd, target)
|
||||||
|
fmt.Fprintf(&sb, "%s\n", trimOutput(checkOutput))
|
||||||
|
}
|
||||||
|
|
||||||
|
return textResult(sb.String()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildSIPFilter создаёт BPF-фильтр для SIP-трафика.
|
||||||
|
func buildSIPFilter(port int, protocol string) string {
|
||||||
|
if protocol != "" {
|
||||||
|
proto := strings.ToLower(protocol)
|
||||||
|
switch proto {
|
||||||
|
case "tls":
|
||||||
|
if port == 0 {
|
||||||
|
port = SIPTLSPort
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("tcp port %d", port)
|
||||||
|
case "tcp":
|
||||||
|
if port == 0 {
|
||||||
|
port = SIPTCPPort
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("tcp port %d", port)
|
||||||
|
case "udp":
|
||||||
|
if port == 0 {
|
||||||
|
port = SIPUDPPort
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("udp port %d", port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if port != 0 {
|
||||||
|
return fmt.Sprintf("udp port %d or tcp port %d", port, port)
|
||||||
|
}
|
||||||
|
return "udp port 5060 or tcp port 5060 or tcp port 5061"
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"forge-tools-ssh/internal/ssh"
|
||||||
|
"forge-tools-ssh/internal/tools"
|
||||||
|
|
||||||
|
"git.totmin.ru/en2zmax/forge-toolkit"
|
||||||
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if toolkit.Health() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mgr := ssh.NewManager("")
|
||||||
|
defer mgr.Close()
|
||||||
|
|
||||||
|
if err := toolkit.Run("forge-tools-ssh", func(s *mcp.Server) {
|
||||||
|
tools.RegisterAll(s, mgr)
|
||||||
|
}); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "ssh server: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"default_key_path": "${SSH_KEY_PATH}",
|
||||||
|
"allowed_hosts": [
|
||||||
|
"10.0.*",
|
||||||
|
"*.internal.example.com"
|
||||||
|
],
|
||||||
|
"profiles": [
|
||||||
|
{
|
||||||
|
"alias": "prod-web",
|
||||||
|
"host": "10.0.1.10",
|
||||||
|
"username": "deploy",
|
||||||
|
"port": 22,
|
||||||
|
"key": "${PROD_SSH_KEY}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": "bastion",
|
||||||
|
"host": "bastion.example.com",
|
||||||
|
"username": "deploy"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": "db-via-pam",
|
||||||
|
"host": "pam-gateway.example.com",
|
||||||
|
"username": "operator",
|
||||||
|
"target": "10.0.2.20",
|
||||||
|
"target_password": "${DB_TARGET_PASSWORD}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user