# SSH 就绪检查清单如何改变代理安全性

对代理来说，只读 HTTP 是一个宽容的首个通道。请求有方法、范围有限的目标、状态码、请求头，通常还有结构明确并有文档说明的正文。失败的 GET 一般不会改变服务。凭据仍可能处理不当，敏感数据也可能泄露，但这种执行模型能让审查者在较小范围内判断风险。

SSH 改变了风险的单位。代理不是调用一项定义明确的操作，而是向远程 shell 发送文本。其行为取决于登录账户、shell、工作目录、环境、操作系统、已安装工具、引用规则和机器的实时状态。一条命令可能部分成功，在报告结果前断开连接，下一次重试就可能把工作做两遍。

这不意味着 SSH 不适合代理。它意味着“HTTP 通道已经可用”不足以作为增加 SSH 的依据。准入测试必须覆盖五项独立属性：可信的结果解析、已验证的主机身份、可安全重复的命令、能说明后果的批准，以及对未知远程状态的明确处理。只要缺少其中一项，就不要开启第二个通道。

## 将 SSH 视为不同的执行模型

SSH 就绪的前提是承认：远程 shell 不是换了 URL 的 HTTP 传输。HTTP API 公开的是由服务器选定的操作。SSH 公开的是远程账户选定的解释器，再让客户端把无结构的命令字符串带入其中。

对于只读 HTTP 调用，审查者通常可从 `GET`、主机和路径推断其影响。状态 404 这样的响应在协议中也有惯常位置，即使应用赋予它特定领域含义。对于 SSH，`test -f /srv/app/release && cat /srv/app/release` 可能因文件不存在而返回 1，`cat /srv/app/release` 也可能因权限被拒绝而返回 1。如果解析器把两者都归为“命令失败”，代理就无法安全决定下一步。

远程环境也会改变含义。`sed -i` 在常见操作系统中的行为不同。登录 shell 可能加载会向 stdout 输出横幅的启动文件。`PATH` 可能找到包装程序，而不是你预计的二进制文件。区域设置可能改变人类可读的诊断信息。伪终端会把面向人的交互行为混入供解析器处理的命令中。典型 HTTP 请求架构不会呈现这些变量。

启用命令前，先写好通道契约。它应说明远程用户、可接受的主机、shell、初始目录、环境策略、是否禁止伪终端、最长运行时间、输出限制和确切的结果封装。对敏感操作固定可执行文件路径。必须处理文本时，设置已知的区域设置。优先使用远程程序产生的机器可读输出，但不要以为程序有 JSON 选项，就能让外围 shell 也变得结构化。

将非交互探测作为第一道关卡：

```sh
/usr/bin/ssh \
  -o BatchMode=yes \
  -o StrictHostKeyChecking=yes \
  -o ConnectTimeout=10 \
  -T deploy@host.example \
  'umask 077; printf "%s\n" "{\"probe\":\"ssh-ready\",\"version\":1}"'
```

OpenSSH 文档说明，`BatchMode=yes` 会禁用提示，包括密码提示和主机密钥确认提示。`-T` 会禁用伪终端分配。`StrictHostKeyChecking=yes` 会拒绝未知或变更过的密钥。预期 stdout 仅有一个 JSON 对象，stderr 为空，远程退出状态为零。应单独测试每一种偏差。无法区分未知主机和格式错误 JSON 的系统，还没有通过这道关卡。

## 让结果封装没有歧义

只有在传输错误、远程终止、stdout 和 stderr 始终彼此分离时，结果解析才算准备就绪。把它们合并到一个文本字段中，会制造危险的虚假确定感。

RFC 4254 将 stdout 定义为通道数据，将 stderr 定义为扩展通道数据。它还定义了 `exit-status` 和 `exit-signal` 消息，但措辞很重要：发送退出状态是建议行为，不是强制要求，客户端也可能忽略它。OpenSSH 在这个协议之上提供了有用的本地约定。其 `ssh` 命令会返回远程命令的状态，发生错误时则返回 255。

不要把这一约定当作普遍真理。远程程序本身也可能退出 255，这会在进程边界上与 OpenSSH 的客户端错误值冲突。服务器或库可能不发送 exit-status 消息。信号终止不同于普通的非零退出。通道可能已产生输出，但在客户端收到最终状态前关闭。

内部结果应像有类型的记录，而不是一份文字记录：

```json
{
  "phase": "completed",
  "transport": "ok",
  "host": "host.example",
  "host_key_fingerprint": "SHA256:verified-value",
  "exit_status": 0,
  "exit_signal": null,
  "stdout": "{\"state\":\"present\",\"release\":\"2026.07\"}\n",
  "stderr": "",
  "truncated": false,
  "started_at": "request timestamp",
  "finished_at": "result timestamp"
}
```

让 `exit_status` 可以为空。让 `phase` 至少区分 rejected、not-started、started、completed 和 unknown。将截断作为数据记录，绝不可悄悄截去输出后，把剩余内容当作完整结果交给代理。附上该连接实际使用的已验证主机指纹，而不只是代理请求的主机名。

接着测试结果矩阵。运行一条成功但没有输出的命令，一条向两个流写入内容后以 7 退出的命令，一条被信号终止的命令，一条超过截止时间的命令，一条在技术栈允许字节时输出无效 UTF-8 的命令，以及一条超过每个输出限制的命令。远程命令休眠时断开客户端连接，再检查结果。适配器不应凭空生成 `exit_status: 0`，不应从 stdout 推断成功，也不应在无法证明命令是否运行过时把超时称作“失败”。

shell 组合方式也值得单独测试。POSIX 规定，除非启用 `pipefail`，管道通常报告最后一条命令的状态。这意味着 `generate | upload` 可能报告成功，因为 `generate` 失败后，`upload` 接收了空输入。不要依赖交互 shell 的默认设置。将多命令操作放进经过审查、带明确错误处理和版本号的脚本中，然后只调用一个脚本入口。

## 在代理能够触达主机前验证主机

主机验证是清单管理问题，不是让代理回答的提示。有效的用户凭据证明客户端在服务器看来是谁。服务器的主机密钥证明回应客户端的是哪台服务器。两者都需要。

绝不可把 `StrictHostKeyChecking=no` 当作自动化的解决办法。当前 OpenSSH 文档指出，该设置可以自动添加新密钥，并且在一定限制下，主机密钥变更时仍可能允许连接继续。`accept-new` 更好，因为它会拒绝变更过的密钥，但它仍信任首次连接。对于代理通道，应在运行前配置可信关系，并使用 `StrictHostKeyChecking=yes`。

`ssh-keyscan` 有助于收集公开主机密钥，但不能验证它们。其手册明确警告，网络攻击者可以替换密钥，并建议通过带外方式验证输出，或只在受信网络中使用。将实时输出直接复制进 `known_hosts`，会把验证步骤变成记录最先回应者的内容。

通过独立的控制平面获取指纹：云实例控制台、镜像构建记录、由主机所有者审查的配置仓库，或管理员直接交接。存储主机名、端口、允许的主机密钥算法、指纹、所有者、环境和轮换流程。别忘了审查别名和跳板主机。最终目标可以被完美固定，但未固定的跳板主机会破坏整条信任路径。

一个实用的准入检查会比较观察到的密钥与已批准的密钥，而不改变信任关系：

```sh
ssh-keyscan -T 5 -t ed25519 host.example > observed.keys
ssh-keygen -lf observed.keys
```

指纹输出包括位长度、指纹、主机标签和密钥类型。人工或可信的清单服务应将指纹与独立提供的值进行比对。只有匹配后，自动化才可安装 known-hosts 条目。扫描本身是待比较的证据，不是可直接信任的证据。

在强制固定前规划好轮换。OpenSSH 的 `UpdateHostKeys` 可在服务器已通过现有可信密钥完成验证后学习额外密钥，支持渐进式轮换。无论使用这一扩展还是分发新的 known-hosts 集合，都要定义重叠期和紧急路径。密钥变更应停止执行并生成明确的身份错误。它绝不应触发普通重试、自动删除旧条目，或让批准卡要求匆忙的审查者接受来历不明的指纹。

## 在影响发生的边界要求幂等性

只有在任意部分执行后重复运行仍能产生相同预期状态、且不重复造成影响时，命令才能安全重试。只读语法不能赋予这种属性，零退出状态也不能证明它。

有些命令天生可重复：读取固定文件、检查服务状态，或在受控权限下使用 `mkdir -p` 创建目录。另一些命令需要保护条件。用 `echo ... >> file` 追加一行、发送通知、以生成的标识符创建用户、通过本地工具对账户收费，以及重启服务，都不会仅仅因为 shell 命令短小就变得安全。

在这一层，“重试暂时性 SSH 错误”是错误建议。它之所以流行，是因为重新连接能修复许多网络故障，也因为 HTTP 客户端库会标准化重试。SSH 可能在远程进程已提交变更、客户端尚未收到退出状态后失去连接。自动重试会重复已完成的操作。

把可重复性放进远程操作中。每个变更请求都应在批准前生成稳定的操作 ID。尽可能在同一事务中将该 ID 与影响一并存储。操作再次运行时，返回已记录的结果，而不是再次应用变更。如果标记与影响无法由一个事务覆盖，则增加能判断哪一侧已完成的核对查询。

一个小型部署脚本可以让这一契约清晰可见：

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

op_id=$1
release=$2
state_dir=/var/lib/agent-ops
record="$state_dir/$op_id"

test -d "$state_dir" || exit 70
if test -f "$record"; then
  cat "$record"
  exit 0
fi

current=$(/usr/bin/readlink /srv/app/current || true)
if test "$current" = "/srv/app/releases/$release"; then
  /usr/bin/printf '{"operation":"%s","state":"already-current"}\n' "$op_id"
  exit 0
fi

test -d "/srv/app/releases/$release" || exit 66
/usr/bin/ln -sfn "/srv/app/releases/$release" /srv/app/current.new
/usr/bin/mv -f /srv/app/current.new /srv/app/current
/usr/bin/printf '{"operation":"%s","state":"changed","release":"%s"}\n' \
  "$op_id" "$release" > "$record.tmp"
/usr/bin/mv -f "$record.tmp" "$record"
cat "$record"
```

这个示例并非在所有情况下都具备原子性。符号链接交换和操作记录是两次文件系统变更，因此在两者之间崩溃会留下空档。明确的 `current` 检查会核对这一特定空档。你的操作需要与自身影响相关的保护条件，而不是从这个脚本照搬通用标记。

将每条允许的命令归类为只读、收敛、去重或不可重复。收敛意味着重复执行会朝一个声明的状态推进，例如设置某个配置值。去重意味着远程端能识别操作 ID。不可重复操作在出现不确定性后，需要单独的状态查询和人工决定。如果所有者无法为命令分类，就不要准入。

## 向批准人展示影响，而不是 shell 文本

只有审查者能在操作前识别目标、权限、预期影响和最坏可能后果，批准才有价值。原始 shell 文本是必要证据，却不是好的摘要。

比较 `systemctl restart api` 与一张写有以下内容的批准卡：生产主机 `api-03`，远程用户 `deploy`，重启服务 `api`，现有连接可能中断，操作 ID `rel-2026-07-24-04`，命令版本 `restart-service/v2`。第二种描述让审查者拥有可与变更核对的事实，也揭示了缺失的上下文。如果代理说不清会影响哪台主机或哪个服务，它就不应获得批准。

批准载荷应绑定到确切的执行请求。包括规范主机名和端口、已验证指纹、远程账户、命令或审查过的脚本摘要、规范化参数、工作目录、环境附加项、超时、请求的权限提升、操作 ID，以及该操作是否可重复。对这份载荷做哈希，并只执行已批准的哈希。否则代理可以为一条命令获得批准，再在发出前修改参数。

准确呈现 shell 引用方式，但不要让审查者在脑中执行它。只解析你拥有的命令形式。如果范围内仍有任意 shell 文本，应标记为任意内容，并完整呈现整个字符串，不作省略。标记重定向、命令替换、管道、后台执行、`sudo`、文件删除、权限变更、包管理、服务控制和网络下载。标记不是结论，而是在提示审查者后果可能藏在哪里。

影响越大，批准范围就必须越窄。针对已批准清单的固定只读探测，按会话批准可以合理。状态变更、使用特权远程账户，或参数决定目标的命令，适合逐次调用批准。不要因为两者共享 SSH 密钥，就让一次无害的 `uname` 批准悄悄授权后续部署。

Sallyport 的固定控制正好对应这一划分：其会话授权识别新的代理进程，而每次调用密钥可要求每次使用都获得批准。重要的设计工作仍在请求本身：批准卡必须说明远程影响，因为持有获批通道并不能解释一条命令会做什么。

测试批准完整性，而不只看界面。修改已批准参数中的一个字节，确认执行会停止。让两个请求竞争复用同一操作 ID。在批准和发送之间撤销会话。批准卡出现后锁定凭据存储。每项测试都应以已记录的拒绝或必须重新批准的请求结束，绝不可尽力继续执行。

## 将远程未知状态作为一等结果建模

只要客户端无法证明远程影响是否完成，未知就是有效结果。称它为失败会诱发重试，称它为成功则会掩盖未完成的工作。

来看一个常见过程。代理连接后启动脚本，脚本替换一个配置文件，服务开始重新加载。就在这时，网络路径中断。客户端既未收到退出状态，也未收到最终 stdout。本地超时触发，将调用标为失败。代理重试。第二次运行看到新文件，再次发送重新加载请求，还可能覆盖第一次运行的诊断记录。原始调用确实完成了一些工作，尽管客户端从未观察到完成。

RFC 4254 让这种歧义并不令人意外。协议将命令输出、退出状态、退出信号、EOF 和通道关闭作为独立消息传递。它建议返回退出状态，但不保证一定返回。即使通道干净关闭，也只告诉客户端通道的情况，无法说明外部系统是否达到所请求的业务状态。

上线前先定义状态机：

1. `not_started`：连接、身份、认证或批准在发送前失败。
2. `started`：远程端已接受命令，但尚无最终结果。
3. `completed`：已收到最终状态和全部受限输出。
4. `unknown`：可能已发送执行请求，但客户端失去了完成证明。
5. `reconciled`：后续独立查询已确认最终状态。

通常只有 `not_started` 可以自动重试，即使这个标签也必须来自可信边界。如果承载命令的字节可能已到达服务器，就使用 `unknown`。截止时间不会取消远程进程，除非你有已确认的取消协议。关闭客户端套接字不是这种协议。

每条变更命令都需要在批准前指定核对方案。方案可以查询操作记录、比较已部署的发布标识符、读取服务管理器状态，或向下游系统查询稳定操作 ID。尽可能使用只读凭据进行核对。保留原始请求、其部分输出、时间戳、主机指纹和操作 ID，让后续查询能回答正确的问题。

设定未知状态预算。决定系统等待多久、谁会收到告警、哪些操作会被未解决的操作阻塞，以及何时转由人工处理。绝不要让两项不确定操作针对同一资源竞争。按资源串行化，或使用带所有者和过期策略、且能在客户端断连后存续的远程锁。

## 在扩展命令前限制远程账户

SSH 是否就绪，更多取决于远程权限，而非客户端意图。再完美的批准界面，也无法弥补一个能改写主机的登录账户。

为代理通道创建专用账户。仅授予已准入操作所需的最小文件系统访问和服务权限。避免使用通用管理员账户。如需提权，仅允许固定路径且参数受控的指定命令。将不受限制的 `sudo`、允许程序中的 shell 转义、可写脚本目录和可写可执行文件，都视为通往更广权限的等价路径。

OpenSSH 的 `authorized_keys` 限制可以降低某个凭据的暴露面。根据设计，强制命令可以将每个连接路由到调度程序，而选项可以禁用伪终端、代理转发、X11 转发和端口转发。服务器配置也可限制转发。应使用服务器实际的手册并测试生效配置，因为一个宽松的 include 或 match 块就可能推翻你的假设。

调度程序应接受少量操作名称和数据，验证两者，并以绝对路径调用可执行文件，不重新拼接任意 shell 文本。例如，`read-release` 不接受参数，`activate-release` 则接受符合严格格式的发布标识符。SSH 通道仍是承载方式，但远程面开始更像定义明确的 API。

不要把开发者的认证代理转发到自主会话中。代理转发允许远程端在连接存续期间通过转发的套接字请求签名。受攻陷的远程主机未必能提取私钥，但可以使用签名能力。应给通道专用凭据，其服务器端授权本就受到严格限制。

检查到每个可执行文件和配置文件为止的整条文件系统所有权。如果受限账户可以修改父目录、替换调度程序、影响被加载的启动文件，或通过 `PATH` 抢先放置二进制文件，允许列表只是摆设。也要检查解释器。运行功能广泛的解释器的权限，通常就意味着该账户能做任何它有权限做的事。

让第一批命令保持简单：固定清单读取、输出受限的健康查询，以及一项有经过测试的核对路径的收敛式变更。端口转发、交互 shell、任意上传、包管理和自由形式的 root 命令，应留给后续审查，前提是它们确有必要。

## 证明在截断和断连情况下仍可观测

当审计证据无需依赖代理自己的摘要，就能重建授权、发送、远程身份和观察到的结果时，审计才算准备就绪。日志必须保留不确定性，而不是把它删改掉。

记录稳定的请求 ID 和操作 ID、代理进程或会话身份、批准决定、批准人方式、已批准载荷哈希、规范目标、主机密钥指纹、远程账户、开始时间、发送时间、最终时间、退出状态或信号、每个流的字节数、截断标志和最终状态分类。stdout 与 stderr 分开保存。如果策略不允许保留完整输出，保存允许的部分、摘要和清晰的保留元数据。

输出限制需要两种行为：停止本地收集，并决定远程端会发生什么。达到 1 MB 后直接关闭通道，可能仍会让进程继续运行。远程包装器可限制输出、将其发送到受控文件并报告摘要，但该包装器同样需要磁盘配额和清理机制。测试永不关闭 stdout 的命令、父进程退出后仍存活的子进程，以及持续向 stderr 写入的进程。

日志还应说明哪些事没有发生。主机密钥不匹配、保险库锁定、批准被拒、会话过期、请求格式错误或命令不被允许，都必须在返回前产生拒绝记录。否则运维人员只能看到一段空白，无法分辨系统悄无声息还是发生了绕过。

Sallyport 会将代理任务和单次调用记录到同一份加密、哈希链式审计日志中，`sp audit verify` 无需密钥即可在密文上离线检查该链。这为通道提供了防篡改的本地证据，但远程操作 ID 和核对结果仍需要出现在请求和结果中，运维人员才能将调用与机器状态对应起来。

收集事故审查者会看到的证据时，进行故障注入。分别在发送前、刚发送后、stdout 输出到一半时，以及远程进程退出后但本地完成前终止客户端。轮换主机密钥但不更新清单。在写入标记前填满远程文件系统。返回成功退出码但结构化输出格式错误。对每种情况都问一个问题：审查者能否看出使用了什么权限、哪些内容可能已改变，以及下一步必须做什么？

## 只有通过关卡后才准入 SSH

当团队能够证明其故障行为时，第二个通道才算准备就绪，而不是一条顺利执行的命令抵达测试主机时。使用带负责人和保留证据的书面关卡。

准入记录应包含：

1. 一份通道契约，明确 shell、账户、目录、环境、超时、输出上限和结果架构。
2. 一份已验证的主机清单，具有独立的指纹来源、跳板主机覆盖和经过测试的轮换流程。
3. 一份命令目录，分类重复行为，并为每项变更指定核对查询。
4. 一份批准规范，绑定到确切主机、账户、命令版本、参数、权限、超时和操作 ID。
5. 故障注入结果，证明未知状态、拒绝、截断、撤销和审计重建。

先通过只读探测。接着在可丢弃环境中准入一项收敛式写入。在每个边界让它断开，并核对结果。随后在生产形态的主机上，使用无害资源重复测试。与拥有该主机的人一起审查证据，而不只与构建代理网关的团队审查。

将回滚与重试分开。回滚是一项新的、明确的变更，有自己的批准、操作 ID、前置条件和可能的未知状态。超时后自动运行反向命令，可能损害其实已正确完成的变更。系统必须先确定当前状态，才能再次改变该状态。

与准入标准同时设定移除标准。当脚本摘要未经审查就变更、主机离开清单、核对不再有效、输出变得无界，或运维人员无法解释未知结果时，禁用该操作。通道访问不是永久毕业证书。

SSH 应逐项操作获得准入。如果你无法固定服务器、说明影响、安全重复、区分所有结果状态并在事后重建调用，检查清单的正确结果就是“尚未准备好”。继续使用只读 HTTP 通道，先补齐缺失的边界，再避免远程 shell 将一次模糊的重试变成第二次生产变更。
