# SSH-переадресация портов для автономных агентов: более безопасные туннели

SSH-переадресация портов для автономных агентов требует более строгой схемы, чем обычный доступ разработчика. Туннель может превратить базу данных, панель администратора или staging-сервис, доступные только через localhost, в ресурс, к которому агент обращается одной командой SSH. Если у агента есть широкие SSH-полномочия, туннель лишь один из симптомов. Настоящая проблема в том, что никто не ограничил место назначения, срок действия и полномочия этой команды.

Я видел, как команды называли туннель «временным», потому что кто-то ввёл `ssh -L` в терминале. Потом терминал оставался запущенным несколько дней, его порт становился зависимостью другого процесса, а человек, открывший туннель, оказывался недоступен, когда служба безопасности спрашивала, почему рядом с production-сервисом появился необъяснимый слушающий порт. Автономная работа усиливает этот сценарий: агент может повторять попытки, переподключаться и использовать туннель гораздо стабильнее, чем отвлечённый человек.

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

## Туннель меняет сетевую доступность, а не только поведение SSH

SSH-переадресация создаёт слушающий порт на одной стороне SSH-соединения и передаёт его трафик к месту назначения, доступному с другой стороны. Этот порт становится путём доступа. Если рассматривать его просто как «доступ по SSH», легко упустить главное: кто может подключиться, куда попадёт трафик и что именно сможет пройти через туннель.

Представим агента на сервере сборки, которому нужно обратиться к `db-admin.internal.example` на порту 5432. Локальная переадресация может сделать этот сервис доступным как `127.0.0.1:15432` на сервере сборки. База по-прежнему закрыта от публичного интернета, но любой процесс на этом сервере, способный обратиться к слушающему порту, теперь может попытаться подключиться. SSH-сервер также становится разрешённым маршрутом к базе.

Это может быть приемлемо. Но это не то же самое, что разрешить агенту «использовать SSH для обслуживания». В подтверждении нужно описать точный маршрут:

- рабочая нагрузка и хост, инициирующие соединение;
- адрес и порт прослушивания;
- хост и порт назначения;
- причина создания маршрута;
- срок действия и сотрудник, давший подтверждение.

При удалённой переадресации место прослушивания меняется. Если агент подключается к bastion-серверу и просит его слушать порт 18080, процесс, который обращается к этому порту, может получить трафик обратно к хосту агента. Удалённая переадресация часто становится неожиданностью: она может открыть сервис за межсетевым экраном без входящего правила на этом экране.

OpenSSH описывает эти режимы отдельно в `ssh(1)`: `-L` создаёт локальную переадресацию, `-R` удалённую, а `-D` запускает SOCKS-прокси. Это полезное операционное разделение. Не подтверждайте абстрактную возможность «переадресации портов» как одно право. Каждый режим создаёт отдельный слушающий порт и требует собственных ограничений.

## Для локальной, удалённой и динамической переадресации нужны разные решения

Локальная переадресация обычно подходит для задачи агента, которому нужен один известный сервис через контролируемый jump host. Слушающий порт находится на стороне агента, а SSH-сервер подключается к внутреннему месту назначения. Формат команды знаком:

```sh
ssh -N \\
  -L 127.0.0.1:15432:db-admin.internal.example:5432 \\
  agent-db-tunnel@bastion.internal.example
```

`-N` просит SSH не запускать удалённую команду. Это не делает соединение безвредным. Адрес привязки `127.0.0.1` ограничивает слушающий порт локальным хостом, а `db-admin.internal.example:5432` указывает запрошенное место назначения. Это разные свойства, и оба должны попасть в запись о подтверждении.

Удалённую переадресацию следует разрешать только после явного решения создать слушающий порт на удалённой стороне. Команда ниже просит bastion слушать собственный loopback-интерфейс и передавать трафик к порту 8080 на машине агента:

```sh
ssh -N \\
  -R 127.0.0.1:18080:127.0.0.1:8080 \\
  agent-return-path@bastion.internal.example
```

Не считайте удалённый слушающий порт на loopback автоматически безопасным. Локальный процесс на bastion может иметь гораздо более широкий доступ, чем исходная машина агента, а другой пользователь общего bastion может обратиться к этому порту. До разрешения проверьте пользователей и локальные процессы на удалённом хосте.

Для автономных агентов динамическую переадресацию следует по умолчанию запрещать:

```sh
ssh -N -D 127.0.0.1:1080 agent@bastion.internal.example
```

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

Не пытайтесь решить проблему более длинной формой подтверждения. Запретите динамическую переадресацию для учётной записи агента. Если задаче нужны несколько мест назначения, опишите каждую переадресацию отдельно или поставьте перед сервисами специализированный прокси с собственной аутентификацией и журналами.

## Адрес привязки определяет, кто может использовать слушающий порт

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

Для локальной переадресации указывайте loopback-адрес явно:

```sh
-L 127.0.0.1:15432:db-admin.internal.example:5432
```

Не полагайтесь на значение по умолчанию. Обычно OpenSSH привязывает локальную переадресацию к loopback, если адрес не указан, но явное значение упрощает проверку, журналирование и расследование. Оно также не даёт последующему изменению клиентской конфигурации незаметно расширить область действия слушающего порта.

Опасный вариант выглядит так:

```sh
-L 0.0.0.0:15432:db-admin.internal.example:5432
```

Он открывает порт 15432 на каждом IPv4-интерфейсе хоста-клиента. Любой хост, способный обратиться к клиенту, сможет попытаться использовать туннель. Сервер сборки в общей подсети может превратиться в мост к внутренней базе, хотя сама база принимает соединения только от bastion.

IPv6 требует такого же внимания. `::1` означает loopback, а `::` слушает все IPv6-интерфейсы. Проверяйте оба семейства адресов. Я видел, как команды убеждались в безопасности `127.0.0.1`, а затем обнаруживали, что автоматизация открыла ещё и IPv6-порт через другой путь конфигурации.

Для удалённых переадресаций SSH-сервер определяет, какие адреса привязки принимать. В `sshd_config` параметр `GatewayPorts` влияет на поведение привязки удалённых переадресаций. OpenSSH указывает, что в обычном случае удалённые переадресации привязываются к loopback, с учётом запрошенного адреса и политики сервера. Оставляйте `GatewayPorts no`, если нет проверенной причины разрешать более широкие удалённые слушающие порты. Значение `GatewayPorts clientspecified` даёт клиентам слишком много контроля для учётной записи агента.

После открытия переадресации проверьте слушающий порт на машине, которой он принадлежит. В macOS и Linux пригодится такая локальная проверка:

```sh
lsof -nP -iTCP:15432 -sTCP:LISTEN
```

В выводе должен быть процесс `ssh` с адресом вроде `127.0.0.1:15432` или `::1:15432`. Если отображается `*:15432`, остановите задачу и проверьте команду и клиентскую конфигурацию. Эта команда подтверждает только локальный адрес слушающего порта. Она не доказывает, что дальняя сторона ограничена нужным местом назначения.

## Отдельная SSH-учётная запись должна описывать разрешённый маршрут

Отдельная SSH-учётная запись с ограничениями на стороне сервера это минимальная разумная граница для туннелей агентов. Клиентский файл конфигурации не может защитить систему от агента, который способен менять аргументы команды. Ограничения должны находиться там, где SSH-сервер принимает соединение.

Для учётной записи, которой разрешена только локальная переадресация, начните с такого блока в `sshd_config`:

```text
Match User agent-db-tunnel
    PasswordAuthentication no
    KbdInteractiveAuthentication no
    PubkeyAuthentication yes
    PermitTTY no
    X11Forwarding no
    AllowAgentForwarding no
    AllowStreamLocalForwarding no
    AllowTcpForwarding local
    PermitOpen db-admin.internal.example:5432
    GatewayPorts no
    PermitUserEnvironment no
```

`AllowTcpForwarding local` разрешает локальные переадресации и запрещает удалённые. `PermitOpen` указывает место назначения, которое может запросить эта учётная запись. Ограничение важно: иначе команда локальной переадресации сможет указывать любой хост и порт, доступные с bastion. Без `PermitOpen` агенту, которому действительно нужна база, ничто не помешает запросить переадресацию к административному API, кэшу, сервису метаданных или другому SSH-серверу.

Осторожно работайте с именами хостов. SSH-сервер разрешает имя места назначения самостоятельно, поэтому имя в `PermitOpen` должно разрешаться именно там. Выбирайте стабильное имя, которым владеет ваша инфраструктура. Если DNS-имя может указывать на произвольные адреса, конфигурация будет выглядеть узкой, хотя фактическое место назначения сможет незаметно измениться. Для фиксированного устройства IP-адрес может быть понятнее, но имя хоста подходит при строгом контроле внутреннего DNS и владельцев сервисов.

Руководство OpenSSH `sshd_config(5)` описывает `PermitOpen` как ограничение назначения в формате `host:port`. Этот параметр не превращает учётную запись в полноценный механизм политик. Он хорошо выполняет одну точную задачу: отклоняет запрос на переадресацию, если место назначения не указано в списке. Такой же точной должна быть и архитектура учётной записи.

Не оставляйте этой учётной записи оболочку со словами «агент всё равно её не использует». Уберите саму возможность. Отдельная учётная запись должна существовать для одной роли подключения. Настройте для её открытого ключа принудительную команду, которая завершается сразу, если нужна дополнительная защита от интерактивного доступа, но проверьте такое решение в своей среде. Некоторые принудительные команды и обёртки мешают ожидаемым SSH-сессиям, а неработающее ограничение часто приводит к тому, что в спешке удаляют весь блок.

Также не разрешайте переадресацию агентского ключа. `AllowAgentForwarding no` не даёт подключённому серверу просить клиентский SSH-агент подписывать запросы аутентификации. Учётная запись для туннеля должна передавать трафик, а не становиться ступенькой к другим хостам.

Если цели нужны два разрешённых сервиса, укажите две отдельные записи `PermitOpen`. Не используйте подстановочный знак и не заменяйте эту учётную запись общей учётной записью bastion только ради экономии времени. Создать ещё одну учётную запись дешевле, чем объяснять, почему агент получил доступ ко всей внутренней подсети.

## Временный доступ означает принудительное завершение и корректное закрытие

Туннель временный только тогда, когда его завершает не одна добрая воля. SSH продолжает держать соединение, пока клиент его не закроет, сеть не оборвётся или тайм-аут не прервёт работу. Автономный агент может оставить процесс после успешного теста, ошибки или истечения срока задачи.

Задайте для каждой задачи максимальную продолжительность сессии, которую агент не может изменить. Если это доступно, исполнитель задач может использовать `timeout`:

```sh
timeout 20m ssh -N \\
  -o ExitOnForwardFailure=yes \\
  -o ServerAliveInterval=30 \\
  -o ServerAliveCountMax=3 \\
  -L 127.0.0.1:15432:db-admin.internal.example:5432 \\
  agent-db-tunnel@bastion.internal.example
```

`ExitOnForwardFailure=yes` не даёт задаче продолжиться, если SSH не смог создать запрошенный слушающий порт. Без этого параметра агент может продолжить работу и сообщить неясную ошибку приложения вместо ошибки настройки туннеля. Параметры проверки доступности сервера заставляют SSH завершиться после нескольких пропущенных ответов, что помогает при незаметной потере сетевого соединения. Они не заменяют ограничение в 20 минут.

В macOS команда GNU `timeout` по умолчанию не установлена. Используйте механизм ограничения времени вашего исполнителя, процесс под контролем супервизора с установленным сроком или небольшую обёртку, которая отправляет `TERM`, а затем проверяет завершение процесса. Не заменяйте отсутствующий срок действия cron-задачей, убивающей «старые SSH-процессы». Она может завершить несвязанные задачи, и вы не сможете доказать, какое соединение остановила.

Требуйте, чтобы задача закрывала туннель и в собственном пути очистки. Для запущенного ею процесса сохраните PID, отправьте `TERM` после завершения задачи, немного подождите и проверьте исчезновение слушающего порта с помощью `lsof` или `ss`. Ограничение времени ловит сбои, а путь очистки обрабатывает штатное завершение.

К мультиплексированию соединений нужно относиться особенно осторожно. При `ControlMaster` и общем `ControlPath` разные вызовы SSH могут использовать одно главное соединение. Для человека в терминале это экономит время настройки. Для агента такая схема смешивает владельцев и сроки действия: одна задача может унаследовать соединение, подтверждённое для другой, а закрытие одного клиента не обязательно закроет главное соединение. Отключите мультиплексирование для учётной записи туннеля или создавайте отдельный путь управления для каждой подтверждённой задачи.

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

## Подтверждение должно описывать соединение, которое можно восстановить по записи

Запись о подтверждении должна позволять ответить, кто разрешил маршрут, что его открыло и оставался ли он в заявленных границах. Общий комментарий в задаче вроде «доступ агента подтверждён» не является доказательством. В нём нет слушающего порта, места назначения и срока действия.

Сохраняйте подтверждение до запуска туннеля. Запишите личность человека, давшего подтверждение, время, идентификатор задачи или запуска, личность процесса агента, исходную машину, отдельную SSH-учётную запись и отпечаток открытого ключа. Затем полностью зафиксируйте запрошенную переадресацию: направление, адрес привязки, порт прослушивания, хост назначения, порт назначения, цель и время завершения.

У записи о результате должны быть собственные поля. Укажите, создал ли SSH переадресацию, идентификатор клиентского процесса, время начала, окончательный код завершения, время закрытия и причину закрытия. Тайм-аут, обычная очистка, отзыв оператором и сбой сети имеют разное значение при разборе.

Используйте структурированные записи вместо текста, из которого кто-то должен извлекать поля. Для многих внутренних систем достаточно такой формы:

```json
{
  "run_id": "run-7c31",
  "approved_by": "oncall-engineer",
  "approved_at": "2025-04-12T14:03:00Z",
  "ssh_account": "agent-db-tunnel",
  "direction": "local",
  "listen": "127.0.0.1:15432",
  "destination": "db-admin.internal.example:5432",
  "expires_at": "2025-04-12T14:23:00Z",
  "purpose": "run migration compatibility check"
}
```

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

Не позволяйте тому же агенту создавать подтверждение и считать его авторизацией человека. Агент может объяснить, зачем ему нужно соединение, но решение о разрешении должен принимать человек или отдельный управляемый процесс. Если действие настолько типовое, что его можно подтверждать автоматически, зафиксируйте это решение в узком описании задачи со сроком действия и сохраняйте версию правила, которая его разрешила.

Усталость от подтверждений говорит о плохом дизайне. Если просить человека одобрять каждый пакет, подтверждения превращаются в бессмысленные клики. Подтверждение одного конкретного временного маршрута даёт человеку понятный объект для оценки. Форма должна быть короткой, но в ней обязательно нужны адрес привязки и точное место назначения. Эти две строки удивительно часто выявляют ошибочные запросы.

## Храните учётные данные отдельно от агента, а полномочия отдельно от его запроса

Автономный агент должен запрашивать соединение, а не получать закрытый ключ, с помощью которого позднее можно создавать произвольные подключения. Если широкий SSH-ключ попал в окружение процесса или рабочую папку агента, он может скопировать его, вызвать другой SSH-клиент, использовать после завершения разрешённой задачи или передать инструменту, которому вы не планировали доверять. Фраза в запросе «используй это только для базы» не ограничивает ни одно из этих действий.

Лучше хранить SSH-учётные данные в отдельной границе выполнения. Агент отправляет запрос с полями маршрута. Граница проверяет доступность хранилища, наличие авторизации сессии и требуемого подтверждения для запрошенных учётных данных. Она выполняет действие SSH и возвращает результат, не передавая закрытый ключ агенту.

Sallyport использует такую модель в macOS: встроенный помощник `sp-ssh` выполняет действия по SSH, а ключи остаются в зашифрованном хранилище приложения и не попадают к MCP-агенту.

Это различие важно при разборе сбоя. Хранение учётных данных отвечает на вопрос: «Мог ли агент повторно использовать секрет в другом месте?» Ограничение маршрута отвечает на вопрос: «Могла ли эта учётная запись обратиться к неразрешённому месту назначения?» Подтверждение отвечает на вопрос: «Кто разрешил именно это использование?» Команды часто смешивают эти меры, а затем обнаруживают, что в аудите указано использование ключа, но не указано, какой маршрут он открыл.

Сделайте интерфейс запроса уже, чем набор произвольных аргументов SSH. Принимайте поля вроде `destination_host`, `destination_port`, `listen_address`, `listen_port` и `max_duration`. Отклоняйте параметры клиента, которые меняют проксирование, направление переадресации, выполнение удалённых команд, файлы идентификации или управляющие сокеты. Если вы принимаете произвольную строку команды `ssh`, то передаёте интерпретацию политики разбору строки. Такой подход ломается, как только кто-то добавляет `-R`, `-D`, `ProxyCommand` или вторую переадресацию.

Не предоставляйте общую оболочку как запасной путь для рабочего процесса туннеля. Агенту не нужно интерактивно запускать `ssh`, если действие состоит в создании одного ограниченного канала. Узкие действия проще подтверждать и отзывать.

## Аудит должен выдержать недоброжелательное расследование

Журналы туннеля полезны только в том случае, если скомпрометированный агент или локальный процесс не может изменить их задним числом. Текстовые журналы рядом с рабочей папкой агента удобны, но почти ничего не доказывают, если один и тот же субъект может редактировать и команду туннеля, и её историю.

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

Собирайте подтверждающие записи из нескольких источников. Журналы SSH на bastion могут показать успешную аутентификацию и ошибки переадресации. Исполнитель задач покажет создание и завершение процесса. Сервис назначения может показать соединение от bastion. Ни один источник сам по себе не объясняет событие целиком, но вместе они выявляют противоречия.

Например, подтверждение разрешает маршрут от `127.0.0.1:15432` к одной базе на 20 минут. Если журнал клиента говорит, что соединение закрыто в 14:23, а сервис назначения видит трафик от bastion в 15:10, немедленно начните проверку. Агент мог открыть другое соединение, использовать общее главное соединение или кто-то мог применить другой путь с той же учётной записью.

Sallyport создаёт журналы сессий и отдельных вызовов из зашифрованного аудита с хешированной цепочкой, а `sp audit verify` проверяет эту цепочку офлайн без ключа хранилища. Это более надёжное свойство, чем файл журнала, который может изменить процесс агента, но оно не отменяет необходимости в строгих ограничениях учётной записи.

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

## Сначала отзывайте маршрут, потом разбирайте события

Если вы подозреваете ошибочный или злоупотребляемый туннель, сначала остановите путь соединения. Расследование может подождать несколько минут, а открытый маршрут в это время способен передавать данные или облегчать боковое перемещение, пока люди спорят, какой журнал считать главным.

Завершите процесс клиента и убедитесь, что его слушающий порт исчез. Отключите отдельную SSH-учётную запись или удалите её открытый ключ на принимающем сервере. Если через туннель проходили учётные данные целевого сервиса и к ним мог обратиться ненадёжный процесс, замените их по процедуре самого сервиса. Не меняйте несвязанные учётные данные только потому, что существовал туннель.

Затем сохраните свидетельства. Скопируйте запись о подтверждении, запрошенный маршрут, журналы процессов, журналы SSH-сервера, записи о соединениях сервиса назначения и результат закрытия. Используйте одну временную зону, предпочтительно UTC. При разборе инцидента люди теряют часы, когда исполнитель задач использует местное время, bastion работает в UTC, а приложение записывает время без указания зоны.

Перед объявлением инцидента локализованным проверьте наличие второго слушающего порта. На хосте агента изучите запрошенный порт и соседние процессы переадресации. На bastion проверьте активные SSH-сессии и журналы аутентификации отдельной учётной записи. В месте назначения ищите соединения, начавшиеся после заявленного срока действия. Цель состоит в поиске сохранившихся путей доступа, а не в быстром создании аккуратной версии событий.

Если человек подтвердил неправильный маршрут, сначала исправьте поверхность запроса. Новый экран проверки, который по-прежнему принимает произвольные флаги SSH, воспроизведёт ту же ошибку. Если серверное ограничение не сработало, после исправления повторите точную отклонённую команду и сохраните вывод ошибки. Контролям можно доверять, когда вы проверили, что они действительно запрещают нужный маршрут.

## Проверьте запреты до того, как агент начнёт зависеть от туннеля

Схема туннеля готова только после проверки разрешённого соединения и запрещённых вариантов. Тесты штатного сценария обнаруживают опечатки. Тесты отказа показывают, существует ли граница на самом деле.

Используйте непроизводственную учётную запись и попробуйте переадресацию, которая должна работать:

```sh
ssh -N -o ExitOnForwardFailure=yes \\
  -L 127.0.0.1:15432:db-admin.internal.example:5432 \\
  agent-db-tunnel@bastion.internal.example
```

Затем запросите место назначения, не разрешённое `PermitOpen`:

```sh
ssh -N -o ExitOnForwardFailure=yes \\
  -L 127.0.0.1:15433:admin-api.internal.example:443 \\
  agent-db-tunnel@bastion.internal.example
```

Вторая команда должна завершиться ошибкой во время настройки переадресации. Сохраните вывод клиента и запись в журнале сервера. Затем попробуйте `-R` и `-D`; оба варианта должны завершиться ошибкой для учётной записи с `AllowTcpForwarding local`. Наконец, запросите слушающий порт не на loopback, а на `0.0.0.0` и убедитесь, что граница запроса отклоняет его до запуска SSH или что локальная среда не допускает раскрытие, которого вы хотите избежать.

Проверьте завершение с коротким сроком действия. Запустите разрешённый туннель, дождитесь срабатывания ограничения супервизора и проверьте три вещи: процесс SSH завершился, `lsof` больше не находит слушающий порт, а запись о закрытии указывает на истечение срока, а не делает вид, что задача завершилась штатно. Это небольшое упражнение обнаруживает проблему оставшихся процессов до того, как production-задача начнёт зависеть от туннеля, который никогда не закрывается.

Когда растёт давление, придерживайтесь простого правила: автономный агент может получить только маршрут, необходимый для текущей задачи, на ограниченное время и при наличии именного подтверждения. Если ваша схема не может описать этот маршрут одной строкой и затем доказать его закрытие, у агента больше сетевых полномочий, чем оправдывает задача.
