Skip to main content
Glama
README.md
# hq-mcp

Русская версия · [English](README.en.md)

MCP-сервер, дающий ИИ-агенту доступ к VPN-бизнесу: биллинг
[SHM](https://github.com/danuk/shm) и панель
[Remnawave](https://github.com/remnawave/backend), сшитые так, чтобы на вопрос,
лежащий поперёк обеих систем, можно было ответить одним вызовом.

Ни один инструмент ничего не меняет тем же вызовом, которым его попросили:
пишущий сперва возвращает план, а применение — это второй вызов, несущий
идентификатор этого плана.

## Как это выглядит в работе

Первый вызов на любой установке — `platform_probe`. Он отвечает, что вообще есть
в этом развёртывании и что из этого живо; всё остальное здесь производно от
того, что он сообщит. Ответы ниже подрезаны, значения вымышлены.

```
platform_probe {}
```
```json
{
  "shm":   { "configured": true, "reachable": true, "version": "2.19.4", "live": true },
  "remna": { "configured": true, "reachable": true, "version": "3.3.2",
             "runtime": { "instances": 6, "youngestUptimeSeconds": 54294 } },
  "capabilities": { "shm.filter": false, "remna.realtimeBandwidth": false,
                    "remna.nodeIntegrations": true, "remna.sharedLists": true,
                    "tunnel.mysql": false, "…": "…" },
  "warnings": [{ "code": "realtime_route_absent", "message": "…" },
               { "code": "specs_are_stale", "message": "…" }]
}
```

Дальше — вопрос, на который ни одна из двух систем не отвечает в одиночку:
«клиент пишет, что оплатил, а конфига нет».

```
client_resolve { "query": "kot@example.com" }
```
```json
{
  "shm":   { "count": 1, "matches": [{ "user_id": 4821, "email": "kot@example.com",
                                      "blocked": false }] },
  "remna": { "count": 0, "ambiguous": false,
             "paths": [{ "path": "email",   "tried": true, "found": 0, "note": null },
                       { "path": "service", "tried": true, "found": 0, "note": "…" }] }
}
```

Панель не знает про него ничего, но `count: 0` здесь — не «аккаунта нет»:
`paths` называет каждый пройденный поиск и то, чего он не видит. Что случилось на
самом деле, говорит вторая пара глаз:

```
provisioning_diagnose { "shm_user_id": 4821 }
```
```json
{
  "verdict": "panel_user_missing",
  "services": { "items": 1, "diagnosed": [{
    "user_service_id": 90210,
    "status": "ACTIVE",
    "verdict": "panel_user_missing",
    "storage": { "name": "vpn_mrzb_90210", "present": true, "checked": true },
    "panel":   { "username": "HQVPN_90210", "id": 11274, "found": false, "checked": true },
    "spool":   { "total": 0, "stuck": 0, "failed": 0, "succeeded": 0 },
    "history": { "total": 1, "success": 1 }
  }] }
}
```

Услуга ACTIVE, снимок конфигурации на месте, провижининг отчитался успехом — а
пользователя, которому этот успех принадлежит, в панели нет. Ни биллинг, ни
панель по отдельности такого не показывают.

Инструментов, читающих обе системы, тридцать семь. Режим `rw` добавляет
шестнадцать пишущих: четырнадцать меняют настоящие данные, один применяет план, ещё
один читает локальный журнал мутаций.

## Почему составные инструменты, а не прокси эндпоинтов

Очевидная конструкция — по инструменту на HTTP-эндпоинт, штук полтораста. Она
была написана и выброшена, по двум причинам.

Сырой прокси обнуляет любой список запретов. Если модель умеет звать
`GET <любой путь>`, то перечень операций, которые вы решили не давать, —
украшение: до запрещённого пути одна строка. Здесь инструменты зовут поимённо
названные маршруты, а сканер на этапе сборки роняет прогон, если запрещённый
путь встретился литералом в исходнике.

И эндпоинт — это не вопрос. Пример выше затрагивает четыре маршрута SHM и два
маршрута панели, а интересное в нём — именно *стык*. `client_overview`,
`sync_audit` и `provisioning_diagnose` существуют потому, что баги живут на этом
шве.

## Правило, определившее всё остальное

**Пустой ответ никогда не должен быть принят за доказанное отсутствие.**

Когда бэкенд отказывает, инструмент деградирует: отказ уезжает в `degraded`,
предупреждение `partial_result` называет недостающую половину, а любая находка,
зависевшая от этой половины, *подавляется*, а не вычисляется из того, что
уцелело. Когда список усечён, вместе с ним приезжает серверный total — чтобы
«такой услуги нет» не опиралось на необъявленное окно.

Это не теоретическая осторожность. В ходе разработки один инструмент прочитал
все записи панели, выбросил их все, потому что поле переименовали на той
стороне, и после этого сообщил, что сотни клиентов нуждаются в
перепровижининге, — разрушительная рекомендация, высказанная уверенно и
выведенная из пустого множества. Починка состояла не только в переименованном
поле: она состояла в том, что корзина, посчитанная из непригодного входа,
обязана отказаться быть находкой.

## Совместимость: заработает ли это у вас

Проверено на **SHM 2.19.4** и **Remnawave 3.2.3** — оба числа сняты с
работающего развёртывания, а не взяты из спецификации.

Поддержка **Remnawave 3.3.2** добавляет интеграции нод, общие списки, Host Mapper,
GeoCheck и подтверждаемую синхронизацию. Контракты сверены с исходниками тега
3.3.2. Чтения проверены на живой панели 3.3.2; заполненные каталоги и новые записи
проверяются локальными тестами с ответами API. Подробности и ограничения — в
[справочнике](COMPATIBILITY.md#remna-332).

**Минимум базовых инструментов — SHM 2.18.0 и Remnawave 3.0.0.** Возможности
новой группы требуют панели 3.3+. Официальный `danuk/shm` подходит:
все маршруты, которые зовут инструменты, — апстримные, форк не нужен.
Единственное место, где патч того развёртывания был *виден* инструменту, —
четвёртый флаг `GET /user/password-auth`; теперь его отсутствие называется
предупреждением `sign_in_flag_absent`, а не выдаётся за диагноз. Полный перечень
маршрутов обеих систем, версия появления каждого и подробный ответ про форк — в
[COMPATIBILITY.md](COMPATIBILITY.md).

Проверка занимает один вызов — тот же `platform_probe`. Если версия ниже
минимума, он отвечает предупреждением `backend_version_below_minimum`, называя
версию, минимум и что именно отвалится. Ничего при этом не выключается: старая
версия даёт *громкие* отказы на конкретных маршрутах, а не тихие пустые ответы.

| Версия | Что пропадает | Кого это касается |
|---|---|---|
| SHM < 2.18.0 | `GET /healthcheck` — единственный маршрут без авторизации | только `platform_probe`: `shm.live` остаётся `null`, «биллинг лежит» и «пароль не тот» перестают различаться (`shm_healthcheck_route_absent`). Остальные инструменты не задеты |
| SHM < 2.11.3 | `GET /admin/user/search` | `client_search`, `client_resolve` — отказ, не пустой список |
| SHM < 2.9.0 | `GET /user/referrals` | `client_account_state` теряет счётчик рефералов |
| SHM < 2.4.0 | `GET /user/email` | `client_account_state` теряет адрес и признак подтверждения |
| Панель < 3.0.0 | пользователь адресуется `uuid`, а не числовым `id` | `client_overview`, `subscription_inspect`, `traffic_stats`, `provisioning_diagnose`, `subscription_ops`: `/api/users/{id}` отвергается валидацией с 400 |
| Панель < 3.0.0 | нет `/api/connections/*` | `connections_inspect` — весь инструмент |
| Панель < 3.0.0 | нет `POST /api/users/{id}/actions/extend` | `subscription_ops` теряет продление (у панели остаётся только массовое) |
| Панель < 3.0.0 | нет `/api/system/stats/digest` и `/stats/http` | `panel_activity` теряет две из пяти своих выборок |
| Панель < 3.2.0 | нет `GET /api/system/configuration` | только `platform_probe`: возможность `remna.subscriptionRequestHistory` остаётся `unknown` — намеренно, а не `false` |
| Панель < 3.3.0 | нет интеграций нод, общих списков, Host Mapper и GeoCheck | новые инструменты и поля требуют 3.3+; отказ дополнительных чтений помечается отдельно от основного аудита |

`platform_probe` проверяет `remna.nodeIntegrations` и `remna.sharedLists` по
каталогам. Пустой доступный каталог означает `true`, 404 — `false`, а 401/403,
лимит запросов и сбой источника — `unknown` с объяснением. Отсутствующий в
3.3.2 обработчик `/api/bandwidth-stats/nodes/realtime` отмечается кодом
`realtime_route_absent`; исторический трафик продолжает читаться.

**Remnawave 3.x ломает совместимость со всем, что писалось под 2.x, и ломает
негромко.** Из объекта пользователя убран `uuid`, а вместе с ним исчезли
маршруты `by-telegram-id`, `by-email` и `by-tag`, причём `/api/users/{uuid}`
отвечает 400, а не 404, — так что отказ не похож даже на «нет такого
пользователя». Здесь этих маршрутов нет вовсе; там, где сервер всё-таки
встречает наследный `uuid` (например, в старом снимке storage SHM), он говорит
об этом в ответе, а не сползает молча на догадку.

Спецификации OpenAPI отстают от работающих систем, поэтому `platform_probe`
несёт предупреждение `specs_are_stale` при каждом вызове; у SHM всё хуже
обычного — её спека штампует `info.version` из конфига в рантайме, то есть
описывает тот стенд, где выгрузку сделали, а не ваш. Поэтому проба не читает
версии из файлов вовсе, а спрашивает их у работающих систем — и там же
устанавливает, что верно для *этого* развёртывания: сужает ли что-нибудь
серверный `filter` у SHM, уважает ли панель `filters` в листинге пользователей
(обе отвечают 200 и молча выбрасывают незнакомые параметры), ведёт ли панель
журнал обращений за подпиской, существует
ли маршрут realtime-трафика, какие ssh-туннели открыты. И отделяет «бэкенд лежит»
от «наши креды не те»: 401/403 сообщается как `credentialsRejected`.

## Установка

Нужны Node 22.12+ и pnpm, **и хотя бы одна из двух систем** — SHM или
Remnawave. Обе не обязательны: каждая настраивается отдельно и в одиночку
является полноценной конфигурацией. Инструменты той системы, которой нет, не
публикуются вовсе — не «отвечают пусто», а отсутствуют, и `platform_probe`
прямо называет, что настроено. Поэтому число инструментов зависит от установки:
только панель — 19, только SHM — 18, обе — 37 (и больше в режиме `rw`).

```bash
pnpm install
pnpm build
pnpm run setup
```

> `pnpm run setup`, именно с `run`. `pnpm setup` — встроенная команда самого
> pnpm: она правит профиль вашей оболочки и до этого репозитория не доходит.

Мастер существует потому, что шаг, который он заменяет, — написать `.env`
руками — отказывает молча: опечатка в токене панели не мешает серверу подняться
и всплывает позже ошибкой инструмента посреди неродственного вопроса. Поэтому он
**проверяет каждый креденшл на работающей системе** и различает три отказа —
хост не ответил вовсе (DNS, TLS, закрытый порт), хост ответил и отверг креды,
хост ответил тем, что не доказывает ничего (502, 429): чинятся они по-разному,
а одно «login failed» отправило бы чинить не то.

Спрашивает он только про ту систему, которая у вас есть, и про режим доступа;
всё прочее убрано за один вопрос `Configure the optional settings? [y/N]`.
Часовой пояс читает у работающей SHM, а не угадывает: SHM пишет даты собственным
локальным временем без офсета, и неверная зона молча сдвигает каждый возраст.
Секретов не печатает. По умолчанию ставит `ro`; на `rw` требует написать слово
`rw` и отдельно подтвердить — назвав перед этим, сколько инструментов появится и
сколько из них пишут в настоящий биллинг и настоящую панель, посчитав по
реестру в тот же момент. `.env` пишет с правами 0600 поверх копии прежнего,
перенося переменные, о которых не спрашивал, и печатает команды подключения
для Claude Code, Codex и opencode — но чужие конфиги не правит: мастер,
переписывающий JSONC, однажды сломает кому-то рабочую настройку. Перезапускать
его можно в любой момент, Enter сохраняет существующее значение. Без терминала
он запускаться отказывается: MCP-клиент стартует *сервер* без TTY, и мастер,
способный проснуться там, завис бы на вопросе, которого никто не видит.

### Или руками

```bash
cp .env.example .env && chmod 600 .env    # и заполнить
```

Каждая переменная описана в `.env.example`. Отсутствующая или неверная роняет
старт с указанием имени переменной и того, что от неё ожидается, вместо того
чтобы всплыть позже непонятной ошибкой инструмента.

```json
{
  "mcpServers": {
    "hq": {
      "command": "node",
      "args": ["/absolute/path/to/hq-mcp/apps/stdio/dist/index.js"]
    }
  }
}
```

### Второй транспорт: MCP поверх HTTP

Тот же набор инструментов доступен по HTTP — это нужно, когда клиент не может
запустить процесс сам: он в контейнере, на другой машине или их несколько.
Отдельное приложение, конфигурация из того же `.env`:

```bash
# метка произвольная (её показывает /metrics), токен — не короче 24 символов:
# openssl rand -hex 24
HQ_MCP_HTTP_TOKENS='<label>:<token>' pnpm --filter @hq/http start
# hq-mcp http ready: url=http://127.0.0.1:42480 mode=ro profile=human tools=37 …
```

Без `HQ_MCP_HTTP_TOKENS` он не стартует вовсе, и отказывает раньше, чем соберёт
клиентов к биллингу и панели. Слушает петлю; открыть его в сеть —
`HQ_MCP_HTTP_HOST=0.0.0.0`, и об этом печатается предупреждение, потому что
между сервером и сетью останется только этот токен. Порт — `HQ_MCP_HTTP_PORT`.
Клиент подключается к `/mcp`, передавая токен обычным `Authorization: Bearer`:

```json
{
  "mcpServers": {
    "hq": {
      "type": "http",
      "url": "http://127.0.0.1:42480/mcp",
      "headers": { "Authorization": "Bearer <тот же токен>" }
    }
  }
}
```

Маршрут бессессионный: `Mcp-Session-Id` не выдаётся и не требуется, поэтому за
обратным прокси можно держать несколько копий процесса без липких соединений.
Серверных сообщений у него нет, поэтому `GET` на SSE-поток и `DELETE` на
закрытие сессии отвечают 405 — клиент MCP это понимает. Запрос с заголовком
`Origin` отбивается 403: защита от DNS rebinding, см. «Ограничения».

Соседний `/v1/tools` — не MCP, а внутренний REST-фасад для ai-bot: одна ручка
списка и одна на вызов, со своим конвертом ответа и своим потолком запросов.

### Production HTTP image

Production-образ собирается только из проверенного 40-символьного lowercase
commit SHA. Этот SHA запечатывается одновременно в OCI label, root-owned
read-only файл и `/healthz`; entrypoint восстанавливает значение из файла, так
что runtime-переопределение `HQ_MCP_IMAGE_REVISION` не меняет health evidence.

```bash
pnpm test && pnpm test:guards && pnpm typecheck && pnpm build
HQ_MCP_COMMIT_SHA="$(git rev-parse HEAD)"
test "${#HQ_MCP_COMMIT_SHA}" -eq 40
docker build --build-arg "HQ_MCP_DEPLOYMENT_REVISION=${HQ_MCP_COMMIT_SHA}" --tag "hq-mcp-http:${HQ_MCP_COMMIT_SHA}" .
scripts/http-container-smoke.sh "hq-mcp-http:${HQ_MCP_COMMIT_SHA}"
```

Compose-потребитель фиксирует именно этот 40-символьный tag и не объявляет
host `ports`. Контейнер работает как UID/GID `10001`, в `bot+ro` публикует
только `/healthz` и REST-фасад и требует общий non-secret
`HQ_MCP_DEPLOYMENT_CONFIG_REVISION` в формате lowercase UUID. В health входят
обе revision, чтобы ai-bot мог закрыться до чтения каталога при несовпадении.

Production передаёт секреты только через три regular non-symlink файла с
точным mode `0600`: `SHM_ADMIN_AUTH_FILE`, `REMNA_API_TOKEN_FILE` и
`HQ_MCP_HTTP_TOKENS_FILE`. Последний содержит только
`ai-bot:<dedicated token>`. Это отдельный токен сервера; креды SHM и Remnawave
тоже выделяются этому deployment отдельно и не переиспользуются из support bot.

### Подгонка под свою установку

Апстримный `danuk/shm` не знает слова «Remnawave» — ни строки. Мост между
биллингом и панелью живёт целиком в *ваших* шаблонах провижининга: один
пользователь панели на **user_service_id**, имя `<NAME_PREFIX><user_service_id>`,
снимок конфигурации в storage SHM под `<STORAGE_PREFIX><user_service_id>`. Оба
префикса сервер читает в рантайме из `config.remnawave` вашей SHM и позволяет
переопределить (`HQ_MCP_STORAGE_PREFIX`, `HQ_MCP_PANEL_PREFIXES`) — оператор
знает, что в панели лежит сегодня, лучше, чем ключ конфигурации, описывающий,
что SHM соберёт завтра.

Имя пользователя панели — единственный ключ связи, и префикс, не совпадающий ни
с чем, не даёт ошибки: он даёт уверенный неверный ответ, в котором каждая услуга
выглядит непровижиненной. Поэтому инструменты, способные это *доказать*, говорят
кодом **`prefix_unverified`** и подавляют затронутую находку — `sync_audit` не
возвращает корзину `missingPanelUser` вовсе, `provisioning_diagnose` помечает
результат тем же кодом или `panel_username_guessed`. Конвенция нужна ровно трём
инструментам (`sync_audit`, `provisioning_diagnose`, мутатор `storage_edit`);
`client_overview` принимает `remna_user_id` необязательным параметром и без него
просто не показывает половину панели. Если конвенции у вас нет, все остальные
инструменты работают как обычно, а эти три не выдумывают находок. Разбор целиком,
с порядком префиксов и наследными именами, — в
[COMPATIBILITY.md](COMPATIBILITY.md#fitting).

## Инструменты

Тридцать семь видны в `ro`; режим `rw` добавляет шестнадцать из последней
таблицы и не убирает ничего. Числа — для профиля `human`; что из этого видит
`bot`, сказано в модели безопасности.

**Платформа и один клиент**

| Инструмент | На что отвечает |
|---|---|
| `platform_probe` | Что живо прямо сейчас: версии, возможности, туннели и является ли отказ аварией или кредами |
| `client_resolve` | Любой идентификатор (telegram id, email, логин, id, имя в панели) в канонические id обеих систем — все совпадения, а не первое |
| `client_search` | Поиск клиентов SHM по фрагменту, с серверным числом совпадений |
| `client_overview` | Клиент целиком в обеих системах за один вызов |
| `client_account_state` | Как учётка входит: email и его подтверждение, OTP, passkey, возможен ли вход паролем, рефералы |
| `client_billing_view` | Деньги глазами *клиента*: предстоящее списание и те платёжные методы, что реально ему предложены |
| `client_catalog_view` | Каталог и промокоды глазами одного клиента — его скидка, его бонусы, скрытые от него тарифы |

**Деньги, каталог, конфигурация**

| Инструмент | На что отвечает |
|---|---|
| `billing_ledger` | Платежи, бонусы, списания и две независимые сверки (баланс и бонус — разные колонки с разными путями обновления) |
| `autopay_inspect` | Состояние автоплатежа и все удержанные комиссии — оно лежит в JSON-поле `comment` платёжных строк, а не в `user.settings` |
| `promo_read` | Промокоды и их погашения: это разные строки, и читать их с одной нельзя |
| `catalog_read` | Тарифы, прайс заказа, дочерние услуги, карта событий, категории — источник допустимых `service_id` |
| `config_read` | Один ключ конфигурации SHM из закрытого списка, секреты замаскированы. Чтения конфигурации целиком не существует |
| `template_read` | Список шаблонов или тело ровно одного — того файла, который и производит уведомление или скрипт провижининга |

**Услуги и провижининг**

| Инструмент | На что отвечает |
|---|---|
| `service_inspect` | Услуги клиента: статус, срок, запланированный следующий тариф, задачи спула по каждой |
| `spool_inspect` | Очередь провижининга: залипшие, упавшие, приостановленные и реальная глубина |
| `provisioning_diagnose` | «Оплачено, а конфига нет» — по каждой услуге, а не по клиенту |
| `sync_audit` | Пакетная сверка биллинга с панелью, обе стороны вычитываются до конца |
| `notify_history` | Сказали ли клиенту на самом деле, а если нет — почему; вердикт доставки, которого не показывает больше ничто |
| `server_inventory` | Собственные транспорты SHM и их группы (ssh, http, mail, telegram) и разрывы, молча останавливающие провижининг. Это не список нод Remnawave |

**Панель** — сперва со стороны клиента, затем со стороны флота

| Инструмент | На что отвечает |
|---|---|
| `subscription_inspect` | Карточка Remnawave: статус, срок, трафик, HWID-устройства, последние обращения за подпиской. Ключи — никогда |
| `subpage_read` | Что страница подписки реально показывает клиенту: платформы, приложения, шаги установки, ссылки кнопок |
| `client_reach` | До каких нод этот клиент реально дотягивается и какие сквады и теги инбаундов это дают |
| `device_inventory` | Картина HWID по всему флоту — та база, без которой число устройств одного клиента ничего не значит |
| `traffic_stats` | Трафик по дням в разрезе нод и сквадов; это временной ряд, а не счётчики карточки |
| `connections_inspect` | Кто подключён прямо сейчас. Панель отвечает на это джобом, и опрос инструмент ведёт сам |
| `infra_map` | Ноды × профили конфигурации × инбаунды × хосты × сквады и разрывы между ними |
| `infra_costs` | Сколько стоит инфраструктура, в стыке с панелью: оплаченная нода, до которой никто не доходит, — это уходящие деньги |
| `country_health` | Ноды, онлайн, трафик и хосты одной страны |
| `node_config_audit` | Профиль и вычисленный Xray-конфиг, отдельно — интеграции ноды и ссылки на общие списки |
| `squads_read` | Оба семейства сквадов: внутренние решают доступ, внешние — как подписка подана |
| `panel_activity` | Что происходит с самой панелью: сводка, дайджест за окно, какие маршруты дёргают, история обращений за подпиской |
| `torrent_reports` | Улики торрент-блокера, его параметры и зависимости от общих списков |
| `node_integrations_read` | Каталог интеграций и порядок их привязок к нодам, без значений конфигурации |
| `shared_lists_read` | Типы и размеры общих списков, зависимости плагинов и нод, отсутствующие ссылки |
| `node_geocheck` | Запуск диагностики ноды и отдельное чтение результата по `job_id`; краткий отчёт без SVG и сырых данных |

**За туннелем** (эти два без него отказывают, называя точную ssh-команду)

| Инструмент | На что отвечает |
|---|---|
| `abuse_report` | Находки антиабуз-хука плюс топы панели. Дорого: неограниченные сканы рабочей MySQL биллинга, потолок 5 вызовов на 5 минут |
| `sql_query` | SQL только на чтение — префлайт и ничего больше, см. ниже |

**Пишущие** (только `rw`, только профиль `human`, сначала план)

| Инструмент | Что меняет |
|---|---|
| `billing_adjust` | Баланс или бонусы клиента SHM |
| `billing_refund_service` | Возвращает на баланс сумму, которую SHM записал снятой за текущий оплаченный период |
| `bulk_ops` | Массовые операции над клиентами панели — по названному набору id или по всему флоту |
| `host_edit` | Один хост Remnawave: подпись, адрес, порт, SNI/host/path/ALPN/fingerprint, слой безопасности, теги, включение и скрытие, Host Mapper с резервной копией |
| `host_cleanup` | Удаляет хосты по явному списку uuid; полный снимок для ручного восстановления хранится в закрытой резервной копии. Автоматического отката нет |
| `node_manage` | Одна нода: enable, disable, restart, reset_traffic, update, create; упорядоченные `integration_uuids` в create/update |
| `panel_sync` | Отправляет плагин или общий список подходящим подключённым нодам; подтверждение означает принятие в очередь |
| `subscription_ops` | Одна подписка в панели: enable, disable, extend, reset_traffic, revoke, set_limits, снятие устройств |
| `service_lifecycle` | Услуга клиента: give, touch, change_plan, schedule_change, stop, activate, delete |
| `provisioning_repair` | retry, resume или pause одной залипшей задачи спула |
| `template_edit` | Перезаписывает тело существующего шаблона SHM |
| `storage_edit` | Пишет пользовательский storage SHM по списку ключей, выведенному для этой установки |
| `server_edit` | Строка транспорта или группа транспортов SHM — вебхуки, ssh-точка провижининга, почтовые отправители |
| `user_flags` | Блокирует клиента или правит безопасные поля карточки (`full_name`, `phone`, `comment`) |
| `ops_confirm` | Применяет план по его `plan_id`. Пишет то, что пишет запланированный инструмент |
| `ops_audit` | Ничего. Читает локальный журнал мутаций — `rw` потому, что журнал есть часть мутационной поверхности |

## Мутации

**Ничто не применяется тем вызовом, который об этом просит.** Мутатор без
`plan_id` читает текущее состояние, строит целевое и возвращает план: `before`,
`after`, `diff` по полям, побочные эффекты, `rollback` там, где он есть, и
идентификатор. Не пишет ничего. Применение — второй вызов:

```
ops_confirm { "plan_id": "…" }          # либо: тот же мутатор, ТЕ ЖЕ аргументы, плюс plan_id
```

План привязан к **профилю**, который его построил, к **инструменту**, под
который он построен, и к хешу **аргументов**: погасить его нельзя ни от другого
вызывающего, ни другим инструментом, ни тем же инструментом с одним изменённым
числом. Живёт 10 минут. Одноразовость — атомарный `rename` на диске, а не
«прочитать и удалить»: из двадцати одновременных подтверждений выигрывает ровно
одно, остальные получают «не найдено». *Отказ* исправный план не сжигает — все
проверки идут после захвата, и провалившаяся возвращает файл на место; сжигает
его сама попытка, и если бэкенд упал, план израсходован. Это намеренно, и в этом
разница между одним списанием и тремя. Перед применением инструмент перечитывает
мир и сверяет его со снимком, из которого план строился: сдвинулся объект — план
отвергается, а не накатывается поверх чужого изменения.

**Каждая попытка журналируется** в `HQ_MCP_AUDIT_PATH` (JSONL, права 0600): кто,
чем, с какими аргументами, как объект выглядел до и после и чем кончилось —
`planned`, `applying`, `applied`, `failed` или `rejected`; отказы наравне с
успехами. `applying` пишется *до* обращения к бэкенду, и в этом весь смысл
конструкции: запись без парной терминальной означает, что процесс умер посреди,
снимок плана уже уничтожен, а деньги могли уйти. `ops_audit` ищет такие
незакрытые записи по всему журналу, независимо от запрошенного окна, и сообщает о
них первыми; неразобранные строки считаются, а не пропускаются молча.

**Потолки держит фреймворк, а не автор инструмента.** Мутация выше
`HQ_MCP_MAX_OP_AMOUNT` отвергается до построения плана, и фреймворк отказывается
*зарегистрировать* инструмент, который объявил денежный эндпоинт, но не сказал,
как прочитать сумму из его входа. Потолок накрывает оба вида движения денег, и
второй легко упустить: и платежи с бонусами, где сумму называет вызывающий, и
действия жизненного цикла, тратящие баланс клиента (`give`, `touch`,
`change_plan`, `activate`), где сумма — это цена тарифа из каталога. План, у
которого цену прочитать не удалось, не выдаётся: незнание числа не делает
списание бесплатным. `HQ_MCP_MAX_BULK_USERS` ограничивает, скольких клиентов
панели вправе задеть одна массовая операция, и план, не сумевший установить это
число у панели, отвергается, а не оценивается на глаз. Выше потолка операция
отвергается целиком — никогда не усекается.

Проверяется потолок **при построении плана и только там**: применение работает
по уже построенному плану и заново его не меряет. Обойти потолок этим нельзя —
аргументы прибиты хешем, — но потолок, опущенный в `.env` после выдачи плана, на
этот план не подействует.

**`template_edit` и `storage_edit` сперва пишут собственный откат** в
`HQ_MCP_BACKUP_DIR` (каталог 0700, файлы 0600); путь возвращается в ответе,
`restore_from` кладёт байты обратно, и без снятого снимка не пишет ни один из
двух. Бэкап отделён от снимка плана намеренно: тела шаблонов и снимки
конфигурации несут секреты голыми подстроками, у которых нет имени поля, чтобы
их замаскировать, — значит, им нельзя ехать обратно к модели внутри
`before`/`rollback`; и откат обязан пережить смену, тогда как снимки планов
подметаются в течение часа.

`host_edit` с полем `mapper` использует такой же закрытый каталог: значения
mapper хранятся в резервной копии, а `restore_from` строит подтверждаемый план
возврата. `host_cleanup` сохраняет там полные удаляемые хосты, включая mapper,
и возвращает `backupRef` с путём и хешем. Перед удалением сверяются целостность
копии и текущее содержимое хостов. Эта копия предназначена для ручного
пересоздания: автоматического восстановления удалённых хостов нет.

Ещё две вещи пишущие делать отказываются. Тело с маркерами `<redacted:…>` не
записывается никогда: это вывод читающего инструмента, и запись его заменила бы
живой креденшл тем словом, которым его спрятали. И сырые блобы панели
(`finalMask`, `xhttpExtraParams`, `muxParams`, `sockoptParams`) исключены из
любого патча хоста — в работающей установке заметная часть хостов несёт внутри
`finalMask` рабочий пароль Hysteria2.

### Что доказано на самом деле, а что нет

`host_edit` — единственный мутатор, чья ветка **применения** прогонялась на
работающей системе: на работающей панели Remnawave 3.2.3 сменили подпись хоста,
проверили, что пароль в `finalMask` уцелел и что не изменилось ничего сверх
заявленного поля, и откатили обратно. Эта проверка относится к правке подписи
на 3.2.3. Новые mapper, привязки интеграций, синхронизация и резервные копии
удаляемых хостов в обновлении 3.3.2 проверены локальными тестами; записи на
живой панели при этой миграции не выполнялись.

## Модель безопасности

**Два профиля.** `human` — доверенный оператор, и ему достаются конкретные
пригодные к действию отказы, включая точную ssh-команду, когда туннель закрыт.
`bot` — недоверенный канал: любой отказ схлопывается в одно и то же сообщение,
чтобы реестр нельзя было перебрать, нащупывая, какие имена отвечают иначе. Ни
один пишущий инструмент боту не предлагается никогда: в `rw` профиль `bot` видит
те же двадцать читающих инструментов, что и в `ro`.

**Запрещённый класс, отдельный от просто опасного.** Эти операции не закрыты
воротами — их нет, и сканер на этапе сборки роняет прогон, если их путь
встретился в исходнике литералом. Маршруты identity и keygen нод (GET, у
которого в теле ответа приватный ключ). Маршруты токенов, авторизации и passkey
(панель отдаёт токены открытым текстом, а созданный токен — это постоянный админ
мимо всех ворот). Настройки панели и подписки. Выгрузка `/admin/config` целиком.
Ручная пометка задачи провижининга успешной — она не выполняет работу, а лишь
переводит услугу в ACTIVE при по-прежнему отсутствующем пользователе в панели.
Удаление платежа, бонуса или списания — голый `DELETE FROM` по реестру, при
котором `users.balance` не пересчитывается. Готовые к употреблению ссылки
подписки и connection-keys. `restart-all`, `reorder`, bulk-actions сквадов и
`PUT /admin/spool` с `job_users` — рассылка всем клиентам без отмены.

Класс *сузился*, и каждое сужение было исправлением, а не послаблением. Чтение
шаблонов было запрещено вместе с записью, хотя причина — нет гита, нет отката —
говорила только про запись; ширина стоила не теоретически: заметная доля
уведомлений за одно наблюдавшееся окно отрендерилась пустыми и не отправила
ничего, задача при этом отчиталась SUCCESS, а причина молчания лежит внутри
тела шаблона. Теперь чтение
открыто, `POST` — под `template_edit`, который принёс с собой откат, а `PUT` и
`DELETE` закрыты: у только что появившегося и у только что исчезнувшего шаблона
нет предыдущего состояния, которое можно снять. Запрет на `/api/sub` был
префиксным и заодно накрывал `/api/subscription-page-configs` и
`/api/subscription-request-history` — два читающих контроллера, ключей не
выдающих; теперь это `exact` плюс `prefix` на `/api/sub/`. Массовые операции над
клиентами панели запрещались потому, что применяются ко всей базе без списка на
просмотр, — верно ровно до тех пор, пока никто не считает: `bulk_ops` считает у
панели до применения, отказывается, когда число установить не удалось или оно
выше `HQ_MCP_MAX_BULK_USERS`, и боту не предлагается.

`POST /api/users/bulk/delete-by-status` остаётся запрещённым **по своей форме**:
в его теле статус, а не список людей. Панель ставит задачу в очередь и удаляет
тех, кто подойдёт *в момент её выполнения*, — не тех, кого просматривал
оператор, — и отвечает 202 с пустым телом и без счётчика, так что учётки,
истёкшие в промежутке, удаляются невидимо. Возможность сохранена как
`bulk_ops delete_by_status`: он перечисляет конкретные id, показывает их и
удаляет ровно их через `bulk/delete`. Массовые маршруты на *других* сущностях —
хосты, ноды, сквады, рассылки спула — такого шага подсчёта не имеют и остаются
отсутствующими.

**Секреты маскируются на выходе — и по имени ключа, и по форме значения.** По
имени: закрытый список кредовых ключей, совпадение с
`token|secret|key|password|auth` при явном списке исключений, хвостовая
маскировка для нескольких и маскировка PII для профиля `bot`. Этого мало, и за
один день это подвело трижды: токен Telegram-бота ехал внутри
`response.request.url` строки спула (ключ называется `url`), он же лежал в
колонке `host` транспортных строк SHM, а тела шаблонов несут креденшлы
голыми подстроками, рядом с которыми имени поля нет вовсе. Поэтому обход
прогоняет каждую проходящую строку ещё и через правила **формы значения**: JWT;
`NAME=<значение>`, где имя обещает секрет, а значение не похоже на плейсхолдер;
токены Telegram-бота с окружающим путём и без него; `user:password@` внутри URL.
Живёт он внутри `redact`, которую зовут оба HTTP-клиента на входе и исполнитель
на выходе, — помнить об этом не обязан ни один отдельный инструмент.

Правила откалиброваны, а не угаданы, и калибровка объявлена прямо в исходнике:
порог «непрозрачного прогона» (32+ символа, выглядящих случайно) измерен на
настоящих телах шаблонов и *выключен* на структурированных ответах API, где его
перешагивают data-URI иконки и hex `uniq_id` платежа — вырезав их, чистка
погасила бы ровно те поля, ради которых инструмент и писали. Границей
безопасности это всё равно не является, и исходник так и говорит: у секрета,
написанного словами, формы нет; всё, что проходит фильтр, остаётся внутри
профиля `human`. Те же правила использует `scripts/no-secrets.test.ts`, не
пускающий секрет в публикуемый коммит: две копии знания о том, «как выглядит
секрет», расходятся молча, и вторая продолжает *выглядеть* работающей.

**`sql_query` ничего не выполняет.** Он валидирует и отказывает, и говорит об
этом в собственном исходнике. Лексическая проверка — дешёвый первый фильтр и
явно не граница безопасности; модуль перечисляет обходы, которые её проходят, и
тесты держат их открытыми, чтобы никто не принял фильтр за гарантию. Пока
выполнение не подключено, предусловия объявлены в том же файле: роль только на
чтение, транзакция только на чтение, таймаут запроса и запретный список колонок.

## Что стоит знать про ограничения

- HTTP-транспорт говорит на MCP (`/mcp`, streamable HTTP) и отдаёт тот же набор
  инструментов, что stdio: публикует их одна функция на оба транспорта. Чего он
  намеренно не умеет: сессий (`Mcp-Session-Id` не выдаётся), server-initiated
  сообщений, а с ними — потока SSE на `GET` и возобновления по `Last-Event-ID`.
  Каждый вызов самодостаточен, поэтому сервер и транспорт создаются свежими на
  запрос; этого требует и сам SDK, чей бессессионный транспорт запрещено
  переиспользовать.
- По маршруту `/mcp` два исхода исполнителя недостижимы, и счётчики `/metrics`
  видят по нему два из четырёх. Негодный вход разбирает SDK ДО инструмента и сам
  отвечает `-32602`; несуществующее имя он тоже отбивает сам, не доходя до
  реестра. Поэтому `invalid_input` и `not_found` по этому маршруту не появляются
  ни в ответе, ни в отчёте. На REST-фасаде достижимы оба.
- Запрос к `/mcp` с заголовком `Origin` отбивается 403 без вариантов: сервер
  слушает петлю, а страница в браузере может увести свой домен на `127.0.0.1` и
  ходить сюда от имени оператора. Браузер проставляет `Origin` на любом POST
  кросс-происхождения, настоящий клиент MCP — никогда, а заголовков CORS сервер
  не отдаёт, поэтому браузерного клиента у него нет и быть не может. Встроенные
  `allowedHosts`/`allowedOrigins` для этого не годятся: в этой версии SDK они
  помечены устаревшими в пользу внешнего middleware, а пустой список origin-ов у
  них означает «проверка выключена», а не «никакой origin не годится».
- Двум инструментам нужен туннель во внутреннюю сеть, и без него они отказывают.
  Видимыми они остаются намеренно: исчезнувший инструмент учит модель, что такой
  возможности не существует, тогда как на деле закрыт порт.
- `sync_audit` вычитывает обе системы до конца и является здесь единственным
  дорогим вызовом — ради этого у него собственная норма запросов.
- Размер страницы панели измеряется в рантайме, а не предполагается: API не
  объявляет максимума, а фактический менялся между релизами.
- Массовые маршруты панели отвечают 202 или 204 с пустым телом и часть работы
  ставят в очередь, поэтому «применено» означает «принято панелью», а не
  «сделано для всех». Число, установленное планом заранее, — единственное
  честное, какое здесь вообще есть.
- Правки, сделанные в панели, не переносятся обратно в биллинг SHM, и
  инструменты, которые их делают, об этом говорят. Шага сверки нет; расхождение
  вам потом покажет `sync_audit`.

## Разработка

```bash
pnpm test          # модульные тесты
pnpm typecheck
pnpm test:guards   # сканер секретов и предохранители скрипта захвата фикстур
```

Тесты гоняются на фикстурах, повторяющих форму настоящих ответов. Там, где
дефект был виден только на работающей системе, тест, который его закрепляет, так
и говорит.

## Лицензия

MIT.