# 验证 SSH 断开后的未知远程状态

SSH 断开并不意味着远程命令失败了。它意味着客户端失去了判断发生了什么所需的证据。运行 `uname` 时，这种区别很让人烦恼；运行数据库迁移、发布、凭据轮换，或调用外部 API 的命令时，代价就可能很高。

解决办法不是把超时时间调大，而是在远程端建立一套验证流程，包含持久化的运行身份、明确的状态转换，以及针对具体效果的完成证明。这样，重新连接后要回答的问题就从“要不要再运行一次？”变成了“这次运行记录写了什么？它改变了什么？”

## 连接断开后，只有三种诚实的答案

当 SSH 客户端报告连接重置、超时、管道断裂或意外 EOF 时，命令只有三种状态：从未开始、已经开始且仍在进行，或已经完成。客户端的退出代码无法可靠地区分这些状态。

从 shell 到远程程序之间存在几个边界：

- 本地 shell 启动 `ssh`。
- 客户端发送 SSH 通道请求和命令字节。
- 服务器接受请求，并启动远程 shell 或程序。
- 该程序执行实际工作。
- 程序退出，sshd 将输出和退出状态发回客户端。

这些边界中的任何一个之后发生网络中断，都可能在本地产生错误。如果中断发生在远程程序启动之前，什么都没有发生。如果中断发生在程序提交更改之后、退出状态返回之前，更改已经发生，但客户端仍然会报告失败。

所以，本地看到的 `Connection reset by peer` 只是传输证据，不是业务证据。它说明客户端无法完成 SSH 对话，却没有说明远程操作是否执行。

OpenSSH 的配置手册也指出了一个相关事实：关闭会话，并不保证关联的 shell 进程已经停止。因此，通道超时不是任务控制机制。

真正容易造成损失的习惯，是把模糊结果当成操作失败。这个习惯很容易理解，因为大多数命令行工具都让我们把非零退出状态理解为“再做一次”。SSH 传输失败会打破这个捷径。

## 命令送达和命令完成是两种不同的判断

一个远程命令至少有四个值得证明的判断：提交、开始、完成和效果。团队经常记录其中一个，就以为四个都有了。

提交表示客户端尝试发送命令。本地终端知道这一点，但这是最弱的判断。开始表示远程包装器在执行工作前创建了持久化证据。完成表示包装器记录了最终结果。效果表示预期的远程状态或外部状态已经符合请求。

进程列表能证明的事情比很多人想象的少。看到一个 PID，可能只说明现在存在一个名称相似的进程。它不能证明该进程属于你的请求，也不能证明重要部分尚未提交，更不能证明稍后的重试一定安全。进程退出后，PID 还可能被重新使用，过期记录的价值会进一步降低。

退出代码也有类似的限制。POSIX 将 `wait` 定义为 shell 获取已知子进程状态的方式。这种关系存在于远程 shell 内部。SSH 连接消失后，本地 shell 失去了获取该状态的路径。之后的新 SSH 会话无法通过 `wait` 重新建立这条关系，只能读取第一次运行持久化保存的记录。

在运行手册和自动化输出中，把下面几句话分开：

1. “客户端无法确认完成。”
2. “运行 `r-20260722-1842-a91f` 已在远程主机上开始。”
3. “该运行记录的退出状态为 0。”
4. “部署标记报告的发布版本为 `2026.07.22.3`。”

第四句话可能才真正回答了运维问题。文件复制命令需要校验和，或需要检查最终路径上的内容是否符合预期。迁移需要查询架构版本或迁移记录。向支付或工单 API 发出的请求，需要该 API 中的幂等记录，而不只是本地日志行。

## 在工作开始前，把运行身份放到远程主机上

持久化的运行 ID 能把模糊的重新连接工作变成一次查询。调用 SSH 前生成它，将它传给远程包装器，并让所有工件都放在由该 ID 派生的路径下。

不要只使用时间戳。两个代理可能在同一秒启动，时钟也可能漂移，而且时间戳不适合作为不透明标识符。可以将时间戳和随机数据组合起来，或使用环境中可用的 UUID 生成器。验证时必须再次提供这个 ID，并让它出现在每条有意义的日志记录中。

下面这段 shell 代码会创建运行目录，写入请求的操作，记录开始标记，并保留标准输出和标准错误。它要求在 `--` 后传入命令。应将包装器放在受控位置，例如 `/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` 调用的作用不只是整理文件。如果运行 ID 已经存在，目录创建就会失败，因此它相当于一次简单的“只创建一次”声明。这能防止两个使用同一 ID 的调用悄悄执行两次工作。但它无法解决使用不同 ID 的并发工作，那需要单独的锁或应用层约束。

顺序很重要。包装器会在执行负载前写入 `started_at`、`state` 和 `pid`，并在把状态切换为 `finished` 前记录 `exit_status`。如果验证器看到 `finished` 却找不到退出状态，应将记录视为损坏，而不是成功。如果看到运行目录，却没有 `started_at`，应将其视为设置不完整的失败。

不要通过 shell trap 写入 `finished`，然后就认为任务完成了。突然的主机故障、存储故障、强制终止或文件系统问题，都可能让 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 的原因。

## 已记录的退出状态，仍可能无法证明预期效果

包装器的最终记录证明的是包装器观察到的情况，不一定是外部世界接受的结果。对于发送请求的命令，这一点尤其明显。

假设一个远程脚本通过 API 创建 DNS 记录，然后写入 `exit_status=0`。脚本可能在解析器看到新记录之前，就已经从 API 收到了成功响应。部署脚本可能在提交发布后成功退出，但发布随后又未通过健康检查。数据库工具可能报告连接成功，但多步骤过程中某条语句已经提交，后面的语句却失败了。

每项操作都需要与其效果相匹配的后置条件。这个后置条件应当可以安全地重复读取，而且要足够具体，能排除旧结果或无关结果。

对于发布，可以把运行 ID 写入发布清单，然后从服务查询当前版本。对于数据库变更，可以查询迁移表中的迁移标识和校验和。对于生成的工件，可以在文件写入最终路径后，比较预先计算的 SHA-256 摘要。对于 API 请求，如果服务提供幂等令牌，就使用该令牌，然后通过令牌或已保存的请求 ID 查询生成的资源。

最糟糕的设计，是脚本发送请求后输出“完成”，再把这个词当成证据。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` 和服务管理器，各自解决不同的一部分问题。它们都不能把不确定的远程请求变成已验证的结果。

在常见的 shell 配置中，`nohup` 有助于让进程在收到挂断信号后继续存活。普通的 `nohup task &` 仍然只会给你输出文件和 PID，除非你另外加入结构化的完成记录。它还引入了新的不确定性：远程 shell 是已经启动了 `nohup`，还是连接在此之前就断了？

`tmux` 和 `screen` 可以保留交互式环境。操作人员需要重新连接并手动检查长时间运行的命令时，它们很合适。但作为自动化契约，它们并不理想，因为会话名称可能冲突，滚动输出不是结果模式，分离的终端也无法告诉另一个系统预期效果是否发生。

当工作确实属于服务或队列任务时，服务管理器更强。例如，远程命令可以提交一个命名单元，之后再查询该单元的生命周期和日志。如果主机已经有负责管理任务的运维体系，应使用这种模型。不要为了避免写一个小型运行记录，就给一个五秒钟的管理命令接入服务管理器。

划分方式很简单。交互式修复工作放在终端复用器中，定时或长期运行的负载交给服务管理器，需要在重新连接后获得可靠答案的命令则使用记录型包装器。

## 保活可以缩短等待，却无法关闭模糊窗口

OpenSSH 客户端保活能更快发现失效的连接，但不能保证命令没有在网络路径失效之前被接受。

对于不希望客户端长时间卡住的主机，下面这样的客户端配置是合理的：

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

当没有数据到达时，`ServerAliveInterval` 会通过加密 SSH 通道发送应用层消息。如果客户端收不到足够的响应，就会退出，而不是无限等待。OpenSSH 将它与 TCP 保活分开说明，后者运行在传输层。

用这个设置限制调用方在开始验证前最多等待多久。不要把它描述成命令送达保证。远程主机接受命令之后、客户端收到结果之前，连接仍然可能断开。

多路复用也要同样谨慎。`ControlMaster` 和 `ControlPersist` 可以让多个 SSH 命令复用已有网络连接，减少建立连接的成本，但损坏的主连接也可能同时影响多个调用。OpenSSH 手册说明，持久化的主连接会在原始客户端退出后留在后台。这在运维上很有用，却不会为通过它发送的命令增加完成证据。

对于自动化，应设置明确的连接超时，设置符合环境的存活边界，并让验证路径独立于原始 SSH 会话。快速失败只有在下一步是状态查询，而不是盲目重试时才有价值。

## 在凌晨两点之前，先把故障测试做好

从未被中断过的协议只是设计草图。应使用安全但足够缓慢的操作，在不同阶段切断连接来测试它。

先准备一个负载，让它写入带编号的进度文件，在各阶段之间休眠，并写入最终效果标记。通过包装器启动它。远程出现 `started_at` 标记后，终止本地 SSH 客户端，然后重新连接并运行验证器。接着在远程负载写入 `finished_at` 之前终止它。最后，如果环境允许，再模拟一次主机重启。

预期分类应明确：

- 包装器声明运行 ID 之前，验证结果为 `absent`。
- 负载执行期间，验证结果为 `running`。
- 正常完成后，结果为带有记录状态的 `finished`。
- 强制中断或主机丢失后，结果为 `damaged`，随后执行后置条件检查。

也要测试重复提交。让两个调用几乎同时使用同一个运行 ID。只有一个调用可以成功取得目录声明，另一个必须返回明确的重复结果，而且不能运行负载。然后再尝试使用两个不同 ID 操作同一资源。如果这样会产生竞争，包装器需要资源级锁，或者目标系统需要唯一性规则。

运行记录应保留足够长的时间，覆盖你的运维重试窗口。如果任务可能在一天后重试，而主机一小时后就删除记录，那么你其实是在不确定性中加入了一个计时器。还要保护记录，避免被随意修改。验证运行的账户不应能编辑 `exit_status` 或替换负载日志。在共享系统上，只要运维模式允许，就应分开提交者、运行者和读取者的权限。

当 AI 代理调用 SSH 时，凭据隔离和命令验证需要一起工作。Sallyport 将 SSH 凭据保存在保险库中，并能记录代理请求的操作；远程包装器则为任务本身提供持久化答案。操作记录可以告诉你哪个进程请求了命令，远程运行 ID 则告诉你连接变得不确定后发生了什么。

## 安全的重试决定有四种结果

连接断开后，先分类，再行动。这里有四种有用的结果，其中只有一种适合自动重试。

如果运行状态为 `absent`，说明远程包装器从未创建记录。如果你信任包装器的只创建一次行为，可以使用同一个运行 ID 提交相同操作。如果命令可能绕过包装器在其他地方执行过，仍应先检查目标，因为包装器无法证明绕过它的操作发生了什么。

如果运行状态为 `running`，应等待，或通过该操作专用的控制路径取消。不要启动另一个副本。超时策略应放在负载或任务管理器中，而不是放在会与第一次调用竞争的第二个 SSH 调用中。

如果运行状态为 `finished`，先评估退出状态；对于具有重要外部效果的操作，还要检查后置条件。退出状态为零但后置条件失败，仍然表示操作失败。应以后置条件检查为准。

如果运行状态为 `damaged`，就不要再把它称为重试问题。这是协调和修复工作。检查日志、日志条目、目标状态以及任何幂等记录，然后决定是修复部分效果、标记操作完成，还是提交一次明确处理当前状态的新运行。由于缺失记录移除了自动化依赖的证明，这个决定可能需要人工参与。

真正值得做的改变很小：任何重复执行可能造成损害的命令，都应拥有运行 ID、远程开始记录、远程最终记录，以及目标状态检查。SSH 仍然会断开，但你的自动化不必再假装自己知道发生了什么。
