# SSH 连接多路复用：控制套接字的风险

SSH 连接多路复用可能会在创建连接的命令返回后，继续为主机保留一条已经完成身份验证的访问路径。这种行为是有意设计的。但当自动化任务、代理运行或部署作业声称已经完成时，人们很容易误解它的含义。

我见过团队调查一个看似已经关闭的维护窗口，结果发现本地 `ssh` master 仍持有活动传输，套接字仍接受新的客户端，后续命令无需再次进行交互式身份验证就复用了这条连接。没有发生什么神秘的事情。配置完全按照 OpenSSH 文档所描述的方式运行。团队把 shell 命令结束当成了访问结束。

需要区分已经完成的**客户端命令**和已经终止的**已验证传输**。多路复用会让这两个事件彼此分离。如果你通过 SSH 执行自主任务，清理流程和证据记录都必须承认这一点。

## 控制套接字可能比打开它的命令存活更久

SSH 多路复用使用一条长期运行的 SSH 连接，称为 master，为后续 SSH 客户端进程承载工作。后续客户端在 OpenSSH 文档中通常称为 slave。当 slave 通过控制套接字成功联系本地 master 时，不会重复执行普通的连接建立和用户身份验证流程。

典型配置看起来很普通：

```sshconfig
Host build-box
    HostName 192.0.2.44
    User deploy
    ControlMaster auto
    ControlPath ~/.ssh/cm/%C
    ControlPersist 20m
```

第一次运行 `ssh build-box` 时，会建立网络连接，并在 `~/.ssh/cm/` 下创建 Unix 域套接字。之后的 `ssh build-box 'uname -a'`、`scp` 和 `sftp` 等命令都可以使用这个套接字。设置 `ControlPersist 20m` 后，master 会在最后一个会话关闭后继续运行二十分钟。

因此，下面的流程完全正常：

1. 任务运行 `ssh build-box 'apply-change'`，并以状态码 0 退出。
2. 由于 `ControlPersist` 要求 master 继续可用，master 仍保持连接。
3. 十九分钟后，另一个本地进程运行 `ssh build-box 'read-status'`。
4. 该进程通过已经完成身份验证的传输打开一个通道。

后一个命令可能得到本地操作系统和套接字权限的授权，但不会让远程主机看到一次新的 SSH 身份验证。只查看最初登录记录的审查人员，很容易误以为活动早已结束。

OpenSSH 在 `ssh_config` 手册中说明，`ControlMaster` 允许多个会话使用同一条网络连接；同时说明，`ControlPersist` 会让 master 在后台保持打开状态。把这两点放在一起看，`ControlPersist` 不只是性能设置。它改变了本地进程可以在多长时间内请求通过已验证连接打开新通道。

对于在一台机器上工作的人员，这种权衡可能合理。对于范围限定在单个任务内的自动化，应明确指定负责人和停止动作。

## 一次身份验证事件不等于一次命令事件

团队经常混淆身份验证、连接、通道和命令。在 SSH 中，它们是不同的概念，而多路复用会把这种差异暴露出来。

最初的 master 会与服务器建立 TCP 连接，执行主机验证，协商加密算法，并验证账户身份。完成身份验证后，它可以打开 SSH 通道。shell 命令、交互式 shell、SFTP 传输、本地端口转发请求和远程端口转发请求，都会在这条传输上使用通道或与通道相关的请求。

如果新的本地 `ssh` 调用找到可用的控制套接字，就会通过套接字发送请求。master 决定是否打开所请求的通道。它不需要再次读取私钥，不需要再次请求代理确认，也不需要让服务器接收一次新的登录尝试。

这并不意味着 OpenSSH 会在套接字中保存可重复使用的密码。这样的说法不准确，也会导致错误分析。真正的安全问题是，一条已经完成身份验证的传输仍在运行，并且提供了一个本地接口，能够请求它执行操作。能够使用套接字的攻击者可能根本不需要原始凭据。

这个区别会改变事件调查的问题。问「密钥是否仍在磁盘上？」很重要，但这并不能确定访问是否仍然存在。还应询问：

- master 进程是否仍连接到远程目标？
- 其他本地进程能否访问它的控制套接字？
- master 是否接受了更多会话、文件传输或转发请求？
- 谁可以以套接字所有者身份运行，或穿过套接字所在目录？
- master 实际上何时退出？

任务运行器报告的命令退出代码本身无法回答这些问题。退出代码只描述它等待的子进程。

## 共享套接字会把本地进程边界变成访问边界

控制套接字是本地 Unix 域套接字。它所在的位置和权限决定哪些本地进程可以尝试与 master 通信。因此，即使远程主机别名看起来井然有序，宽泛的 `ControlPath` 仍可能把无关的工作连接在一起。

考虑一个采用以下配置的 CI runner 账户：

```sshconfig
Host *
    ControlMaster auto
    ControlPath /tmp/ssh-%r@%h:%p
    ControlPersist 1h
```

任务 A 以 `deploy` 身份连接到 `app.internal`。它创建 master，并在 `/tmp` 中创建套接字。任务 A 随后完成。使用同一本地账户运行的任务 B 连接到同一目标。如果任务 B 能找到并访问该套接字，就可以复用任务 A 的已验证传输。

这种做法很受欢迎，因为几乎不需要修改应用，就能加快重复命令的执行。但对于相互独立的任务，它是错误的。复用边界由主机、端口和用户决定，而不是由拥有授权的任务决定。共享 runner 账户会把这个错误变成日常的跨任务访问。

目录和套接字文件同样重要。在 Unix 系统中，进程需要目录的搜索权限才能访问路径。存放在私有所有者目录、目录权限为 0700 的套接字，比放在共享临时目录中能形成更清晰的边界。套接字本身的权限仍然重要，但不要把它们当成全部控制措施。

使用为任务创建、并由执行账户拥有的目录。无需依赖全局 SSH 配置，shell 包装器就可以做到这一点：

```sh
set -eu
run_id="release-4821"
cm_dir="$HOME/.ssh/task-control/$run_id"
mkdir -p "$cm_dir"
chmod 700 "$cm_dir"

socket="$cm_dir/%C"
ssh -o ControlMaster=auto \
    -o ControlPersist=5m \
    -o ControlPath="$socket" \
    build-box 'id && hostname'
```

`%C` 可以避免名称过长，并减少不同连接参数集合之间的冲突。OpenSSH 的 `ssh_config` 手册将其定义为根据连接详情生成的哈希。它很有用，但不会包含部署工单、代理会话或任务 ID。在这个例子中，父目录补上了缺少的边界。

不要仅仅因为方便，就把固定名称的控制套接字放在代码仓库 checkout、广泛可写的工作区或 `/tmp` 中。方便往往会让本地套接字意外变成共享能力。

## ControlPersist 是保留策略，不是清理方案

`ControlPersist` 会让 SSH 在客户端会话关闭后继续保留 master。它可以设置为 `yes`，让 master 无限期在后台运行，也可以设置为 `10m` 这样的时间值。两者都是保留策略。

客户端在清理前崩溃时，超时确实有帮助。但它不能证明任务结束和访问结束发生在同一时间。在超时期间，能够访问套接字的进程仍可以请求新的工作。如果部署任务只持续两分钟，将连接保留一小时尤其难以解释。

有些情况下，较短的超时是合理的。受控的自动化进程可能会连续执行多个命令，从保持热连接中受益。此时，应让超时时间短于预期的空闲间隔，将套接字隔离到本次运行中，并在成功路径上终止 master。这样，超时是故障备用机制，而不是正常的关闭方式。

`ControlPersist yes` 需要由一个有长期访问理由的负责人管理。管理员的交互式工作站可能有这样的理由。一次性任务没有。

另一个常见错误，是以为命令失败会关闭 master。shell 可能在远程命令失败后提前返回，而后台 master 仍继续运行。任务取消也可能产生同样结果。清理必须在成功、失败和中断后都执行，并记录关闭是否成功。

如果自动化使用 trap，应让清理范围保持明确，并验证目标。不要先盲目删除套接字路径。删除路径可能会让后续发现更加困难，而 master 进程和连接仍然继续存在。

```sh
cleanup() {
    ssh -S "$socket" -O exit build-box >/dev/null 2>&1 || true
    rmdir "$cm_dir" 2>/dev/null || true
}
trap cleanup EXIT HUP INT TERM
```

这个模式会先请求 master 退出，再尝试删除目录。如果 `ssh -O exit` 失败，应保留目录并进行调查，而不是抹掉证据。在生产代码中，将失败情况、进程 ID 和套接字路径写入任务记录。

## `exit` 和 `stop` 的操作含义不同

OpenSSH 通过 `ssh -O` 提供控制命令。操作员经常选错命令，因为这两个名称听起来都像清理操作。

`ssh -O check host` 会询问 master 是否正在运行；如果 master 响应，还会报告其进程 ID。`ssh -O exit host` 会请求 master 退出。对于已完成的任务，通常应使用 `exit`，因为它会结束可复用的传输。

`ssh -O stop host` 会让 master 停止接受新的多路复用会话，但现有会话会继续运行。当操作员需要排空活动工作时，这可能很有用。但它无法满足「任务完成时所有 SSH 访问都已结束」这样的要求。只要 master 仍有活动通道，网络连接就仍然存在。

需要诊断时，明确指定套接字路径，不要依赖用户当前的配置：

```sh
ssh -S "$socket" -O check build-box
# Master running (pid=41782)

ssh -S "$socket" -O exit build-box
# Exit request sent.

ssh -S "$socket" -O check build-box
# Control socket connect(...): No such file or directory
```

具体文字和错误信息会因平台及 OpenSSH 版本而异，因此应同时捕获标准输出和标准错误，不要把某一句话当成固定格式来解析。证据的结构更重要：成功的 check 能确认 master 处于活动状态，成功的 exit 表明终止请求已发送，之后失败的 check 则支持「没有控制套接字响应」这一判断。

还有一种需要处理的情况：master 崩溃后套接字可能仍然存在，而 PID 也可能在两次检查之间消失。过期路径不能证明访问仍然存在。相反，如果在检查前删除了路径，检查无响应也不能证明远程连接已经结束。删除任何内容前，先检查进程表和打开的 Unix 套接字。

在 macOS 上，`lsof` 通常是最快的本地检查工具：

```sh
lsof -nP -U | grep '/.ssh/task-control/'
ps -p 41782 -o pid=,ppid=,lstart=,etime=,command=
```

将输出保存到任务记录中。第一条命令把 Unix 套接字与进程关联起来。第二条提供父进程关系、启动时间、已运行时间和调用详情。把 `grep` 当作交互式辅助工具，而不是审计控制措施。真正的采集器应直接查询并保存相关记录。

## 端口转发会让空闲 master 的影响更大

看似空闲的 master 仍可能携带转发状态，或接受之后的转发请求。因此，只统计 shell 命令会得到不完整的图景。

本地转发会公开一个本地监听器，通过 SSH 连接发送流量。远程转发会要求服务器监听，并通过客户端把连接传回来。动态转发会创建 SOCKS 代理。根据会话和 master 的启动方式，每一种转发都可能比建立它的命令存活更久。

OpenSSH 手册记录了多路复用启用时用于转发请求的 `forward` 和 `cancel` 等控制命令。这些命令在操作上很有用，但也意味着，能够访问套接字的进程可能请求超出普通 shell 命令范围的网络路径，具体取决于服务器策略和 master 状态。

对于自动化任务，应把转发视为明确的例外。记录绑定地址、本地或远程端口、目标主机和端口，以及清理结果。不要让通用 SSH 包装器悄悄继承用户宽泛的 `Host *` 配置中的 `LocalForward`、`RemoteForward` 或 `DynamicForward` 指令。

在信任别名之前，先检查解析后的设置：

```sh
ssh -G build-box | grep -E '^(controlmaster|controlpath|controlpersist|localforward|remoteforward|dynamicforward) '
```

`ssh -G` 会在 OpenSSH 应用主机匹配和默认值后，打印生效的配置。这个检查能发现一个相当常见的问题：任务使用了简单的主机别名，但某个被包含的配置文件在远离任务自身配置的地方启用了多路复用或转发。不同版本可能显示更多字段。应保存完整的 `ssh -G` 输出，而不只是预期的几行。

远程系统可能只记录一个来源连接，却有多个转发的应用连接通过它。网络遥测、服务器 SSH 日志和任务日志分别回答故事的不同部分，彼此不能替代。

## 仅靠服务器日志无法还原本地决策过程

远程 SSH 服务器能看到传输，以及其日志配置所记录的通道和命令。它无法可靠地告诉你：哪个本地进程获准使用控制套接字，哪个任务拥有套接字目录，或者原始任务结束后是否有另一个本地进程复用了它。

这不是对服务器日志的批评。它们位于系统的另一个位置。`sshd` 可以记录身份验证和连接事件。强制命令包装器或审计子系统可能记录远程命令。这些记录仍然有用，但默认情况下未必能识别每一个本地多路复用客户端，因为服务器可能把所有通道都看作同一条已完成身份验证的传输的一部分。

在边界两侧都保留证据。一份有用的任务记录应包括：

- 完整解析后的客户端配置，排除秘密；
- 远程主机名、地址、账户、主机指纹验证结果和初始 master PID；
- 控制套接字路径、私有父目录，以及观察到的创建和退出时间；
- 每个请求的远程命令、传输或转发操作及其退出状态；
- 清理前的 `check` 结果、`exit` 结果，以及清理后的进程和套接字检查。

将任务标识符加入目录名称和日志记录，而不是加入所有任务共享的全局控制套接字路径。标识符可以连接本地事件，但不能因此把它当作独立的安全边界。

采集后对记录进行哈希或签名，有助于发现之后的编辑，但无法弥补缺失的事件。应在生命周期事件发生时收集它们。事件发生后再从 shell 历史拼出记录，证据力度很弱，尤其是在后台 master 和重试都参与其中时。

对于代理驱动的工作，应区分代理意图和实际执行的 SSH 操作。「部署版本 X」是意图。`ssh build-box 'sudo systemctl restart api'` 是操作。套接字复用记录说明这个操作是建立了新传输，还是沿用了已有传输。这些是不同的审计事实。

## 任务范围内的套接字让清理有明确负责人

对于短时间的自动化工作，最安全的模式很简单：每个任务使用独立的控制套接字目录，只允许该任务内部复用，并在任务报告完成前明确发送 `exit` 请求。

实用的包装器需要清晰的生命周期。它应使用严格权限创建目录，在首次连接前写入记录，使用同一个明确的 `ControlPath` 运行命令，关闭 master，验证结果，然后才删除空目录。如果无法验证清理，任务就应报告清理失败，即使远程命令成功。

下面的示例使用 `mktemp`，避免自行猜测唯一目录名称：

```sh
set -eu
base="$HOME/.ssh/task-control"
mkdir -p "$base"
chmod 700 "$base"
cm_dir=$(mktemp -d "$base/run.XXXXXX")
chmod 700 "$cm_dir"
socket="$cm_dir/%C"

finish() {
    status=$?
    ssh -S "$socket" -O exit build-box >>"$cm_dir/cleanup.log" 2>&1 || \
        printf '%s\n' 'master exit request failed' >>"$cm_dir/cleanup.log"
    ssh -S "$socket" -O check build-box >>"$cm_dir/cleanup.log" 2>&1 || true
    exit "$status"
}
trap finish EXIT HUP INT TERM

ssh -o ControlMaster=auto \
    -o ControlPersist=2m \
    -o ControlPath="$socket" \
    build-box 'deployctl apply release-4821'
```

较短的 `ControlPersist` 设置可以覆盖进程在 trap 尚未运行前死亡的情况。但不要把它理解为允许另一个任务复用 master。随机目录阻止了这种复用，因为第二个任务不知道也不会继承第一个任务的路径。

采用这个示例前，还要做一项修正。不要把敏感命令参数、环境值或复制的私密数据写入 `cleanup.log`。审计记录需要足够的信息来说明谁做了什么，但不能变成新的秘密存储。应在包装器边界处进行参数脱敏，因为此时你仍然知道它们的含义。

Sallyport 通过自带的无状态 `sp-ssh` helper 路由 SSH 操作，同时将 SSH 密钥保存在加密保险库中，不把密钥暴露给代理。这消除了一个常见的凭据处理问题，但团队仍应定义操作边界，并保留能够显示运行何时结束的记录。

## 全局便利设置会破坏任务边界

全局 `Host *` 配置段经常会为账户下的每个交互式 shell、脚本、代码仓库和自动化子进程开启多路复用。当同一账户运行的任务拥有不同审批或不同负责人时，这种范围过于宽泛。

设置可能来自 `Include` 文件、配置管理、开发者个人 dotfiles 或构建镜像。命令行也可以覆盖它。不要根据某一个可见配置文件推断当前行为。应使用任务实际采用的主机别名和用户上下文运行 `ssh -G`。

如果自动化环境无法保证控制路径是私有的，就为该操作禁用多路复用：

```sh
ssh -o ControlMaster=no \
    -o ControlPath=none \
    build-box 'maintenancectl status'
```

这样每次调用都会建立新连接并重新进行身份验证。对于高影响操作、少见的紧急访问，或跨越任务和信任边界的工作，这个代价值得承担。重复身份验证会提供更清晰的授权事件，也会让生命周期判断少很多歧义。

不要把 `ControlMaster=auto` 误认为进程只会复用自己的连接。`auto` 的意思是，客户端会尝试在配置路径中寻找 master，找不到时再创建一个。它可能找到谁的连接，由配置路径决定。

有些团队认为共享套接字没问题，因为所有任务都以同一个服务账户运行。只有在同一 Unix 身份下的每个任务都有权通过该账户访问所有相同目标，并且团队接受一个任务继承另一个任务的活动传输时，这个说法才成立。大多数成熟环境实际上并不希望这样。

## 清晰的任务结束需要传输关闭证据

不要在最后一个远程命令返回时就宣布 SSH 任务完成。只有在任务关闭了 master 并记录证据，或报告无法关闭时，才应宣布完成。

最终记录应告诉调查人员，之后的命令是否还有路径复用这条连接。记录需要包含任务身份、解析后的 SSH 配置、套接字路径、master 进程详情、活动记录、明确控制命令的结果，以及清理后的观察结果。对于独立于 SSH 继续运行的变更，例如服务重启或脱离终端的进程，还需要远程证据。

制定一套操作员在压力下也能执行的标准：隔离套接字，清理前检查它，请求 `exit`，确认没有 master 响应，并保存结果。与长时间的默认超时相比，这套流程更有用，因为它把对访问状态的假设变成了可检查的事实。

如果工作敏感到无法接受遗留的已验证传输，就不要用共享 master 优化它。建立新连接，执行操作，关闭连接，并保存记录。多花的几秒钟，比解释为什么任务本应结束后访问路径仍然存在，要便宜得多。
