# 面向接触生产环境的代理进行 SSH 主机验证

自主编码代理不应自行判断新的 SSH 服务器是否可信。它可以检查代码库、起草部署方案并请求访问权限，但当远程身份与人工或可信配置流程建立的记录不一致时，代理应该停止操作。

SSH 有两个彼此独立的安全问题：「我连接到的是目标服务器吗？」以及「这个账户在那台服务器上可以做什么？」团队经常把两者混在一起，因为它们都使用公钥。这个错误会先把错误连接变成凭据泄露，再让权限过大的账户演变成生产事故。

实际做法很简单：在代理连接前固定主机身份，为每项代理任务分配权限狭窄的远程账户，并让人审核那些无法安全推断影响范围的命令。每一层控制针对一种不同的故障，任何一层都不能替代其他层。

## 必须在用户身份验证前检查主机身份

主机验证用于确认 SSH 客户端是否连接到了它要连接的服务器。用户身份验证则确认这台服务器是否接受客户端提供的账户凭据。顺序很重要，因为 SSH 会先协商并验证服务器主机密钥，然后客户端才会发送用户身份验证材料。

主机密钥属于服务器，不属于管理员，也不属于代理。指纹是这个主机公钥的简短表示，通常以 SHA256 格式显示。如果 `deploy.example.internal` 通常提供一个指纹，却突然提供另一个指纹，客户端就有理由认为发生了变化。变化可能来自合法重建，也可能是 DNS 错误、地址复用、堡垒机配置错误或主动拦截尝试。

设想一个编码代理被要求在 `db-prod.internal` 上运行迁移。它的 SSH 配置会将这个名称解析为一个地址。能够影响 DNS、代理路由或过期资产清单的攻击者，可以把连接引向自己控制的服务器。如果客户端接受陌生的主机密钥，这台服务器就可以请求用户身份验证。客户端上的 SSH 私钥可能不会离开客户端，但代理访问通常还包括密码、证书签发流程、转发能力，或登录后能够泄露有用信息的命令。更重要的是，代理现在可能会在错误的机器上执行原本要执行的命令。

固定主机指纹会让连接在代理把这台机器当作目标之前失败。这就是为什么陌生或发生变化的主机属于授权边界，而不是应该被压制的小警告。

主机验证不能证明机器运行正常、配置正确或适合修改。它只能确认加密身份的连续性。这个限制很有价值。不要让主机指纹替你判断 `rm -rf`、数据库结构迁移或防火墙修改是否合理。

## 首次使用时信任不适合无人值守工作

对于开发者手动连接一台随时可以丢弃的个人机器，首次使用时信任还算可以接受。但对于自主进程，它不是好的默认设置，因为第一次连接恰恰是需要有人判断主机名、路由和指纹是否相互匹配的时候。

OpenSSH 在 `ssh_config` 手册的 `StrictHostKeyChecking` 部分说明了这个选择。设置为 `yes` 时，客户端不会自动添加未知主机密钥，也会拒绝发生变化的密钥。设置为 `accept-new` 时，客户端会自动记录未知密钥，但仍会拒绝发生变化的密钥。设置为 `no` 或 `off` 时，则会接受更多本应由操作人员关注的情况。

`accept-new` 经常被宣传为合理的折中方案。主机频繁创建时，它确实能减少反复处理。但它也把建立初始身份记录的权力交给了网络路径。对于能够修改基础设施的代理来说，做决定的不应该是这一方。

对由代理操作的生产和预发布终端使用 `StrictHostKeyChecking=yes`。当连接因为主机未知而失败时，把代理的请求交给能够将报告的指纹与 SSH 连接之外的信息源进行比较的人。云控制台、签名的资产清单、物理控制台或现有管理通道都可以提供比较依据。

不要通过在全局配置文件中加入 `StrictHostKeyChecking=no` 来解决中断。那一行往往会在临时事件结束后继续存在，然后悄悄应用于原本没人打算放宽验证的主机。

有一个范围更窄的例外：短生命周期的测试基础设施，其主机身份来自能够在测试开始前发布经过身份验证的主机列表的配置系统。代理仍然不应该从自己的第一次网络连接中学习主机身份。变化的是信任来源，而不是信任的必要性。

## 只有独立来源的指纹才有价值

从你正在验证的终端复制指纹，几乎不能证明任何事情。`ssh-keyscan` 便于收集公开主机密钥，而这种便利也带来了常见陷阱：操作人员对主机名运行它，把结果粘贴进 `known_hosts`，然后认为主机已经验证。如果 DNS 或路由本来就指向攻击者，他们固定的其实是攻击者的密钥。

在已经获得独立指纹后，用 `ssh-keyscan` 收集密钥，而不要把它当作信任来源。例如，管理员可以从服务商控制台或签名的构建记录中获取主机密钥指纹，然后与收集到的密钥进行比较。

```sh
ssh-keyscan -t ed25519 app-prod.internal > /tmp/app-prod.hostkey
ssh-keygen -lf /tmp/app-prod.hostkey -E sha256
```

第二条命令会输出类似下面的内容：

```text
256 SHA256:exampleFingerprintMaterial app-prod.internal (ED25519)
```

逐字符比较 `SHA256:` 后面的值与独立获得的值。同时验证算法。如果记录显示的是 ED25519，而收集结果是 RSA，应停止并调查，不要把两种结果当作可以互换。

然后固定公钥，而不只是保存一条写有指纹的备注。单独的文件可以让代理目标与开发者个人积累的旧主机记录分开：

```text
app-prod.internal ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExamplePublicHostKeyMaterial
[192.0.2.44]:22 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExamplePublicHostKeyMaterial
```

同时包含规范主机名，以及代理获准使用的任何地址形式。否则，某个工作流今天使用地址、明天使用名称时，可能会再次触发新的信任决定。将该文件纳入受控配置管理，并通过变更审核明确旧指纹和替换后的指纹。

在 DNSSEC 从头到尾正确部署的情况下，DNS SSHFP 记录会有所帮助。但它无法挽救一个把普通未签名 DNS 响应当作证据的代理配置。将 SSHFP 当作额外的已验证发布渠道，而不是让盲目首次连接变得安全的装饰性记录。

## 在 SSH 配置中固定路由和主机记录

代理需要一份能够消除歧义的 SSH 配置，而不是继承工作站使用习惯的配置。通过明确的配置条目固定目标名称、预期的主机密钥文件、账户和连接行为。

```sshconfig
Host app-production
    HostName app-prod.internal
    User agent_release
    Port 22
    UserKnownHostsFile ~/.ssh/agent_known_hosts
    GlobalKnownHostsFile /dev/null
    StrictHostKeyChecking yes
    UpdateHostKeys no
    PasswordAuthentication no
    KbdInteractiveAuthentication no
    ForwardAgent no
    PermitLocalCommand no
```

这段配置可以避免多种常见错误。`UserKnownHostsFile` 避免意外依赖某个人积累的主机记录。`GlobalKnownHostsFile /dev/null` 防止未受管理的系统级文件悄悄扩大信任范围。`UpdateHostKeys no` 防止代理工作期间自动更新主机密钥并改变固定集合。`ForwardAgent no` 防止远程机器利用转发的身份验证代理连接到其他地方。

OpenSSH 的 `ssh_config` 手册说明，`UpdateHostKeys` 支持主机在证明自己持有受信任密钥后进行主机密钥轮换。对于采用严格主机管理的交互式主机群，这一行为可能很有用。对于自主代理来说，自动修改会让事件审核更加困难。生产身份变化应该由人批准，并有意识地更新受控主机文件。

在代理请求中使用 `app-production` 这样的稳定别名，并将原始地址保留给紧急流程。别名会让获批目标在日志中清晰可见，也能避免命令在名称、临时地址和复制来的 shell 片段之间漂移。

在授予代理任何凭据路径前，测试实际生效的配置：

```sh
ssh -G app-production | grep -E '^(hostname|user|stricthostkeychecking|userknownhostsfile|forwardagent|updatehostkeys) '
```

预期值类似于：

```text
hostname app-prod.internal
user agent_release
stricthostkeychecking yes
userknownhostsfile /Users/operator/.ssh/agent_known_hosts
forwardagent no
updatehostkeys no
```

这可以发现 `Include` 文件、用户默认设置和配置管理造成的优先级错误。我见过经过仔细配置的主机条目被后面的通配符条目覆盖，后者修改了用户、启用了转发，或选择了另一个 known-hosts 文件。真正执行的命令所使用的配置，才是必须检查的配置。

## 账户范围能在正确连接后限制损害

经过验证的主机仍可能收到错误命令，因此应该给代理分配一个目标明确、权限很小的账户。不要把操作人员处理各种紧急情况时使用的同一个 SSH 登录交给自主编码代理。

账户范围包括四个方面：账户可以连接哪些主机，可以影响哪些文件和服务，具备哪些权限提升路径，以及可以使用多长时间。独立账户会让这些答案更容易检查。共享的 `deploy` 登录会让每次自动化运行都变成归因问题，而且通常会因为每个新工作流都需要增加例外，最终积累出过大的权限。

应用服务器上的发布代理可能需要读取发布目录、写入新构件、调用一个部署包装器并重启一个服务。它不需要不受限制的 `sudo` shell，不需要读取每个用户的主目录，也不需要修改 SSH 配置的能力。

任务范围较窄时，可以使用强制命令限制授权的 SSH 公钥。在 `authorized_keys` 中，服务器端条目可以将凭据绑定到一个包装器：

```text
command="/usr/local/sbin/agent-release-wrapper",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExampleAgentPublicKey agent-release
```

包装器应该解析一小组参数，拒绝 shell 元字符，记录请求的操作，并使用固定路径调用固定二进制文件。不要编写一个接受 `SSH_ORIGINAL_COMMAND` 中任意字符串并将其交给 `sh -c` 的包装器。那只是把不受限制的远程 shell 访问藏到了一个函数名后面。

强制命令并不适合所有维护任务。如果代理确实需要 shell 进行调查，应使用独立的调查账户，只提供读取权限，不提供权限提升。针对变更建立单独且经过明确审核的路径。把诊断和修改混在一个权限宽泛的账户中，会给代理太多空间，让不完整的推断变成不可逆操作。

如果已经运行着具有明确签发控制的证书颁发机构，短期 SSH 证书可以减少清理负担。但它不能免除主机验证的需要。证书说明的是客户端账户，主机指纹说明的是接收该证书的服务器。

## 命令审核必须展示实际影响，而不是模糊意图

只有当审核人员能看到足够多的上下文来判断影响时，命令批准才有价值。「部署发布版本」是一种意图。在 `app-production` 上以 `agent_release` 身份执行 `sudo systemctl restart payments-api`，才是审核人员可以评估的动作。

审核准确的目标别名、远程账户、字面命令、参数和工作目录。同时检查命令是否调用 shell、展开变量、读取远程脚本、下载内容或使用 `sudo`。这些细节决定了一个看似无害的请求能否触及远超其描述的范围。

远程操作的批准记录可以是这样：

```text
Target: app-production (app-prod.internal)
Account: agent_release
Command: /usr/local/sbin/agent-release-wrapper activate 2025.04.17-rc2
Reason: activate the approved release after smoke tests
```

再看下面这个请求：

```text
ssh app-production "curl $URL | sudo sh"
```

第二条命令把远程内容获取、shell 执行、提升权限，以及可能以不同方式展开的值捆绑在了一起。任何主机验证设置都不能让它变得安全。应该拒绝它，并要求使用之前已经检查过摘要的构件、固定的部署程序，以及明确写出获批发布版本的参数。

命令审核本身也有一种故障模式：批准疲劳。如果代理对每个无害的 `cat`、`git status` 和服务健康检查都请求确认，人们就会学会不阅读内容直接批准。可以通过受限账户或严格定义的命令包装器处理只读调查，然后把交互式批准保留给会写入、重启、轮换、修改权限或跨越信任边界的操作。

Sallyport 可以将 SSH 凭据保存在加密保险库中，并要求每次使用指定凭据时获得批准，同时通过 `sp-ssh` 执行 SSH 通道，而不把凭据暴露给代理。不过，固定主机身份，以及判断显示出来的命令是否值得批准，仍然是你的责任。

## 危险的故障路径通常是一连串普通的捷径

自动化相关的大多数 SSH 事故并不是从复杂的密码学突破开始的，而是从设置阶段听起来无害的捷径开始的。

设想一个发布代理被配置为使用 `StrictHostKeyChecking=accept-new`、共享部署账户，以及只有「运行部署」几个字的批准提示。资产清单完成更新前，某个 DNS 记录短暂地指向一台替换机器。代理看到陌生主机，记录它的密钥；由于接受共享账户，它登录到替换机器，并运行部署包装器。由于共享账户还支持紧急修复，包装器拥有广泛的写入权限。

这个过程不需要攻击者参与。一次普通的命名错误，就可能把构件部署到错误环境，将部署输出暴露给非预期机器，或修改一台尚未准备好的主机。如果再加上一个能够影响名称解析或路由的攻击者，同样的捷径就会变成严重得多的入口。

每项控制都会在链条中的不同位置打断故障：

1. 预先固定的主机记录会拒绝替换机器，直到操作人员完成验证。
2. 独立账户可以在获批目标仍以某种主机身份无法检测的方式出错时，限制损害范围。
3. 包含完整命令的批准记录让审核人员有机会发现意外目标或提升权限的操作。
4. 会话和调用轨迹让团队可以在故障后还原请求、批准、目标和结果。

不要用后面的控制替代第一项控制。审核人员可能会漏看目标不匹配。账户限制可能存在未被发现的权限。日志可以解释事后发生的损害。固定主机身份则能在远程账户进入流程前，阻止一类连接错误。

## 主机密钥轮换需要变更流程，而不是例外按钮

主机密钥因正当原因发生变化：机器重建、镜像替换、算法退役，或操作人员在怀疑密钥泄露后轮换凭据。应将这些事件视为有证据支持的计划身份变更，而不是可以随手关闭的警告对话框。

负责该主机的人应从新机器的控制台或其他经过身份验证的管理路径获取替换后的主机公钥。然后将新条目发布到受控 known-hosts 文件，记录变化原因，并在切换成功后再删除旧条目。如果过渡期间必须保持服务，OpenSSH 可以为同一主机名保存多个可接受的主机密钥。这样，客户端就能在明确的时间窗口内同时接受旧身份和新身份。

轮换记录可以简短，但必须具体：主机名、相关时的地址、旧指纹、新指纹、原因、验证人，以及双密钥期间的到期时间。一张只写着「SSH 发生变化」的工单，无法区分计划内工作和有人试图让客户端信任冒牌主机。

绝不要指示代理运行 `ssh-keygen -R hostname` 后自动重连。删除旧记录会在有人确认原因前抹去不匹配信号。操作人员经过验证后，可以将该命令作为审核过的更新步骤使用，但它不应成为代理工作流中的恢复逻辑。

## 审计记录应该让人能够重现决策

SSH 操作失败或出现意外后，有用的记录应该回答四个问题：哪个代理进程发起了请求，谁批准了它，使用了什么准确的连接和命令，以及返回了什么结果。「代理部署了服务」一个问题都回答不了。

将会话记录与单次调用记录分开。会话用于标识代理运行，并让操作人员在代理行为异常时撤销其权限。单次调用记录则保存主机别名、账户、时间、批准结果、命令和输出或错误。当一次代理运行执行了一百次安全读取后又进行了一次不安全写入时，这种区分非常重要。

保护日志，不能让生成请求的代理修改日志。如果代理可以在远程命令执行后编辑记录，审计轨迹就只是日记，而不是证据。至少应使用带完整性验证的只追加存储。同时不要把秘密放进命令或参数中，因为可靠的审计日志会准确保存你要求它保存的内容。

Sallyport 会将会话和活动日志写入一个对写入方不可见、经过哈希链保护的加密审计日志；使用 `sp audit verify` 可以在没有保险库密钥的情况下离线验证哈希链。应使用这类证据调查异常，但要在事故迫使你查看日志之前，先设计好主机固定、账户限制和批准文本。

首先要做的运维改动很简单：找出所有能够接受未知主机的代理 SSH 配置，将这种行为替换为专用的固定主机文件，然后使用 `ssh -G` 测试实际生效的设置。你会发现过期别名、意外的通配符规则，以及权限远超工作需要的账户。这些连接应该在代理变得更快之前修好。
