# SSH 配置中的 Match 块与代理批准

只有当获批的连接确实是 SSH 将要建立的连接时，SSH 批准才有意义。这听起来很明显，但实际情况可能是：代理运行 `ssh prod`，主机别名选择了另一个 `HostName`，`Match` 块修改了远程用户，而 `ProxyJump` 又让会话先经过一台请求中完全没有提到的跳板机。

有人在终端旁观察时，大多数 SSH 配置错误都还能被及时发现。这个人会看到陌生的主机密钥提示，会注意到 `root@...`，或者记得 `prod` 在办公网络中的含义不同。自主代理没有这种警觉。它使用收到的别名，并严格按照配置执行。

因此，在授予代理 SSH 访问权限前，SSH 配置中的 Match 块值得进行安全审查。重点不是禁止别名、跳板机或条件配置，而是让请求的操作、有效的 SSH 配置以及人批准的连接描述同一件事。

## 主机别名是输入，不是身份

`ssh app-prod` 并不能告诉你 SSH 会连接到哪里、使用哪个账户、提供哪把密钥，或者流量是否会先经过另一台机器。它只告诉 OpenSSH 从哪个配置参数开始处理。

别名之所以容易混淆，是因为它让日常命令行操作更方便。输入 `app-prod`，比输入带有具体用户和非标准端口的完整主机名容易得多。把它交给代理也更方便。但别名只是一个句柄。它的含义来自 SSH 读取的所有配置文件中与之匹配的设置。

OpenSSH 会先读取命令行选项，然后读取用户的 `~/.ssh/config`，最后读取系统级配置。对于大多数单值选项，SSH 取得的第一个值就是它最终使用的值。OpenSSH 的 `ssh_config(5)` 手册明确说明了实际影响：应把具体声明放在前面，把通用默认值放在后面。靠近顶部的宽泛 `Host *` 块，可能悄悄压过后面更谨慎的条件规则。

先建立一份清单，把代理可能使用的每个别名记录成审查者可以核对的形式：

| 别名 | 解析后的目的地 | 远程用户 | 路由 | 身份用途 |
|---|---|---|---|---|
| `staging-api` | `api-01.staging.example.net` | `deploy` | 直连 | staging 部署身份 |
| `prod-api` | `api-01.prod.example.net` | `deploy` | `prod-bastion` | 生产部署身份 |
| `prod-breakfix` | `api-01.prod.example.net` | `ops` | `prod-bastion` | 仅限故障处理的身份 |

不要在目的地一栏写 `production`。应写 SSH 实际使用的目标。不要写“默认用户”，而要写服务器实际接收的账户，例如 `deploy`、`ubuntu`、`ec2-user`。如果路由包含跳板机，就把它写出来。如果别名在不同网络中行为不同，就需要分别列出，因为那是不同的有效连接。

有一个重要区别：**别名标识的是配置条目，目的地标识的是远程端点**。把两者混为一谈会导致错误批准。审查者可能同意代理连接到 `staging-api`，却仍然误判实际端点，因为这个别名包含过时或有条件的行为。

这也是别名应描述操作目的，而不是隐藏目的的原因。`prod-readonly`、`prod-deploy` 和 `prod-breakfix` 会让审查者在正确的位置停下来。一个名为 `prod` 的别名通过条件块选择用户、密钥和路由，虽然少输入几个字符，却会长期制造审查问题。

## Match 块是可执行的连接逻辑

`Match` 块不是一组主机的标签，而是 `ssh_config` 中一段有条件的配置区域，用来改变哪些指令会生效。条件可以包括请求的主机、原始主机、远程用户、本地用户、规范化状态、本地网络、请求的命令，以及 SSH 通过本地 shell 执行的 `exec` 命令。

这种能力很有用，但也意味着配置中可能包含一些行为，只查看附近的 `Host` 别名时完全看不出来。

看下面的配置：

```sshconfig
Host prod-api
    HostName api-01.prod.example.net
    User deploy
    ProxyJump prod-bastion

Match originalhost prod-api user root
    IdentityFile ~/.ssh/breakfix_ed25519
    IdentitiesOnly yes
```

如果只查看 `Host prod-api` 块，审查者看到的是以 `deploy` 身份建立的部署连接。如果调用者运行 `ssh -l root prod-api`，`Match originalhost prod-api user root` 条件可能会生效。身份设置是否最终生效，还取决于前面是否已经取得身份设置，以及该选项是否支持多个值。关键点很简单：连接因为命令行用户参数而改变了，而不是因为别名改变了。

对于代理使用，避免使用会授予更高权限身份或路由的 `Match user` 规则。按账户名组织行为看起来很整齐，但调用工具很容易通过 `-l`、`user@host` 或生成的命令改变账户。应把预期的 `User` 直接写入用途明确的别名。

更安全的版本会明确写出每种用途：

```sshconfig
Host prod-deploy
    HostName api-01.prod.example.net
    User deploy
    ProxyJump prod-bastion
    IdentityFile ~/.ssh/prod_deploy_ed25519
    IdentitiesOnly yes

Host prod-breakfix
    HostName api-01.prod.example.net
    User ops
    ProxyJump prod-bastion
    IdentityFile ~/.ssh/prod_breakfix_ed25519
    IdentitiesOnly yes
```

这并不会让特权访问变得无害，但会让请求的连接清晰可读。代理需要单独获得调用 `prod-breakfix` 的授权，不能仅仅通过改变用户名就意外进入这种行为。

在面向代理的配置中，`Match exec` 更不值得信任。SSH 评估配置时会通过本地 shell 运行命令。团队可能用它检测网络、查询资产清单或选择凭据。这会把一次 SSH 连接尝试变成依赖环境的本地代码路径。如果人工操作需要这种灵活性，应把这些别名放在代理无法调用的配置范围之外。连接审查不应要求人反向分析任意 shell 命令。

## 第一个匹配值可能让你的例外规则失效

最常见的 SSH 配置错误不是语法无效，而是一个有效配置块放在了已经设置该选项的更宽泛规则之后。

假设开发者为了要求生产环境经过跳板机，写了下面的配置：

```sshconfig
Host *
    User deploy
    ProxyJump dev-bastion

Host prod-*
    ProxyJump prod-bastion
```

这种预期很容易理解：`prod-*` 看起来更具体，所以应该优先。但 OpenSSH 不会按照具体程度对配置块排序。它按文件顺序处理配置，对于许多指令，第一个取得的值会生效。`prod-api` 会保留 `dev-bastion`，因为前面的 `Host *` 已经提供了 `ProxyJump`。

应把具体配置块放在前面：

```sshconfig
Host prod-*
    ProxyJump prod-bastion

Host *
    User deploy
    ServerAliveInterval 30
```

这不只是风格问题。经过错误的跳板机可能让会话进入错误的网络路径。宽泛的 `User deploy` 默认值可能让生产别名使用一个不该出现在该主机上的账户进行认证。宽泛的 `IdentityFile` 可能在预期凭据之前提供意外的凭据。

不要把第一个值规则理解得过于绝对。有些指令本来就接受多个值，`IdentityFile` 就是常见例子。多个配置身份可能被加入 SSH 考虑的集合中。这会产生另一种故障：范围很窄的别名虽然写了正确身份，但前面的配置或本地 SSH 代理仍可能让其他身份可用。

对于自动化连接，应让身份选择明确而单调：

```sshconfig
Host prod-deploy
    HostName api-01.prod.example.net
    User deploy
    IdentityFile ~/.ssh/prod_deploy_ed25519
    IdentitiesOnly yes
    ProxyJump prod-bastion
```

`IdentitiesOnly yes` 会告诉 OpenSSH，只使用 SSH 配置中设置的身份或命令行提供的身份，而不是随意尝试代理中可用的每个身份。它无法修复混乱的配置，但能阻止本地代理中无关的密钥成为意外候选项。

一种常见建议是把所有默认值放进 `Host *`，需要时再覆盖。对于保持连接的间隔等无害设置，这样做可以接受。但对于用户选择、路由、身份文件、端口、`ProxyCommand` 和主机改写，这不是好习惯。影响权限的默认值应尽量少。多写几行配置，总比解释代理为何通过错误路径到达正确机器便宜。

## ProxyJump 会创建另一个需要审查的连接

`ProxyJump` 并不是给目标连接添加装饰。SSH 会先连接跳板机，然后从跳板机建立通往目标的 TCP 转发路径。可以列出多个代理并按顺序经过。OpenSSH 手册还提醒，目标主机的配置通常不会应用到跳板机。

审查经常在这里失败。配置可能对 `prod-api` 写得很精确，却对 `prod-bastion` 完全没有说明。

```sshconfig
Host prod-api
    HostName 10.40.8.17
    User deploy
    ProxyJump prod-bastion

Host prod-bastion
    HostName bastion.prod.example.net
    User jump
    IdentityFile ~/.ssh/bastion_ed25519
    IdentitiesOnly yes
```

这里包含两次身份认证决定和两个主机身份：

1. SSH 将本地客户端认证到 `bastion.prod.example.net`，账户是 `jump`。
2. 跳板机把 TCP 流转发到 `10.40.8.17`。
3. SSH 通过这条转发流向目标进行认证，账户是 `deploy`。

跳板机可以使用不同的密钥、用户、端口和主机密钥记录。它也可能由通配符别名或条件块选择，而代理只请求了 `prod-api`，因此没有人检查这些设置。

使用 `ssh -G` 检查每一跳，而不只是最终别名：

```sh
ssh -G prod-api | egrep '^(hostname|user|port|proxyjump|identityfile|identitiesonly) '
ssh -G prod-bastion | egrep '^(hostname|user|port|identityfile|identitiesonly) '
```

输出每行包含一个选项。一次健康的审查可能看到这样的结果：

```text
hostname api-01.prod.example.net
user deploy
port 22
proxyjump prod-bastion
identityfile ~/.ssh/prod_deploy_ed25519
identitiesonly yes
```

然后单独验证跳板机。如果 `prod-api` 使用了类似 `edge-bastion,prod-bastion` 的逗号分隔链，就为两个别名都运行命令。一条链不是一条不透明的路线，而是多个独立的 SSH 客户端配置。

避免在 `Host *` 或 `Host *.internal` 这样的宽泛模式中定义通用跳板路由。它很容易捕获临时主机、预发布环境以及几个月后新增的别名。应在需要跳板路由的别名中定义它。如果许多生产别名都需要同一跳板机，可以使用仅为生产别名保留的窄范围模式，但不要随意复用该模式。

还要检查 `ProxyCommand`。OpenSSH 把 `ProxyJump` 和 `ProxyCommand` 视为相互竞争的选项：先指定的那个会阻止后续另一个选项生效。看起来使用跳板机的配置，实际可能运行的是更早的代理命令。两种设置都应被审查，因为它们都会改变网络连接从哪里发起，以及如何到达目标。

## 用户选择会改变获批的权限

远程账户属于请求操作的一部分。`deploy@api-01.prod.example.net` 和 `ops@api-01.prod.example.net` 可能到达同一台服务器，但它们拥有的权限、shell 配置、强制命令、sudo 权限和审计记录都可能不同。

SSH 可以从多个位置获得远程用户：命令中的 `user@host`、`ssh -l user host`、`User` 指令，或者在没有其他设置时使用本地用户名。只问“哪个主机”的配置审查是不完整的。

只要代理有明确工作，就应使用固定用户的别名：

```sshconfig
Host inventory-read
    HostName inventory.prod.example.net
    User inventory_ro
    IdentityFile ~/.ssh/inventory_ro_ed25519
    IdentitiesOnly yes

Host inventory-deploy
    HostName inventory.prod.example.net
    User deploy
    IdentityFile ~/.ssh/inventory_deploy_ed25519
    IdentitiesOnly yes
```

不要把通用主机名交给代理，然后假设提示或包装器会让它保持正确账户。命令生成器既可以生成 `ops@inventory.prod.example.net`，也可以生成 `deploy@inventory.prod.example.net`。配置应让获批路径最容易使用，让特权路径明显区别开来。

测试工具可能生成的不同形式：

```sh
ssh -G inventory-read | grep '^user '
ssh -G -l ops inventory-read | grep '^user '
ssh -G ops@inventory-read | grep '^user '
```

如果第二或第三条命令生成了你不希望代理使用的账户，就不能认为配置已经审查通过。应修复调用接口或隔离该别名。`Match user` 块也可能在这些变体之一中生效，所以必须显式测试，而不能只靠目测配置。

对于团队，应把 `root` 访问保留给单独命名的紧急别名，并排除在普通代理权限之外。把 `User root` 隐藏在 `Match` 条件后面，比直接写出来更糟。发生事故时，这会变成寻找配置的过程，而调用者有时还可以通过改变命令行参数来满足条件。

## IdentityFile 控制的不只是密钥路径

`IdentityFile` 看起来像一个文件选择设置。实际上，它决定 SSH 可以提供哪些凭据，而这又决定服务器会检查哪些远程授权规则。

常见的错误配置如下：

```sshconfig
Host *
    IdentityFile ~/.ssh/id_ed25519

Host prod-*
    IdentityFile ~/.ssh/prod_ed25519
```

操作员以为生产环境使用 `prod_ed25519`。但由于 `IdentityFile` 支持多个条目，SSH 可能会同时把两个身份文件加入候选列表。如果 SSH 代理中还有其他密钥，而没有设置 `IdentitiesOnly`，SSH 也可能提供这些密钥。有些服务器会很早拒绝重复尝试，有些服务器则会接受一个碰巧具有访问权限的非预期身份。两种结果都不能清晰表达实际意图。

面向代理的别名应说明单一的凭据用途，并限制提供的身份：

```sshconfig
Host reports-export
    HostName reports.prod.example.net
    User exporter
    IdentityFile ~/.ssh/reports_export_ed25519
    IdentitiesOnly yes
```

然后检查有效配置，不要只相信配置块：

```sh
ssh -G reports-export | grep '^identityfile '
ssh -G reports-export | grep '^identitiesonly '
```

出现多行 `identityfile` 并不一定错误。基于证书的部署和有计划的密钥轮换可能需要多个身份。但所有列出的身份都应属于同一个权限边界。如果一个别名可以提供个人管理员密钥、旧部署密钥和生产自动化密钥，那么它就没有清晰的授权逻辑。

不要通过把私钥放进代理的文件、环境变量、提示框或生成脚本来解决问题。这只是把配置歧义变成凭据暴露。Sallyport 将 SSH 密钥保存在加密保险库中，并通过辅助程序执行 SSH 操作，但它无法让含义模糊的 SSH 配置变得诚实。在操作员批准代理运行前，别名、路由、用户和身份用途仍然需要清楚。

密钥名称也遵循同一规则。`~/.ssh/id_ed25519` 这样的路径无法说明预期用途。`prod_deploy_ed25519` 更好，但配置仍需完整说明：哪些主机组、哪个用户、哪条路由会使用它。文件名有助于审查，但不能代替审查。

## 规范化可能让一个别名匹配两次

主机名规范化是 SSH 配置中最不容易被察觉的变化之一。启用 `CanonicalizeHostname yes` 后，OpenSSH 可以为未限定名称追加配置的域名后缀，解析它，然后使用新的目标名称重新处理配置。`Match canonical` 会在第二次处理中生效。`Match final` 会要求进行最终解析，并在该次处理中匹配；启用主机名规范化时，canonical 和 final 条件会同时匹配。

这种行为在大型内部网络中很有用，但也可能让一个短别名变成条件配置陷阱。

```sshconfig
CanonicalizeHostname yes
CanonicalDomains corp.example.net

Host build
    User ci

Match canonical host *.prod.example.net
    ProxyJump prod-bastion
```

调用者输入 `ssh build`。第一次处理看到的是 `build`。如果规范化把它解析为 `build.prod.example.net`，SSH 会重新解析配置，`Match canonical host *.prod.example.net` 块就可能设置生产路由。连接改变并不是因为调用者请求了另一个别名，而是因为 DNS 和第二次解析让后续规则看到的主机发生了变化。

OpenSSH 手册区分了两个经常被混为一谈的条件：

- `Match originalhost` 检查调用者提供的主机令牌。
- `Match host` 检查 `HostName` 替换或规范化后的目标。

当行为必须绑定到刻意命名的别名时使用 `originalhost`。当行为必须依赖实际解析后的目的地时使用 `host`。不要随意使用其中任何一个来改变权限。

规范化在跳板机环境中还有一个重要细节。对于使用 `ProxyCommand` 或 `ProxyJump` 的连接，`CanonicalizeHostname yes` 通常不会生效；`CanonicalizeHostname always` 才会把它扩展到代理连接。这意味着两个结构看起来相似的别名，可能仅仅因为其中一个有跳板机，就遵循不同的改写规则。

对于代理权限，通常最简单的策略就是最好的策略：对交给代理的别名禁用规范化，并使用明确的完整 `HostName`。如果环境确实需要规范化，就在代理运行的每个实际网络环境中测试所有允许的别名。不要假设短主机名在家庭网络、公司网络、VPN 和办公室 Wi-Fi 中解析方式相同。

`Match localnetwork` 也有同样的问题。OpenSSH 文档指出，本地网络地址不适合用于安全敏感的配置，尤其是在通过 DHCP 配置的网络中。它可以用于便利性设置，但不要用它决定代理是否获得更高权限的身份、是否跳过跳板机或是否可以到达生产环境。

## 在批准前渲染连接

`ssh -G` 是把 SSH 配置从文字变成可测试结果的最快方法。它会在处理主机和 Match 规则后，打印 SSH 将使用的配置，然后退出而不会建立连接。

应使用代理实际会采用的完整别名和参数运行它。不要只测试手动清理过的命令版本。

```sh
ssh -G prod-deploy | egrep '^(hostname|user|port|proxyjump|proxycommand|identityfile|identitiesonly|canonicalizehostname) '
```

认真审查时，应把完整输出作为固定测试文件，保存在负责该自动化的代码仓库中。使用明确指定的配置文件，避免测试悄悄继承开发者个人设置：

```sh
ssh -F ./agent-ssh-config -G prod-deploy \u003e ./testdata/prod-deploy.effective
```

配置发生变化时审查这个固定文件。有效的差异比较可以在变化进入批准流程前，发现 `hostname`、`user`、`proxyjump` 或身份列表的改变。完整配置差异可能有些嘈杂，但仍然比相信某人在拉取请求中粘贴的一个配置块更可靠。

只有在 `ssh -G` 显示预期值后，才使用 `ssh -vvv`。详细连接日志有助于确认 SSH 实际尝试了哪些主机密钥和认证方法，但它会把配置决定和网络噪声混在一起。`-G` 首先回答“这个配置写的是什么”，这正是排查可达性前需要先确定的问题。

有意测试不同变体：

```sh
ssh -F ./agent-ssh-config -G prod-deploy
ssh -F ./agent-ssh-config -G -l ops prod-deploy
ssh -F ./agent-ssh-config -G ops@prod-deploy
ssh -F ./agent-ssh-config -G prod-deploy.prod.example.net
```

结果应始终处于预期权限边界内，或者直接失败。如果用户覆盖会改变账户、完整主机名形式会跳过跳板机，或者短名称在规范化后获得不同身份，就找到了值得关闭的配置路径。

还要检查包含的文件。`Include` 可能让可见的 `~/.ssh/config` 只是一个入口，后面还有一整目录的机器生成、公司级或项目专用规则。应使用执行代理时相同的本地账户和配置路径检查有效输出。在自己的 shell 中测试，而代理使用另一个账户，会产生虚假的确定感。

## 让面向代理的 SSH 配置小而明确

适合自主编程代理的 SSH 配置，通常不是在个人 SSH 配置上加几条注释。个人配置会积累快捷方式、客户端例外、旧主机别名、本地网络行为、代理转发和曾经方便过的身份。代理需要的是一份范围窄的连接目录。

创建一个专用配置文件，只包含获批的别名及其必要的跳板机支持项。使用 `-F` 让代理或执行包装器指向该文件。每个别名只承担一项工作，并明确设置 `HostName`、`User`、路由和身份用途。除非能说明静态别名无法完成任务，否则不要加入条件逻辑。

一个简洁的例子如下：

```sshconfig
Host prod-bastion
    HostName bastion.prod.example.net
    User jump
    IdentityFile ~/.ssh/prod_bastion_ed25519
    IdentitiesOnly yes

Host prod-deploy
    HostName api-01.prod.example.net
    User deploy
    ProxyJump prod-bastion
    IdentityFile ~/.ssh/prod_deploy_ed25519
    IdentitiesOnly yes

Host staging-deploy
    HostName api-01.staging.example.net
    User deploy
    IdentityFile ~/.ssh/staging_deploy_ed25519
    IdentitiesOnly yes
```

这份配置有重复内容。很好。审查者无需在脑中执行通配符优先级和条件状态，就能看懂每个连接的含义。

不要把专用配置文件误认为策略引擎。它无法证明会话建立后执行的命令是安全的，但可以把传输连接变得足够具体，便于审查：这个别名、这个端点、这个用户、这条路由、这个身份。这是一个有用的边界。

Sallyport 的按会话授权和活动记录为操作员提供了人工控制点，也为代理操作留下记录，但 SSH 配置仍然提供操作背后的事实。如果 `prod-deploy` 可以变成多条不同的网络路径或多个账户，配置本身已经降低了批准的可靠性。

允许代理使用 SSH 别名前，先渲染配置，检查每个跳板机，并测试代理可能生成的命令行变体。如果有效连接曾让你感到意外一次，就应假设它会在最糟糕的时间再次让某个人感到意外。修复别名，直到它看起来像一个人真正能够理解并批准的请求。
