# Мультиплексирование SSH-соединений: риски управляющих сокетов

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

Я видел, как команды расследовали якобы закрытое окно обслуживания и обнаруживали, что локальный master `ssh` по-прежнему удерживал активный транспорт, сокет принимал новые клиенты, а следующая команда повторно использовала его без новой интерактивной аутентификации. Ничего необычного не произошло. Конфигурация работала именно так, как описано в документации OpenSSH. Команда сочла завершение shell-команды завершением доступа.

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

## Управляющий сокет может жить дольше открывшей его команды

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

Типичная конфигурация выглядит безобидно:

```sshconfig
Host build-box
    HostName 192.0.2.44
    User deploy
    ControlMaster auto
    ControlPath ~/.ssh/cm/%C
    ControlPersist 20m
```

Первый вызов `ssh build-box` создает сетевое соединение и Unix-сокет в `~/.ssh/cm/`. Последующие команды, например `ssh build-box 'uname -a'`, `scp` и `sftp`, могут использовать этот сокет. При `ControlPersist 20m` master остается доступным еще двадцать минут после закрытия последней сессии.

Поэтому следующая последовательность вполне обычна:

1. Задача выполняет `ssh build-box 'apply-change'` и завершается с кодом 0.
2. Master остается подключенным, потому что `ControlPersist` велит ему продолжать работу.
3. Через девятнадцать минут другой локальный процесс выполняет `ssh build-box 'read-status'`.
4. Процесс открывает канал через уже аутентифицированный транспорт.

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

В руководстве `ssh_config` OpenSSH говорится, что `ControlMaster` позволяет использовать несколько сессий через одно сетевое соединение, а `ControlPersist` оставляет master работать в фоновом режиме. Эти положения нужно читать вместе. `ControlPersist` это не просто настройка производительности. Она меняет период, в течение которого локальный процесс может запросить новые каналы на аутентифицированном соединении.

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

## Одно событие аутентификации не равно одному событию команды

Команды часто смешивают понятия аутентификации, соединения, канала и команды. В SSH это разные вещи, и мультиплексирование хорошо показывает различие.

Исходный master создает TCP-соединение с сервером, проверяет хост, согласует криптографию и аутентифицирует учетную запись. После аутентификации он может открывать SSH-каналы. Команда shell, интерактивный shell, передача через SFTP, запрос локального перенаправления порта и запрос удаленного перенаправления используют каналы или связанные с ними запросы в этом транспорте.

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

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

Это различие меняет вопросы при расследовании инцидента. Вопрос «Остался ли ключ на диске?» важен, но сам по себе не отвечает, сохранился ли доступ. Вместо этого спросите:

- Остался ли master подключенным к удаленному назначению?
- Мог ли другой локальный процесс обратиться к его управляющему сокету?
- Принимал ли master дополнительные сессии, передачи файлов или запросы перенаправления?
- Кто мог запуститься от имени владельца сокета или пройти через каталог сокета?
- Когда master действительно завершил работу?

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

## Общие сокеты превращают границы процессов в границы доступа

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

Рассмотрим учетную запись CI-раннера с такой конфигурацией:

```sshconfig
Host *
    ControlMaster auto
    ControlPath /tmp/ssh-%r@%h:%p
    ControlPersist 1h
```

Задание A подключается как `deploy` к `app.internal`. Оно создает master и сокет в `/tmp`, после чего завершается. Задание B под той же локальной учетной записью подключается к той же цели. Если оно может узнать имя сокета и получить к нему доступ, то переиспользует аутентифицированный транспорт задания A.

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

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

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

```sh
set -eu
run_id="release-4821"
cm_dir="$HOME/.ssh/task-control/$run_id"
mkdir -p "$cm_dir"
chmod 700 "$cm_dir"

socket="$cm_dir/%C"
ssh -o ControlMaster=auto \
    -o ControlPersist=5m \
    -o ControlPath="$socket" \
    build-box 'id && hostname'
```

Токен `%C` не дает имени стать слишком длинным и уменьшает число конфликтов между разными наборами параметров соединения. В руководстве `ssh_config` OpenSSH он определен как хеш параметров соединения. Это полезно, но в хеш не входят ваш тикет развертывания, сессия агента или идентификатор задачи. В этом примере недостающую границу создает родительский каталог.

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

## ControlPersist это политика хранения, а не план очистки

`ControlPersist` велит SSH оставлять master доступным после закрытия клиентских сессий. Параметр принимает `yes`, что оставляет процесс работать в фоне бессрочно, или значение времени, например `10m`. И то и другое это варианты хранения соединения.

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

В некоторых случаях короткий тайм-аут оправдан. Контролируемый автоматизированный процесс может выполнять несколько команд подряд и получать пользу от уже установленного соединения. Тогда сделайте тайм-аут короче ожидаемого простоя, изолируйте сокет для этого запуска и завершайте master при штатном выходе. Тайм-аут будет запасным механизмом при сбое, а не обычным способом закрытия.

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

Еще одна распространенная ошибка состоит в предположении, что сбой команды закрывает master. Shell может досрочно завершиться после неудачной удаленной команды, пока фоновый master продолжает работать. То же возможно при отмене задачи. Очистка должна выполняться после успеха, ошибки и прерывания, а ее результат нужно записывать.

Если автоматизация использует trap, ограничьте очистку нужными действиями и проверяйте ее цель. Не удаляйте сначала путь к сокету вслепую. Удаление пути может затруднить расследование, пока процесс master и соединение продолжают существовать.

```sh
cleanup() {
    ssh -S "$socket" -O exit build-box >/dev/null 2>&1 || true
    rmdir "$cm_dir" 2>/dev/null || true
}
trap cleanup EXIT HUP INT TERM
```

Этот шаблон сначала просит master завершиться и только потом пытается удалить каталог. Если `ssh -O exit` завершается с ошибкой, сохраните каталог и разберитесь в причине, не стирая доказательства. В рабочем коде запишите в журнал задачи ошибку, идентификатор процесса и путь к сокету.

## `exit` и `stop` означают разные действия

OpenSSH предоставляет управляющие команды через `ssh -O`. Операторы часто выбирают не ту, поскольку оба названия звучат как очистка.

`ssh -O check host` проверяет, работает ли master, и сообщает его идентификатор процесса, если получает ответ. `ssh -O exit host` просит master завершиться. Для законченной задачи обычно нужна именно `exit`, потому что она завершает повторно используемый транспорт.

`ssh -O stop host` велит master перестать принимать новые мультиплексированные сессии. Уже открытые сессии продолжают работать. Это может пригодиться оператору, который хочет дождаться завершения активной работы, но не подтверждает, что весь SSH-доступ закончился вместе с задачей. Работающий master с активными каналами все еще имеет активное сетевое соединение.

Используйте путь к сокету явно, если диагностика не должна зависеть от текущей конфигурации пользователя:

```sh
ssh -S "$socket" -O check build-box
# Master running (pid=41782)

ssh -S "$socket" -O exit build-box
# Exit request sent.

ssh -S "$socket" -O check build-box
# Control socket connect(...): No such file or directory
```

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

Есть важный неоднозначный случай. Сокет может остаться после сбоя master, а процесс может исчезнуть между двумя проверками. Устаревший путь не доказывает, что доступ еще существует. И наоборот, неотвечающая проверка не доказывает завершение удаленного соединения, если путь был удален до осмотра. Перед удалением проверьте таблицу процессов и открытые Unix-сокеты.

В macOS самым быстрым локальным инструментом проверки часто оказывается `lsof`:

```sh
lsof -nP -U | grep '/.ssh/task-control/'
ps -p 41782 -o pid=,ppid=,lstart=,etime=,command=
```

Сохраняйте вывод вместе с записью о задаче. Первая команда связывает Unix-сокет с процессом. Вторая показывает родительский процесс, время запуска, прошедшее время и сведения о вызове. Используйте `grep` как интерактивную помощь, а не как средство аудита. Настоящий сборщик должен напрямую запрашивать и сохранять нужные записи.

## Перенаправление портов делает бездействующий master важнее

Master, который выглядит бездействующим, все еще может хранить состояние перенаправления или принять последующий запрос на перенаправление. Поэтому подсчет одних shell-команд дает неполную картину.

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

Руководства OpenSSH описывают управляющие команды вроде `forward` и `cancel` для запросов перенаправления при активном мультиплексировании. Это полезно в работе. Но это также означает, что процесс, получивший доступ к сокету, может запросить сетевые пути за пределами обычной shell-команды, с учетом политики сервера и состояния master.

Для автоматизированных задач сделайте перенаправление отдельным исключением. Записывайте адрес привязки, локальный или удаленный порт, целевой хост и порт, а также результат очистки. Не позволяйте общей SSH-обертке незаметно унаследовать параметры `LocalForward`, `RemoteForward` или `DynamicForward` из широкого пользовательского блока `Host *`.

Проверяйте разрешенные настройки до того, как доверять псевдониму:

```sh
ssh -G build-box | grep -E '^(controlmaster|controlpath|controlpersist|localforward|remoteforward|dynamicforward) '
```

`ssh -G` выводит итоговую конфигурацию после применения правил выбора хоста и значений по умолчанию. Такая проверка обнаруживает удивительно частую проблему: задача использует простой псевдоним хоста, но подключенный файл конфигурации включает мультиплексирование или перенаправление далеко от собственной конфигурации задачи. В разных версиях команда может показывать дополнительные поля. Сохраняйте полный вывод `ssh -G`, а не только ожидаемые строки.

Удаленная система может записать одно исходное соединение, через которое пройдет несколько перенаправленных соединений приложений. Сетевые данные, журналы SSH-сервера и журналы задачи отвечают на разные части этой истории. Ни один источник не заменяет остальные.

## Одних серверных журналов недостаточно для восстановления локальной истории решений

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

Это не претензия к серверным журналам. Они работают в другой точке системы. `sshd` может записывать события аутентификации и соединения. Обертка с принудительной командой или подсистема аудита может записывать удаленные команды. Эти записи полезны, но по умолчанию не показывают каждого локального клиента мультиплексирования, поскольку сервер может видеть все каналы как части одного уже аутентифицированного транспорта.

Храните доказательства по обе стороны этой границы. Полезная запись о задаче включает:

- полную итоговую конфигурацию клиента из `ssh -G`, без секретов;
- удаленное имя хоста, адрес, учетную запись, результат проверки отпечатка хоста и исходный PID master;
- путь к управляющему сокету, закрытый родительский каталог, наблюдаемые моменты создания и завершения;
- каждую запрошенную удаленную команду, передачу или операцию перенаправления с ее кодом завершения;
- результат `check` перед очисткой, результат `exit`, а также проверку процессов и сокетов после очистки.

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

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

Для работы под управлением агента различайте намерение агента и выполненное действие SSH. «Развернуть версию X» это намерение. `ssh build-box 'sudo systemctl restart api'` это действие. Запись повторного использования сокета показывает, получило ли действие новый транспорт или прошло через существующий. Это разные факты для аудита.

## Сокет, ограниченный задачей, получает ответственного владельца

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

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

В этом примере `mktemp` помогает не угадывать уникальное имя каталога:

```sh
set -eu
base="$HOME/.ssh/task-control"
mkdir -p "$base"
chmod 700 "$base"
cm_dir=$(mktemp -d "$base/run.XXXXXX")
chmod 700 "$cm_dir"
socket="$cm_dir/%C"

finish() {
    status=$?
    ssh -S "$socket" -O exit build-box >>"$cm_dir/cleanup.log" 2>&1 || \
        printf '%s\n' 'master exit request failed' >>"$cm_dir/cleanup.log"
    ssh -S "$socket" -O check build-box >>"$cm_dir/cleanup.log" 2>&1 || true
    exit "$status"
}
trap finish EXIT HUP INT TERM

ssh -o ControlMaster=auto \
    -o ControlPersist=2m \
    -o ControlPath="$socket" \
    build-box 'deployctl apply release-4821'
```

Короткая настройка `ControlPersist` помогает, если процесс завершился до срабатывания trap. Ее не следует воспринимать как разрешение другой задаче повторно использовать master. Случайный каталог блокирует такое повторное использование, поскольку вторая задача не знает путь первой и не наследует его.

Перед применением этого примера внесите одну поправку. Не записывайте в `cleanup.log` чувствительные аргументы команд, значения окружения или скопированные приватные данные. В журналах аудита должно быть достаточно сведений, чтобы установить, кто и что сделал, но они не должны превращаться в новое хранилище секретов. Редактируйте аргументы на границе обертки, пока еще понимаете их смысл.

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

## Глобальные настройки удобства разрушают границы задач

Глобальный блок `Host *` часто включает мультиплексирование для каждой оболочки, скрипта, рабочей копии и дочернего процесса автоматизации, запущенных от одной учетной записи. Это слишком широко, когда одна учетная запись запускает задачи с разными согласованиями или владельцами.

Настройка может прийти из файлов `Include`, системы управления конфигурацией, личных dotfiles разработчика или образа сборки. Ее также можно переопределить в командной строке. Не судите о поведении по одному видимому файлу конфигурации. Используйте `ssh -G` с точным псевдонимом хоста и контекстом пользователя, которые будет использовать задача.

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

```sh
ssh -o ControlMaster=no \
    -o ControlPath=none \
    build-box 'maintenancectl status'
```

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

Не принимайте `ControlMaster=auto` за гарантию, что процесс будет повторно использовать только собственное соединение. `auto` означает, что клиент попытается найти master по настроенному пути и создаст новый, если не найдет. Именно настроенный путь определяет, чье соединение может быть найдено.

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

## Чистое завершение задачи требует доказательства закрытия транспорта

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

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

Установите процедуру, которую оператор сможет выполнить в стрессовой ситуации: изолировать сокет, проверить его до очистки, отправить `exit`, убедиться, что ни один master не отвечает, и сохранить результат. Это полезнее длинного тайм-аута по умолчанию, потому что превращает предположение о доступе в проверяемый факт.

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