commit 1821fe7968404e6a83e26e34a011015c6e0fb84e Author: Maksim Totmin Date: Thu Oct 1 10:13:48 2026 +0700 Initial commit: forge-tools-ssh — MCP-сервер для администрирования по SSH diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7292b64 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c0780e9 --- /dev/null +++ b/Makefile @@ -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) diff --git a/README.md b/README.md new file mode 100644 index 0000000..40cbd72 --- /dev/null +++ b/README.md @@ -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-ассистент
(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-портов | + +--- + +## Конфигурация + +Доступы задаются декларативно в `/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). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..d7158af --- /dev/null +++ b/SECURITY.md @@ -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/`). diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9cde245 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ad2766d --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/ssh/client.go b/internal/ssh/client.go new file mode 100644 index 0000000..652067a --- /dev/null +++ b/internal/ssh/client.go @@ -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) +} diff --git a/internal/ssh/keys.go b/internal/ssh/keys.go new file mode 100644 index 0000000..2afc052 --- /dev/null +++ b/internal/ssh/keys.go @@ -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 +} diff --git a/internal/ssh/manager.go b/internal/ssh/manager.go new file mode 100644 index 0000000..df46375 --- /dev/null +++ b/internal/ssh/manager.go @@ -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 политики из /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 = "" +} diff --git a/internal/ssh/policy.go b/internal/ssh/policy.go new file mode 100644 index 0000000..d9874d0 --- /dev/null +++ b/internal/ssh/policy.go @@ -0,0 +1,171 @@ +// Package ssh: per-tenant политика подключений (контракт PLAN.md +// "Per-tenant forge-tools"). Файл /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 читает /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 +} diff --git a/internal/ssh/policy_test.go b/internal/ssh/policy_test.go new file mode 100644 index 0000000..6af0bdb --- /dev/null +++ b/internal/ssh/policy_test.go @@ -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) + } +} diff --git a/internal/tools/core.go b/internal/tools/core.go new file mode 100644 index 0000000..99880bb --- /dev/null +++ b/internal/tools/core.go @@ -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 +} diff --git a/internal/tools/db.go b/internal/tools/db.go new file mode 100644 index 0000000..0cf855a --- /dev/null +++ b/internal/tools/db.go @@ -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 +} diff --git a/internal/tools/disk.go b/internal/tools/disk.go new file mode 100644 index 0000000..03ff782 --- /dev/null +++ b/internal/tools/disk.go @@ -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 . Use for 'how much space/free on '.", + 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 reports the FS containing (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") +} diff --git a/internal/tools/disk_test.go b/internal/tools/disk_test.go new file mode 100644 index 0000000..ec24121 --- /dev/null +++ b/internal/tools/disk_test.go @@ -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) + } +} diff --git a/internal/tools/docker.go b/internal/tools/docker.go new file mode 100644 index 0000000..2a74a0a --- /dev/null +++ b/internal/tools/docker.go @@ -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 +} diff --git a/internal/tools/files.go b/internal/tools/files.go new file mode 100644 index 0000000..b30ed98 --- /dev/null +++ b/internal/tools/files.go @@ -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 +} diff --git a/internal/tools/monitoring.go b/internal/tools/monitoring.go new file mode 100644 index 0000000..7a50719 --- /dev/null +++ b/internal/tools/monitoring.go @@ -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 ? " </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 +} diff --git a/internal/tools/network.go b/internal/tools/network.go new file mode 100644 index 0000000..b67f5bc --- /dev/null +++ b/internal/tools/network.go @@ -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 +} diff --git a/internal/tools/patterns.go b/internal/tools/patterns.go new file mode 100644 index 0000000..2d509a9 --- /dev/null +++ b/internal/tools/patterns.go @@ -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) +} diff --git a/internal/tools/registry.go b/internal/tools/registry.go new file mode 100644 index 0000000..d0f2d18 --- /dev/null +++ b/internal/tools/registry.go @@ -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) +} diff --git a/internal/tools/shellsplit.go b/internal/tools/shellsplit.go new file mode 100644 index 0000000..bc7c1c9 --- /dev/null +++ b/internal/tools/shellsplit.go @@ -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 { + // Паттерн: -c ... или -e ... + 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, "&") +} diff --git a/internal/tools/shellsplit_test.go b/internal/tools/shellsplit_test.go new file mode 100644 index 0000000..aa0f28a --- /dev/null +++ b/internal/tools/shellsplit_test.go @@ -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) + } + }) + } +} diff --git a/internal/tools/toolkit.go b/internal/tools/toolkit.go new file mode 100644 index 0000000..a2b7a3d --- /dev/null +++ b/internal/tools/toolkit.go @@ -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) +} diff --git a/internal/tools/utils.go b/internal/tools/utils.go new file mode 100644 index 0000000..7a53e19 --- /dev/null +++ b/internal/tools/utils.go @@ -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 +} diff --git a/internal/tools/utils_test.go b/internal/tools/utils_test.go new file mode 100644 index 0000000..b8cf1c8 --- /dev/null +++ b/internal/tools/utils_test.go @@ -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) + } +} diff --git a/internal/tools/voip.go b/internal/tools/voip.go new file mode 100644 index 0000000..cb28102 --- /dev/null +++ b/internal/tools/voip.go @@ -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" +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..1f56dba --- /dev/null +++ b/main.go @@ -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) + } +} diff --git a/ssh.json.example b/ssh.json.example new file mode 100644 index 0000000..56970aa --- /dev/null +++ b/ssh.json.example @@ -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}" + } + ] +}