Initial commit: forge-tools-ssh — MCP-сервер для администрирования по SSH

This commit is contained in:
Maksim Totmin
2026-10-01 10:13:48 +07:00
commit 1821fe7968
30 changed files with 5342 additions and 0 deletions
+21
View File
@@ -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/
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [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.
+26
View File
@@ -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)
+294
View File
@@ -0,0 +1,294 @@
# forge-tools-ssh
> MCP-сервер, который даёт AI-ассистенту аккуратный доступ к серверам по SSH.
![Go](https://img.shields.io/badge/Go-1.27-00ADD8?logo=go&logoColor=white)
![MCP](https://img.shields.io/badge/MCP-stdio-6E56CF)
![Tools](https://img.shields.io/badge/tools-45-success)
![License](https://img.shields.io/badge/license-Apache--2.0-blue)
**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
View File
@@ -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/`).
+25
View File
@@ -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
)
+46
View File
@@ -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=
+396
View File
@@ -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)
}
+139
View File
@@ -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
}
+710
View File
@@ -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 = ""
}
+171
View File
@@ -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
}
+117
View File
@@ -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)
}
}
+186
View File
@@ -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
}
+251
View File
@@ -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
}
+95
View File
@@ -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")
}
+56
View File
@@ -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)
}
}
+285
View File
@@ -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
}
+375
View File
@@ -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
}
+316
View File
@@ -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
}
+214
View File
@@ -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
}
+14
View File
@@ -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)
}
+23
View File
@@ -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)
}
+309
View File
@@ -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, "&")
}
+105
View File
@@ -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)
}
})
}
}
+88
View File
@@ -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)
}
+136
View File
@@ -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
}
+46
View File
@@ -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)
}
}
+609
View File
@@ -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"
}
+28
View File
@@ -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)
}
}
+28
View File
@@ -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}"
}
]
}