# SSH 主机名规范化会改变已批准的目标吗？

一项写着 `ssh build` 的审批，并不一定授权了最终接收 TCP 连接的那台机器。OpenSSH 可能会补全这个短名称、跟随允许的 CNAME、再次解析配置、选择不同的用户或端口，再把得到的名称解析为多个地址之一。如果代理网关只审批代理提交的文本，审批描述的目标和 SSH 客户端实际使用的目标就可能不是同一个。

这并不意味着主机名规范化不该使用。短名称很方便，规范名称也便于管理配置，主机密钥仍然可以验证服务器身份。问题在于，审批边界应放在 OpenSSH 算出实际目的地之后、打开套接字之前。审批者应该同时看到请求名称、最终名称和地址，并能看出配置带来的实质变化。

## 一条 SSH 命令包含四种不同身份

SSH 目标不是一个字符串。把它当成一个字符串，正是本文所说问题的根源。可靠的审批流程会区分四种身份：

- 请求名称是代理提供的参数，例如 `build`。
- 实际主机名是 OpenSSH 完成 `Hostname` 替换和规范化后使用的值，例如 `build.ops.example`。
- 对端地址是客户端打开套接字时选定的 IP 地址。
- 已验证身份是该连接接受的主机密钥或主机证书。

这些值回答不同的问题。请求名称记录意图。实际名称控制后续配置匹配，通常也用于查找主机密钥。地址说明数据包去了哪里。主机密钥说明哪台服务器证明了自己持有对应私钥。审批前需要前三项，密钥交换完成后的审计结果则应补上第四项。

OpenSSH 在 `ssh_config` 中明确暴露了这种名称差异。`%n` 表示命令行给出的原始远程主机名，`%h` 表示配置处理后的远程主机名。`%k` 又是另一个值：如果设置了 `HostKeyAlias`，它就是该别名，否则是命令行中的原始名称。这些标记之所以存在，是因为 OpenSSH 不能假装每个阶段使用的名称都相同。

这种区别也解释了为什么主机密钥检查成功无法弥补含糊的授权决定。身份验证可以证明端点持有预期密钥，却无法证明审批卡只显示 `build` 时，审批者确实想授权这个端点。授权要回答该操作是否可以访问这个目的地，身份验证则要回答响应的服务器是否符合身份记录。两项检查都需要，而且发生在不同阶段。

## 规范化会在连接前改写名称

`CanonicalizeHostname` 控制 OpenSSH 是否显式改写名称。默认值是 `no`，名称查找交给系统解析器。设为 `yes` 后，OpenSSH 会对直连目标尝试 `CanonicalDomains` 中的后缀。设为 `always` 后，通过 `ProxyCommand` 或 `ProxyJump` 到达的目标也会规范化。

假设代理请求以下操作：

```text
host: build
user: deploy
command: /usr/local/bin/release status
```

客户端可能有如下配置：

```sshconfig
Host *
    CanonicalizeHostname yes
    CanonicalDomains ops.example
    CanonicalizeFallbackLocal no
    CanonicalizeMaxDots 1
    CanonicalizePermittedCNAMEs *.ops.example:*.hosts.example
```

OpenSSH 会先尝试 `build.ops.example`。如果 DNS 返回一个被允许、指向 `build-07.hosts.example` 的 CNAME，后者就可能成为规范名称。请求仍写着 `build`，但客户端已不再以该名称连接。只显示请求文本的审批卡隐藏了真正重要的转换。

这些边界参数需要认真对待。`CanonicalizeMaxDots` 默认为 1，因此带一个点的名称仍可能被规范化，不只是没有点的标签。`CanonicalizeFallbackLocal` 默认为 `yes`；如果配置的规范域找不到结果，OpenSSH 可以把原名称交给系统解析器及其搜索规则。设为 `no` 会让失败明确发生。对审批网关而言，明确失败通常更好，因为本地搜索列表可能随网络和时间变化。

是否跟随 CNAME 由另一项设置约束。`CanonicalizePermittedCNAMEs` 默认为 `none`，规则把允许的来源模式与目标模式配对。这个默认值很合理。像 `*:*` 这样的宽泛规则会把 DNS 别名变成不受限制的改写步骤。窄规则则能明确记录真正允许的命名空间转换，例如从 `ops.example` 下的服务别名转到 `hosts.example` 下的主机。

OpenSSH 手册对这些控制说明得很精确，但策略层不能只把选项值抄到审批卡上。它必须用相同的客户端配置运行同一套计算。在审批服务中重新实现后缀搜索和 CNAME 规则，往往会随着 SSH 客户端版本与本地配置变化而产生偏差。

## 第二次配置解析改变的不只是 DNS

启用规范化后，OpenSSH 会用新的目标名称再次处理配置。第二次解析可能激活原始别名无法匹配的 `Host` 和 `Match` 块。因此，只做 DNS 预检仍可能漏掉目的地的其他变化。

假设规范名称匹配以下配置块：

```sshconfig
Match canonical host *.prod.ops.example
    User release
    Port 2222
    IdentityFile ~/.ssh/prod_release
    ProxyJump bastion.ops.example
```

对 `deploy` 的请求可能先扩展为 `deploy.prod.ops.example`，然后获得不同的远程用户、端口、身份文件和跳板主机。OpenSSH 对多数指令采用最先取得的值，所以这些设置是否生效，取决于前面的配置块是否已经赋值以及配置顺序。只看这个匹配块，无法推断最终配置。

`canonical` 条件只在主机名规范化后的解析中匹配。即使没有启用规范化，`final` 条件也会要求执行最后一次解析。规范化启用时，`canonical` 和 `final` 在同一次解析中匹配。`host` 匹配 `Hostname` 替换或规范化后的目标，`originalhost` 匹配命令行中的值。这些细节让看似简单的配置带有明显的状态依赖。

危险的审批方式是先批准 `host=deploy, user=agent, port=22`，再把 `ssh deploy` 交给普通客户端，并假定这些字段不会变化。如果客户端配置仍能修改 `User`、`Port`、`ProxyJump`、`RemoteCommand`、端口转发或身份选择，审批覆盖的只是提案，不是实际执行的操作。

更安全的做法是在最后一次解析后冻结重要的实际配置。至少要把审批绑定到实际主机、选定地址、端口、远程用户、代理路径、远程命令、转发请求、主机密钥别名以及客户端可使用的身份来源。如果任何绑定值在连接前发生变化，就丢弃审批并重新询问。不要让旧审批悄悄跨过第二次求值继续生效。

## 配置来源也是目标的一部分

最终目的地取决于 OpenSSH 读取了哪些配置文件、命令行选项、环境和本地网络。审批目标时不记录这些输入，就给已批准含义的变化留下了简单通道。OpenSSH 手册给出的常规来源顺序是命令行选项、用户配置文件和系统配置文件。对大多数指令，最先取得的值生效。

能够添加任意 SSH 参数的代理可以绕过精心设置的默认值。`-F` 可以选择另一个配置文件，`-o` 则能直接设置 `Hostname`、`ProxyCommand`、`ProxyJump`、`Port`、`User`、`HostKeyAlias` 或转发指令。网关应把 SSH 操作解析为有类型的字段并拒绝不支持的选项，不要接受一整段不透明的命令字符串。如果必须支持原始参数，就要分类所有影响目的地的参数，并在审批中加入其规范化后的值。

配置文件还会引入传递输入。`Include` 接受多个路径、通配符、标记和环境变量，并按词法顺序处理通配符匹配。因此，即使不修改顶层文件，只要向被包含的目录添加文件，也可能改变哪个值最先出现。应记录每个已加载文件的内容摘要和所有者，而不只是第一个文件的路径。代理或不可信代码仓库的工作目录可写的文件应被拒绝。

`Match exec` 不只是比较一个条件字符串：OpenSSH 会在用户 shell 下运行指定命令，并把退出状态为零视为匹配。`Match localnetwork` 可以根据活动网络接口的地址改变配置。手册本身也警告，在 DHCP 配置网络的情况下，本地观察到的网络不能作为敏感配置的可信条件。这些谓词意味着，同一个 `ssh build` 请求可能在 VPN 连上、脚本退出状态变化或新包含文件出现后得到不同的实际目标。

审批执行器应在求值前建立封闭的输入集合。使用固定的客户端二进制文件、受控配置根目录、清理过的环境、已知的文件所有权、明确的地址族，以及声明清楚的用户和系统配置策略。计算该集合的摘要并放进执行票据。摘要不必占据审批卡的主要位置，但必须进入审计记录，并在使用前再次核对。

冻结配置并不意味着禁止有用的逐主机设置，而是要决定谁可以编写这些设置。由运维人员管理、把 `build` 映射到规范生产主机的别名可以安全使用，前提是审批显示该映射。代理控制的代码仓库中创建的别名属于不可信请求的一部分，即使语法看起来只是普通 OpenSSH 配置。

来源检查也能堵住常见的测试缺口。团队经常用干净的临时配置测试规范化，实际执行时却使用开发者的完整配置和系统默认值。这样的测试证明的是临时文件，不是真实操作。只捕获一次确切的求值输入，并让它贯穿审批与连接。

OpenSSH 版本也应视为输入。默认值和可接受指令会变化，即使所有机器读取同一个共享文件，一组机器中也可能存在不同的客户端版本。在票据中记录可执行文件路径和版本，并在升级进入代理流量前重新运行兼容性测试。针对某个版本编写的解析器遇到未知输出时应该拒绝，而不是猜测改名或新增字段无关紧要。

配置来源还决定审批能否复用。只有请求、完整的求值配置、解析结果和连接器状态在较短有效期内完全相同时，复用才说得过去。相同的昵称本身什么也证明不了。与其发放实际目的地可能在代理多次运行之间悄悄漂移的长期授权，不如重新给出一个简洁审批。

## 审批卡出现后 DNS 答案仍会变化

即使实际主机名不变，其地址也可能在审批和连接之间变化。DNS 轮换、分区视图、VPN 变化、解析器搜索路径以及普通记录更新，都可能给出不同答案。如果审批服务先解析名称并显示地址，SSH 进程稍后又解析一次，就形成了典型的检查与使用时间差。

解决办法不是把 DNS 标成不可信后忽略地址，而是把执行绑定到审批者看到的精确解析结果。在可信执行器内只解析一次，按照客户端的地址族规则选择地址，显示该地址，再把选定的套接字地址传入连接路径，期间不再查找名称。规范名称仍可用于适用的类似服务器名称指示的身份用途、主机密钥查找、证书和日志，但传输层不能悄悄选择新地址。

多个 A 或 AAAA 记录需要明确规则。显示一个地址集合并允许客户端尝试其中任意成员可以是合理方案，但审批必须说明它覆盖所显示的集合，审计日志也必须记录最终成功的成员。如果集合变化，旧审批不能自动扩展到新地址。对于高风险主机，审批单个选定地址更容易理解。

代理会改变本地 DNS 解析发生的位置。使用 `ProxyJump` 时，客户端通常要求跳板连接把流量送到最终主机和端口。`ProxyCommand` 则几乎可以实现任何传输行为。此时本地可见的对端地址可能属于代理，最终目的地名称在其他位置解析。审批必须包含两跳：本地进程连接的具体代理端点，以及通过代理传递的最终主机值。把代理地址假装成目标会丢失操作目的地，假装它无关紧要则会丢失网络路径。

## 有效主机密钥不能验证请求名称

严格主机密钥检查保护的是另一条边界。它应该保持启用，但无法判断规范化结果是否符合人的意图。共享主机密钥、包含多个主体的主机证书和显式 `HostKeyAlias`，都可能让客户端在审批时未显示的名称下建立密码学上有效的连接。

SSH 协议体系 RFC 4251 描述了一种本地数据库，把用户输入的主机名与主机密钥关联起来。OpenSSH 在这个基础模型上又加入配置驱动的命名。`HostKeyAlias` 明确要求客户端在读取或写入主机密钥记录及验证主机证书时，使用别名代替真实主机名。它对隧道和一个地址上的多台服务器很有用，但也创造了审计日志应记录的又一个名称。

定义 SSHFP 记录的 RFC 4255 与这个问题尤其相关。它警告未完全限定主机名和被注入的 DNS 搜索路径，并建议在这种情况下先查本地主机密钥数据库，再查 DNS 指纹。它还规定，除非 DNSSEC 验证了 SSHFP 记录，否则不能信任该记录。这些建议讨论的是服务器身份验证，但也强化了更广泛的结论：名称扩展会改变安全上下文，解析器输出本身不是身份证明。

RFC 4462 对 GSS-API 的警告更直接。它规定，实现不能用不安全的 DNS 结果构造服务器目标名称，因为攻击者可以改变别名或地址映射来冒充服务器。即使代理使用普通公钥认证而不是 GSS-API，设计教训仍然成立。不受保护的名称改写不应悄悄决定安全控制声称已经批准的身份。

主机密钥验证应把连接结果重新绑定到预检记录。记录实际出现的指纹、查找时使用的名称或别名、证书主体是否匹配以及严格检查结果。如果客户端退回到提示接受新密钥，这就是一个新的安全决定。自主进程不能因为此前有人批准了 shell 命令，就把它转成自动接受。

## 预检必须使用相同的 OpenSSH 输入

有用的预检会重现客户端的最终配置，并在不执行远程命令的前提下观察连接路径。`ssh -G` 会打印求值 `Host` 和 `Match` 块后的配置。`ssh -vvv` 会加入解析、连接和主机密钥诊断，OpenSSH 手册规定三个 `-v` 是最高详细级别。

在受控环境中运行以下序列，并用确切的请求标记替换 `build`：

```sh
ssh -G build | awk '
  $1 == "hostname" || $1 == "user" || $1 == "port" ||
  $1 == "proxyjump" || $1 == "proxycommand" ||
  $1 == "hostkeyalias" || $1 == "canonicalizehostname" {
    print
  }
'
ssh -vvv -o BatchMode=yes -o SessionType=none \
  -o ConnectTimeout=5 build 2>&1
```

第一条命令的输出大致如下：

```text
user deploy
hostname build-07.hosts.example
port 22
canonicalizehostname true
proxyjump none
```

详细运行随后会输出若干行，具体措辞随 OpenSSH 版本变化，但会标明扩展后的主机、选定地址和端口、配置解析过程，以及密钥交换时服务器给出的主机密钥。执行器应捕获结构化事实，不要把调试文本当成稳定 API。这些命令很适合运维人员调查配置；生产代码则应从真正打开套接字的实现中取得相同事实。

这项诊断有两个陷阱。第一，`ssh -G` 显示求值后的客户端配置，却不能证明后续连接最终会选择并到达哪个地址。第二，即使设置 `SessionType=none`，详细探测仍可能打开网络连接并执行密钥交换；只有在探测本身获得授权时才能运行。`BatchMode=yes` 会抑制交互式密码和主机密钥提示，但不会把连接尝试变成纯本地计算。

要让配置调查可复现，必须隔离所有输入。用 `-F` 提供预期的用户配置，考虑当前客户端构建处理系统配置的方式，固定 `Include` 或标记展开所用的环境变量，并记录 OpenSSH 版本。还要记录控制套接字是否可能复用现有多路复用连接。如果执行最终附着到更早建立的主会话上，针对新 DNS 和配置做的预检就没有多少意义。

不要为了审批启动一次独立解析，又为了执行启动另一次。理想架构是同一个执行器加载配置、规范化、解析地址，带着不可变候选记录暂停，批准后再继续同一个状态机。如果 SSH 库无法安全暂停，就创建一张带签名或密钥保护的执行票据，包含所有重要字段，并要求连接器在任何字段不一致时拒绝执行。

## 审批记录应展示转换过程

好的审批卡会让变化清晰可见，而不要求审批者阅读调试日志。把请求目标、实际目标和地址并排显示。如果没有变化，就简洁说明。如果规范化或 `Match` 块改变了重要字段，则标出旧值和新值。

实际审批记录可以采用以下结构：

```json
{
  "requested": {
    "host": "build",
    "user": "deploy",
    "command": "/usr/local/bin/release status"
  },
  "effective": {
    "host": "build-07.hosts.example",
    "address": "192.0.2.44",
    "port": 22,
    "user": "deploy",
    "proxy": null,
    "host_key_alias": null
  },
  "resolution": {
    "canonicalized": true,
    "source": "build.ops.example",
    "permitted_cname": true
  },
  "binding": "sha256:REDACTED"
}
```

绑定应覆盖请求字段和实际字段的确定性编码、远程命令、转发选项、相关配置身份、选定地址或获批地址集合，以及较短的过期时间。连接器在打开套接字前立即重新计算绑定。如果 DNS、配置、命令、用户、端口或代理路径不同，它就停止执行。

不要因为绑定已经包含某字段，就从可视审批卡上删掉它。人只能授权自己看得到的内容。用一条易读的转换显示 `build -> build-07.hosts.example (192.0.2.44)`，再显示用户、端口、代理和命令。原始指纹与配置来源可以放进可展开的详细信息，除非出现新的或已变化的主机身份，需要直接提醒。

难处理的情况是主机名解析到一个庞大或频繁变化的地址池。不要批准诸如该名称当前对应的任意地址这样没有边界的概念。应选择一个连接候选项、批准一个有过期时间的受限公开集合，或者要求更稳定的强身份，例如由可信 CA 支持的主机证书主体。连接器不应自己猜采用了哪种模型，审批必须明确说明。

## 代理和连接复用需要单独绑定

规范化在代理环境下表现不同。`CanonicalizeHostname yes` 只作用于没有 `ProxyCommand` 或 `ProxyJump` 的连接；`always` 才包含经代理的连接。这个单词的变化就可能改变最终名称，并触发第二次配置解析。审批系统必须记录真实值，不能假定规范化全局开启或关闭。

每台跳板主机都是独立 SSH 连接，都有自己的请求名称、实际名称、地址、用户、端口和已验证主机密钥。隧道另一端的最终目标也保留自己的身份。只批准最终标签，会漏掉被攻破或意外的跳转路径；只批准跳板服务器，则会漏掉它把数据流转发到哪里。

连接多路复用会造成更隐蔽的绕过。如果已经存在匹配的 `ControlMaster` 会话，后续 `ssh` 调用可以复用它，而不建立预检所预测的新连接。现有主连接可能更早解析了 DNS、使用了旧配置，并在当前审批之前验证了主机密钥。对受控代理操作，应禁用复用，或者把审批绑定到主会话不可变的连接身份，并在打开通道前核验该身份。

同一规则也适用于重试。一个地址失败后，只有第二个地址在显示的获批集合中，重试才仍然获得授权。代理回退、备用端口或新规范化名称都不是普通传输细节。它们改变了授权对象，需要重新决定。

## 审计证据必须保留意图与结果

有用的 SSH 审计项应让调查人员无需重新运行 DNS，就能重建整个转换过程。保存原始代理请求、实际 OpenSSH 配置字段、规范名称、DNS 链、候选地址、选定地址、代理跳点、接受的主机指纹、证书主体以及请求的命令或子系统。分别记录解析和套接字连接的时间戳，这样时间差就清楚可见。

Sallyport 通过随附的 `sp-ssh` 辅助程序传递 SSH 操作，同时把密钥保留在加密保险库中，因此代理永远拿不到 SSH 私钥。它的 Activity 日志和 Sessions 日志来自同一份加密的哈希链审计日志，使审批和执行路径能够同时保留请求目标与观察到的目标，而不只是一个像 shell 命令的字符串。

仅仅保留记录还不够。定义可由测试断言的不变量：执行地址属于获批候选集合，实际主机和端口符合票据，接受的主机身份已有记录，每次重试和代理跳转都获得授权。给测试输入含 `CanonicalizeHostname`、允许的 CNAME、`Match canonical` 块和两个 DNS 答案的配置。然后在预检与连接之间更改一个事实，确认执行会停止。

值得阻止的故障很普通：代理请求 `build`，审批者认出这个昵称并点击批准，但另一网络上的客户端把它扩展到不同域名。SSH 会话可能仍然加密，服务器也可能为自己的名称出示有效密钥。这个审批仍然是错的。把请求名称、最终名称和选定地址放进同一个决定，并让连接器证明它使用的正是这些值。
