# Отключение SSH и неизвестное удалённое состояние: как безопасно проверить результат

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

Решение не в увеличении тайм-аута. Нужна последовательность удалённой проверки с постоянным идентификатором запуска, явными переходами состояний и проверкой завершения, подходящей конкретному эффекту. Тогда после переподключения вопрос меняется с «Запускать ли это снова?» на «Что записано в журнале этого запуска и что он изменил?»

## После разрыва соединения остаются три честных варианта

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

Между вашей оболочкой и удалённой программой есть несколько границ:

- Локальная оболочка запускает `ssh`.
- Клиент отправляет запрос канала SSH и байты команды.
- Сервер принимает запрос и запускает удалённую оболочку или программу.
- Эта программа выполняет фактическую работу.
- Программа завершается, после чего sshd отправляет обратно вывод и статус завершения.

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

Поэтому локальное сообщение вроде `Connection reset by peer` говорит о состоянии транспорта, а не о бизнес-результате. Оно означает, что клиент не смог завершить диалог SSH. Оно не показывает, выполнялась ли удалённая операция.

В руководстве по конфигурации OpenSSH есть близкое по смыслу замечание о неактивных каналах: закрытие сеанса не гарантирует остановку связанных с ним процессов оболочки. Поэтому тайм-аут канала не заменяет механизм управления задачами.

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

## Доставка команды и её завершение, это разные утверждения

Для удалённой команды стоит подтвердить как минимум четыре факта: отправку, запуск, завершение и эффект. Команды часто записывают только один из них и считают, что получили все четыре.

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

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

У кодов выхода есть похожее ограничение. POSIX определяет `wait` как способ получить статус дочернего процесса, о котором знает оболочка. Эта связь существует внутри удалённой оболочки. Когда SSH-соединение исчезает, локальная оболочка теряет путь к этому статусу. Поздний SSH-сеанс не может восстановить его через `wait`, он должен прочитать запись, сохранённую первым запуском.

Разделяйте эти утверждения в инструкциях и выводе автоматизации:

1. «Клиент не смог подтвердить завершение».
2. «Запуск `r-20260722-1842-a91f` начался на удалённом хосте».
3. «Этот запуск записал код выхода 0».
4. «Маркер развёртывания сообщает о релизе `2026.07.22.3`».

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

## Сначала создайте идентификатор запуска на удалённом хосте

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

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

Этот фрагмент оболочки создаёт каталог запуска, записывает запрошенную операцию, ставит маркер начала и сохраняет оба потока вывода. Он ожидает команду после `--`. Держите обёртку в контролируемом месте, например `/usr/local/sbin/run-recorded`, а не вставляйте её каждый раз в строку команды.

```sh
#!/bin/sh
set -eu

run_id=$1
shift
[ "$1" = "--" ]
shift

base=/var/lib/recorded-runs
run_dir="$base/$run_id"

case "$run_id" in
  *[!A-Za-z0-9._-]*|'')
    printf '%s\n' "invalid run id" >&2
    exit 64
    ;;
esac

if ! mkdir "$run_dir" 2>/dev/null; then
  printf '%s\n' "run already exists: $run_id" >&2
  exit 75
fi

umask 077
printf '%s\n' "$*" > "$run_dir/request"
date -u +%Y-%m-%dT%H:%M:%SZ > "$run_dir/started_at"
printf '%s\n' "started" > "$run_dir/state"
printf '%s\n' "$$" > "$run_dir/pid"

set +e
"$@" >"$run_dir/stdout" 2>"$run_dir/stderr"
status=$?
set -e

printf '%s\n' "$status" > "$run_dir/exit_status"
date -u +%Y-%m-%dT%H:%M:%SZ > "$run_dir/finished_at"
printf '%s\n' "finished" > "$run_dir/state"
exit "$status"
```

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

Порядок действий важен. Обёртка записывает `started_at`, `state` и `pid` до запуска полезной нагрузки. Она записывает `exit_status` до перевода состояния в `finished`. Если проверяющий видит `finished` без кода выхода, запись нужно считать повреждённой, а не успешной. Каталог запуска без `started_at` означает неполную ошибку настройки.

Не записывайте `finished` через trap и не считайте задачу решённой. Внезапный сбой хоста, проблема с хранилищем, принудительное завершение или ошибка файловой системы могут не дать trap сработать. Финальный маркер служит свидетельством, когда он есть, но его отсутствие не доказывает, что полезная нагрузка не завершилась.

## Проверяйте запуск в порядке, который не вводит вас в заблуждение

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

Хорошая проверка должна классифицировать запись как `absent`, `running`, `finished` или `damaged`. Этот пример использует описанный формат каталогов и выводит факты, которые может оценить человек или агент.

```sh
#!/bin/sh
set -eu

run_id=$1
run_dir="/var/lib/recorded-runs/$run_id"

if [ ! -d "$run_dir" ]; then
  printf '%s\n' 'state=absent'
  exit 0
fi

if [ ! -f "$run_dir/started_at" ]; then
  printf '%s\n' 'state=damaged reason=missing-start-marker'
  exit 2
fi

if [ -f "$run_dir/finished_at" ] && [ -f "$run_dir/exit_status" ]; then
  printf '%s\n' 'state=finished'
  printf 'exit_status=%s\n' "$(cat "$run_dir/exit_status")"
  printf 'started_at=%s\n' "$(cat "$run_dir/started_at")"
  printf 'finished_at=%s\n' "$(cat "$run_dir/finished_at")"
  exit 0
fi

if [ -f "$run_dir/pid" ]; then
  pid=$(cat "$run_dir/pid")
  if kill -0 "$pid" 2>/dev/null; then
    printf 'state=running pid=%s\n' "$pid"
    exit 0
  fi
fi

printf '%s\n' 'state=damaged reason=no-finish-record-and-pid-not-live'
exit 2
```

Запустите её новой SSH-командой:

```sh
ssh ops@host /usr/local/sbin/check-recorded-run r-20260722-1842-a91f
```

Вывод должен соответствовать одному из вариантов:

```text
state=absent
```

```text
state=running pid=48192
```

```text
state=finished
exit_status=0
started_at=2026-07-22T18:42:19Z
finished_at=2026-07-22T18:47:03Z
```

Состояние `damaged` должно быть частью протокола. Если его не предусмотреть, проверка превратит отсутствие данных в бодрую догадку. Если хост перезагрузился во время выполнения, `kill -0` завершится ошибкой, а финального маркера не будет. Правильный ответ, проверить целевой эффект и журналы приложения, а затем решить, нужна ли сверка состояния.

Не используйте в проверяющей программе `ps | grep`. Она найдёт посторонние процессы, имена команд меняются, а форматы вывода различаются. `kill -0` лишь показывает, жив ли процесс с записанным PID. Это не доказательство завершения, поэтому сначала нужно проверять финальные артефакты, а уже затем PID.

## Даже финальный код выхода не всегда подтверждает нужный эффект

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

Представьте удалённый скрипт, который создаёт DNS-запись через API, а затем записывает `exit_status=0`. API мог сообщить об успехе до того, как резолвер увидел новую запись. Скрипт развёртывания мог успешно завершиться после отправки запроса на раскатку, которая позже не прошла проверки работоспособности. Инструмент базы данных мог успешно подключиться, пока один оператор многоэтапной процедуры зафиксировал изменения, а следующий завершился ошибкой.

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

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

Хуже всего скрипт, который печатает «готово» сразу после отправки запроса и принимает это слово за доказательство. Файл stdout показывает, что напечатал один процесс. Постпроверка показывает, что теперь содержит система.

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

## Идемпотентность лучше показного восстановления

Протокол проверки уменьшает неопределённость. Идемпотентный дизайн команд уменьшает цену этой неопределённости. Нужны оба подхода.

Идемпотентная операция приводит к одному и тому же желаемому состоянию при повторном применении того же запроса. `mkdir -p /srv/app/cache` близок к этой модели. `useradd deploy` идемпотентным не является, если скрипт сначала не проверяет свойства существующей учётной записи. `curl -X POST /orders` тоже не идемпотентен, если сервис не понимает токен идемпотентности и не считает повтор с тем же токеном тем же запросом.

Не путайте идемпотентность с предположением, что «при втором запуске команда, скорее всего, ничего не сделает». Развёртывание может дважды одинаково перезаписать файл, но дважды вызвать перезапуск. Инструмент миграции может знать свою историю, но всё равно выполнить опасную инициализацию до проверки. Изучите поведение команды и протестируйте прерывание.

Стройте запросы вокруг стабильного идентификатора операции. Передавайте один и тот же ID удалённой обёртке и, когда возможно, целевой системе. Обёртка релиза может создавать `/var/lib/recorded-runs/$run_id/effect` только после того, как конечная точка активного релиза сообщит о запрошенной версии. Вызов API подготовки ресурсов может использовать `run_id` как значение идемпотентности. Тогда после сбоя SSH можно запросить состояние одной и той же работы в обеих системах.

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

## Фоновый запуск переносит проблему в другое место

`nohup`, `&`, `disown`, `tmux`, `screen` и менеджеры сервисов решают разные задачи. Ни один из них не превращает неопределённый удалённый запрос в проверенный результат.

`nohup` помогает процессу пережить сигнал разрыва в распространённых конфигурациях оболочки. Обычная команда `nohup task &` всё равно оставляет вам файлы вывода, PID и отсутствие структурированной записи завершения, если вы не добавите её сами. Появляется и новая неопределённость: успела ли удалённая оболочка запустить `nohup` или соединение оборвалось раньше?

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

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

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

## Keepalive сокращает ожидание, но не закрывает окно неопределённости

Keepalive клиента OpenSSH быстрее обнаруживает мёртвое соединение. Он не гарантирует, что команда не была принята до сбоя сетевого пути.

Для хостов, где зависший клиент напрасно тратит время, подойдёт такая настройка:

```text
Host production-*
    ServerAliveInterval 20
    ServerAliveCountMax 3
    TCPKeepAlive yes
```

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

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

К мультиплексированию относится то же предупреждение. `ControlMaster` и `ControlPersist` могут использовать существующее сетевое соединение для нескольких SSH-команд. Это уменьшает расходы на установку соединения, но сбой master-соединения может одновременно затронуть несколько вызовов. В руководстве OpenSSH сказано, что сохранённый master остаётся в фоне после завершения исходного клиента. Это удобно, но не добавляет доказательств завершения отправленной через него команды.

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

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

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

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

Ожидаемые классификации должны быть явными:

- До заявки обёртки на идентификатор запуск возвращает `absent`.
- Во время выполнения полезной нагрузки проверка возвращает `running`.
- После обычного завершения она возвращает `finished` с записанным статусом.
- После принудительного прерывания или потери хоста она возвращает `damaged`, после чего выполняется проверка эффекта.

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

Храните записи запусков достаточно долго, чтобы покрыть рабочее окно повторных попыток. Если задачу можно повторить через день, а хост удаляет записи через час, вы встроили таймер в собственную неопределённость. Защитите записи и от случайного изменения. Учётная запись, проверяющая запуск, не должна иметь возможности редактировать `exit_status` или подменять журнал полезной нагрузки. В общих системах, если это допускает рабочая модель, разделите роли отправителя, исполнителя и читателя.

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

## Безопасное решение о повторе имеет четыре результата

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

Если запуск `absent`, удалённая обёртка не создала запись. Можно отправить ту же операцию с тем же ID, если вы доверяете поведению «создать один раз». Если команда могла выполниться вне обёртки, сначала проверьте цель: обёртка не докажет, что происходило в обход неё.

Если запуск `running`, подождите или отмените его через специальный для операции путь управления. Не запускайте вторую копию. Политика тайм-аута должна находиться в полезной нагрузке или менеджере задач, а не во втором SSH-вызове, который соревнуется с первым.

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

Если запуск `damaged`, прекратите считать это проблемой повторной попытки. Это работа по сверке состояния. Проверьте журналы, записи журнала событий, состояние цели и запись идемпотентности. Решите, нужно ли исправить частичный эффект, отметить операцию как завершённую или создать новый запуск, явно учитывающий наблюдаемое состояние. Может потребоваться человек, потому что повреждённая запись убрала доказательство, на котором основана автоматизация.

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