Linux 基础体系 · 第 51/85 篇。示例面向现代主流 Linux 发行版;发行版差异、权限和生产风险会明确说明。

SSH 自动化与批量运维:Config、ProxyJump、ControlMaster 和密钥治理

SSH(Secure Shell)在批量运维中不只是“远程执行一条命令”。一次自动化操作通常同时涉及:

  1. 如何选择目标主机和参数;
  2. 如何通过跳板机建立网络路径;
  3. 如何验证服务器身份;
  4. 如何认证操作人员或自动化账户;
  5. 如何复用连接、控制并发;
  6. 如何处理非交互执行、超时和失败;
  7. 如何轮换、撤销和审计密钥。

~/.ssh/configProxyJumpControlMaster 和密钥治理分别解决这些问题中的不同层次。把它们混为一谈,容易出现“能手工登录,但脚本无法运行”“跳板机可达,但目标主机认证失败”“删除密钥后仍能登录”等故障。


一、先建立 SSH 自动化的整体模型

一次普通的 SSH 会话可以抽象为以下步骤:

本地 ssh 进程
  │
  ├─ 读取配置、命令行参数、环境
  │
  ├─ 解析目标地址和端口
  │
  ├─ 验证服务器主机密钥
  │
  ├─ 协商算法并建立加密传输
  │
  ├─ 使用用户密钥、密码或其他方法认证
  │
  ├─ 请求 shell、命令、子系统或端口转发
  │
  └─ 返回退出状态和输出

如果存在跳板机,网络路径变为:

ssh 客户端 ── SSH/转发连接 ── 跳板机 ── TCP 连接 ── 目标主机

这里有两个容易忽略的事实:

  • ProxyJump 解决的是网络可达路径,不代表跳板机上的账户可以登录目标主机。
  • 目标主机仍然会验证客户端身份,并且目标主机的主机密钥仍然需要被本地客户端验证。

因此,跳板机登录凭据和目标主机登录凭据可以完全不同。

SSH 的配置来源通常包括命令行选项、用户配置和系统配置。对同一个选项,OpenSSH 的配置处理具有“先获得的值优先”的特征,因此配置文件中的 Host 块顺序会影响结果。命令行选项通常优先于配置文件,但不能简单认为“文件最后一行一定覆盖前面”。

查看最终生效配置是排查问题的第一步:

ssh -G app-01.example.com

输出会包含类似内容:

hostname 10.20.30.11
user deploy
port 22
proxyjump bastion.example.com
identityfile ~/.ssh/id_ed25519

ssh -G 只展开配置,不建立连接。它适合确认 Host 匹配、别名、用户、端口、密钥和跳板设置是否如预期。

要进一步观察连接过程:

ssh -vvv app-01.example.com

常见日志含义包括:

  • Applying options for ...:某个 Host 块被应用;
  • Connecting to ...:正在连接某个地址;
  • Offering public key:客户端提供了某个公钥对应的私钥;
  • Server accepts key:服务端接受了该公钥;
  • Entering interactive session:认证完成并进入会话;
  • channel ... open failed:主连接可能成功,但某个会话、转发或子通道失败。

二、~/.ssh/config:把连接意图从命令行中提取出来

2.1 Host 是匹配别名,不一定是 DNS 主机名

一个基础配置可以这样写:

Host app-01
    HostName 10.20.30.11
    User deploy
    Port 22
    IdentityFile ~/.ssh/ops_ed25519
    IdentitiesOnly yes

执行:

ssh app-01

等价于在命令行中指定:

ssh \
  -l deploy \
  -i ~/.ssh/ops_ed25519 \
  -p 22 \
  10.20.30.11

其中:

  • Host app-01 是本地使用的匹配名;
  • HostName 是实际连接的地址;
  • User 是远端登录用户;
  • IdentityFile 指定候选私钥;
  • IdentitiesOnly yes 要求客户端主要使用配置中指定的身份,而不是把 Agent 中所有密钥都逐个尝试。

IdentitiesOnly yes 在共享 Agent、密钥较多或服务端限制认证尝试次数时尤其重要。否则客户端可能先尝试 Agent 中的多个密钥,服务端因为达到 MaxAuthTries 而断开,导致“明明指定了正确私钥,却认证失败”。

2.2 通配符和配置顺序

可以用通配符减少重复配置:

Host app-*
    User deploy
    IdentityFile ~/.ssh/ops_ed25519
    IdentitiesOnly yes
    ConnectTimeout 8
    ServerAliveInterval 30
    ServerAliveCountMax 3

Host app-01
    HostName 10.20.30.11

Host app-02
    HostName 10.20.30.12

Host app-* 会匹配 app-01app-02。但是不要把所有主机都写成:

Host *
    IdentityFile ~/.ssh/old_key

然后期待后面的配置无条件覆盖它。对于同一参数,前面已经得到的值可能继续生效。更稳妥的方式是:

  • 把通用设置放在 Host *
  • 把特定主机设置写在明确匹配的块中;
  • 使用 ssh -G 验证最终值;
  • 避免同一个参数在多个通配块中重复定义。

可以拆分配置:

Include ~/.ssh/conf.d/*.conf

目录权限应限制为当前用户可写,避免其他用户篡改连接目标或密钥路径:

chmod 700 ~/.ssh
chmod 600 ~/.ssh/config
chmod 700 ~/.ssh/conf.d
chmod 600 ~/.ssh/conf.d/*.conf

如果目录中没有匹配文件,某些 OpenSSH 版本对 Include 的处理可能产生提示;生产脚本应先确认目标版本行为,或只在文件存在时部署配置。

2.3 常用连接控制参数

Host batch-*
    User deploy
    ConnectTimeout 10
    ConnectionAttempts 2
    ServerAliveInterval 30
    ServerAliveCountMax 3
    BatchMode yes
    RequestTTY no

它们含义不同:

  • ConnectTimeout 10:限制建立连接和初始握手阶段的等待时间;不等于整个命令最多运行 10 秒。
  • ConnectionAttempts 2:连接失败时尝试次数。
  • ServerAliveInterval 30:客户端通过加密连接发送应用层保活消息。
  • ServerAliveCountMax 3:连续未收到响应达到次数后断开。它用于发现失联,不是性能优化。
  • BatchMode yes:禁止交互式密码提示等行为;适合自动化,但也会让缺少密钥时直接失败。
  • RequestTTY no:不请求伪终端。执行 sudo、需要终端控制的程序或交互式命令时,不能盲目设置为 no

ServerAlive* 和 TCP keepalive 不完全相同。前者由 SSH 协议层发出,能够帮助 SSH 判断对端是否仍然响应;它不能修复网络丢包、服务端负载过高或中间设备主动丢弃连接的问题。

2.4 用 Match 表达条件配置

现代 OpenSSH 支持按条件匹配,例如:

Host *
    User deploy

Match exec "test -f ~/.ssh/automation-mode"
    Host app-*
        BatchMode yes
        IdentityFile ~/.ssh/automation_ed25519
        IdentitiesOnly yes

Match exec 会执行本地命令,其结果影响配置匹配。它功能强,但也增加了配置理解和审计成本。对于生产环境,通常应优先使用清晰的别名和独立配置文件,而不是把复杂逻辑全部塞进 Match exec


三、跳板机:ProxyJump 的数据流和认证边界

3.1 ProxyJump 做了什么

配置:

Host bastion
    HostName bastion.example.com
    User jump

Host internal-*
    User deploy
    ProxyJump bastion
    IdentityFile ~/.ssh/ops_ed25519
    IdentitiesOnly yes

Host internal-01
    HostName 10.10.1.21

执行:

ssh internal-01

逻辑过程是:

  1. 本地客户端连接 bastion.example.com
  2. 使用 jump 身份认证跳板机;
  3. 通过 SSH 的 direct-tcpip 通道,请求跳板机连接 10.10.1.21:22
  4. 本地客户端在这个通道上继续与 internal-01 完成独立的 SSH 握手;
  5. 本地客户端验证目标主机的主机密钥;
  6. 本地客户端使用 deploy 对目标主机认证。

可以表示为:

sequenceDiagram
    participant C as 本地客户端
    participant B as 跳板机
    participant T as 目标主机

    C->>B: TCP 连接并认证 jump
    C->>B: 请求连接 T:22
    B->>T: 建立 TCP 连接
    C->>T: 通过转发通道进行 SSH 握手
    T-->>C: 返回目标主机密钥
    C->>T: 使用 deploy 密钥认证
    T-->>C: 建立 shell/命令通道

跳板机通常只能看到:

  • 到目标主机的 TCP 连接;
  • 加密后的 SSH 流量;
  • 连接时间、流量大小等元数据。

它通常不能看到目标 SSH 会话中的命令和文件内容,因为目标会话的加密终点是本地客户端和目标主机,而不是跳板机。

3.2 ProxyJumpProxyCommand 的关系

ProxyJump 是面向跳板场景的简化配置。等价功能也可以用:

Host internal-01
    HostName 10.10.1.21
    User deploy
    ProxyCommand ssh -W %h:%p bastion

-W %h:%p 要求跳板机把标准输入输出连接到目标主机的 TCP 地址和端口。

优先使用 ProxyJump 的原因是语义更清楚、配置更简单。ProxyCommand 仍适用于:

  • 需要调用专用代理程序;
  • 需要自定义连接前置逻辑;
  • 使用非 SSH 的代理协议;
  • 兼容旧环境。

ProxyCommand 会执行本地 shell 命令,涉及参数转义和注入风险。主机名来源不可信时,不能把未校验的字符串直接拼入 shell 命令。

3.3 多级跳板

Host bastion-1
    HostName bastion1.example.com
    User jump1

Host bastion-2
    HostName bastion2.example.com
    User jump2
    ProxyJump bastion-1

Host internal-01
    HostName 10.10.1.21
    User deploy
    ProxyJump bastion-2

多级跳板的每一层都可能失败:

  • 本地到 bastion-1 不通;
  • bastion-1 无法连接 bastion-2
  • bastion-2 无法连接目标;
  • 任一层的主机密钥验证失败;
  • 某一层账户认证失败;
  • 目标主机认证失败。

诊断时应逐层测试:

ssh -vvv bastion-1
ssh -vvv bastion-2
ssh -vvv internal-01

不要先假设“目标主机拒绝了密钥”。如果跳板机无法建立到目标的 TCP 连接,目标主机甚至还没有机会参与 SSH 认证。

3.4 跳板机上的密钥不一定需要存在

使用 ProxyJump 时,默认是本地客户端在完成目标认证,目标私钥不需要复制到跳板机。这比在跳板机上保存一份私钥更容易治理。

以下配置有明显不同:

ProxyJump bastion

与:

ssh bastion ssh internal-01

前者是本地客户端通过跳板机转发连接,后者是在跳板机上重新启动一个 SSH 客户端。后者可能需要:

  • 跳板机本地存在目标私钥;
  • 跳板机上的 known_hosts 包含目标主机;
  • 跳板机上的用户具备相应权限;
  • 跳板机允许执行该命令。

在密钥治理和审计上,前者通常更容易明确“谁使用了哪把密钥连接目标”。


四、ControlMaster:复用传输连接,而不是复用认证结果

4.1 多路复用的基本机制

SSH 每次新建连接都要经历 TCP 建立、密钥交换、服务器认证和用户认证。批量执行多个命令时,重复握手会增加延迟和服务端负载。

ControlMaster 允许一个 SSH 进程作为主连接,后续 SSH 进程通过 Unix 域套接字接入该主连接,建立新的逻辑会话。

配置示例:

Host app-*
    ControlMaster auto
    ControlPersist 60
    ControlPath ~/.ssh/cm/%C

先创建目录:

install -d -m 700 ~/.ssh/cm

其中:

  • ControlMaster auto:若控制连接存在则复用,否则创建主连接;
  • ControlPersist 60:主连接在最后一个会话结束后继续保持 60 秒;
  • ControlPath:控制套接字路径;
  • %C:由连接相关信息计算出的哈希标识,避免路径过长,也减少不同目标之间冲突。

状态可以通过:

ssh -O check app-01

查看,典型成功输出类似:

Master running (pid=12345)

关闭主连接:

ssh -O exit app-01

如果需要立即建立主连接:

ssh -MNf app-01

选项含义是:

  • -M:启用主连接;
  • -N:不执行远程命令;
  • -f:认证并建立连接后转入后台。

4.2 连接生命周期

可以把控制连接看成状态机:

不存在
  │ ssh app-01
  ▼
主连接建立并认证
  │
  ├─ 后续 ssh app-01 → 创建逻辑会话
  ├─ scp/sftp → 创建文件传输会话
  ├─ ssh -O check → 查询主连接
  └─ 最后会话结束
          │
          ├─ ControlPersist 到期 → 主连接退出
          └─ ssh -O exit → 立即退出

控制连接只复用底层加密传输和已建立的认证上下文。它不意味着每个远程命令共享 shell 状态:

ssh app-01 'cd /tmp'
ssh app-01 'pwd'

第二条命令通常不会输出 /tmp,因为两次命令请求不是同一个远程 shell。若需要共享状态,应在一次远程命令中完成:

ssh app-01 'cd /tmp && pwd'

4.3 复用连接与密钥轮换的边界

一个经常被忽略的风险是:控制连接建立后,后续会话可能不再重新执行用户认证。于是:

  1. 用户使用旧密钥建立主连接;
  2. 管理员从 authorized_keys 删除旧公钥;
  3. 已存在的 ControlMaster 仍然存活;
  4. 后续命令通过该主连接继续执行。

删除公钥只会影响新的认证,不会自动终止已经建立的 SSH 会话。密钥撤销流程必须同时检查并关闭相关控制连接:

ssh -O exit app-01

在更严格的自动化环境中,可以降低复用窗口:

Host production-*
    ControlMaster auto
    ControlPersist 10

或者对高敏感操作禁用复用:

ssh -o ControlMaster=no production-01 'command'

4.4 并发和控制套接字故障

多个进程同时首次连接同一主机时,可能竞争创建控制套接字。OpenSSH 会处理常见的主连接竞争,但脚本仍应考虑以下故障:

  • 控制套接字路径目录不存在;
  • 路径过长,Unix socket 无法创建;
  • 主连接已经失效但套接字文件残留;
  • 不同用户共享同一个 ControlPath
  • 本地目录权限允许其他用户替换套接字。

排查:

ssh -O check app-01
ls -l ~/.ssh/cm
ssh -vv app-01

不要使用所有用户都可写的目录保存控制套接字。控制套接字本身相当于“借用已认证连接”的入口,文件权限错误会扩大本地权限边界。


五、密钥治理:从生成、使用到撤销

5.1 密钥认证的实际过程

以公钥认证为例:

  1. 客户端向服务端声明自己拥有某个公钥;
  2. 服务端检查该公钥是否出现在目标账户的授权列表中;
  3. 服务端发送需要签名的数据;
  4. 客户端使用私钥签名;
  5. 服务端使用公钥验证签名;
  6. 验证成功后建立用户认证上下文。

私钥不会因为正常公钥认证而发送给服务器。必须保护的是私钥本身、Agent 中加载的私钥,以及能够访问控制套接字的本地权限。

生成一把现代常用的 Ed25519 密钥:

ssh-keygen -t ed25519 -f ~/.ssh/ops_ed25519 -C 'ops@example.com'

命令会要求设置口令。口令用于加密私钥文件,不能替代服务器端的公钥授权。检查公钥指纹:

ssh-keygen -lf ~/.ssh/ops_ed25519.pub

公钥通常应部署到目标账户:

ssh-copy-id -i ~/.ssh/ops_ed25519.pub deploy@app-01

该命令依赖目标主机允许密码或其他初始认证方式。自动化环境中,也可以通过受控配置管理系统写入 authorized_keys,而不是把私钥复制到目标主机。

现代 OpenSSH 通常优先使用 Ed25519。RSA 密钥仍可使用,但需要区分:

  • RSA 密钥类型;
  • ssh-rsa(SHA-1 签名算法)。

许多现代 OpenSSH 默认禁用基于 SHA-1 的 ssh-rsa 用户认证签名,但仍可能支持 RSA 密钥配合更强的 RSA-SHA2 签名。不要为了兼容旧设备就全局重新启用 SHA-1;应限定到单个主机,并制定升级计划。

5.2 ssh-agent 的作用与风险

ssh-agent 保存已解锁的私钥,并为客户端执行签名操作:

eval "$(ssh-agent -s)"
ssh-add -t 1h ~/.ssh/ops_ed25519
ssh-add -l

-t 1h 为加载的密钥设置生命周期。完成工作后可以删除:

ssh-add -d ~/.ssh/ops_ed25519

或清空 Agent 中的密钥:

ssh-add -D

Agent 的价值是避免每次连接都输入私钥口令,但它不是无风险的保险箱。能够访问 Agent 套接字的进程,可能请求 Agent 使用其中的密钥签名。应注意:

  • 不要在不可信主机上盲目启用 Agent 转发;
  • 不要把长期高权限密钥永久放在共享开发机上;
  • 用短时密钥、短生命周期和人工确认降低暴露窗口;
  • 使用 ssh-add -c 要求每次使用密钥时确认,具体交互方式依赖 Agent 实现。

Agent 转发配置:

ssh -A bastion

或:

Host bastion
    ForwardAgent yes

转发后,跳板机通常拿不到私钥文件,但跳板机上的高权限用户或被攻陷的进程可能利用转发的 Agent 发起签名请求。ProxyJump 通常不需要 Agent 转发,因为目标认证仍由本地客户端执行:

Host internal-*
    ProxyJump bastion
    IdentityFile ~/.ssh/ops_ed25519

这也是两者安全边界不同的关键原因。

5.3 服务器端 authorized_keys 与限制选项

最简单的授权行是:

ssh-ed25519 AAAAC3... ops@example.com

自动化专用密钥不应默认拥有完整交互 shell。可以使用限制选项,例如:

restrict,command="/usr/local/sbin/backup-wrapper" ssh-ed25519 AAAAC3... backup-job

restrict 是一组限制的组合,现代 OpenSSH 支持的具体行为应以目标服务器的 sshd 版本为准。常见单项限制包括:

no-pty,no-agent-forwarding,no-port-forwarding,no-X11-forwarding,command="/usr/local/sbin/backup-wrapper" ssh-ed25519 AAAAC3... backup-job

这些限制分别控制:

  • no-pty:禁止伪终端;
  • no-agent-forwarding:禁止请求 Agent 转发;
  • no-port-forwarding:禁止 TCP 转发;
  • no-X11-forwarding:禁止 X11 转发;
  • command="...":认证成功后执行固定命令,而不是用户提交的命令。

强制命令脚本应从环境变量 SSH_ORIGINAL_COMMAND 获取客户端原始请求,并严格解析允许的参数。例如:

#!/bin/sh
set -eu

case "${SSH_ORIGINAL_COMMAND-}" in
  "backup status")
    exec /usr/local/bin/backup status
    ;;
  "backup run")
    exec /usr/local/bin/backup run
    ;;
  *)
    echo "command not allowed" >&2
    exit  restricted
    ;;
esac

上例中的 exit restricted 不是合法的数字退出状态,应使用明确的整数,例如:

exit 1

完整写法:

#!/bin/sh
set -eu

case "${SSH_ORIGINAL_COMMAND-}" in
  "backup status")
    exec /usr/local/bin/backup status
    ;;
  "backup run")
    exec /usr/local/bin/backup run
    ;;
  *)
    echo "command not allowed" >&2
    exit 1
    ;;
esac

固定命令比简单使用 command="..." 更可控,但脚本仍需防止参数注入、路径替换和环境变量污染。

5.4 权限是功能的一部分

服务端常见权限要求:

chmod 700 ~/.ssh
chmod 600 ~/.ssh/authorized_keys

用户家目录也不能允许不可信用户随意写入或替换 .ssh 内容。服务端检查:

sshd -T | grep -E 'pubkeyauthentication|authorizedkeysfile|passwordauthentication|permitrootlogin'

修改 /etc/ssh/sshd_config 后先验证语法:

sudo sshd -t

验证通过后再重载:

sudo systemctl reload sshd

有些发行版服务名是 ssh 而不是 sshd

sudo systemctl reload ssh

不要在未保持现有 SSH 会话的情况下直接重启服务。正确的恢复步骤是:

  1. 保留一个已登录的管理会话;
  2. 修改配置;
  3. 执行 sshd -t
  4. 从第二个终端建立新连接验证;
  5. 确认新连接成功后再关闭旧会话。

六、不要把服务器主机密钥验证和用户密钥认证混淆

SSH 中有两类不同的密钥:

类型 证明什么 典型位置
服务器主机密钥 “我连接的确实是这台服务器” 本地 known_hosts
用户认证密钥 “我是被授权的这个用户” 服务端 authorized_keys

第一次连接时常见提示:

The authenticity of host 'app-01' can't be established.
Are you sure you want to continue connecting (yes/no/[fingerprint])?

自动化不应简单使用:

-o StrictHostKeyChecking=no
-o UserKnownHostsFile=/dev/null

这会放弃主机身份验证,使中间人攻击更容易被隐藏。更合理的流程是预先获取并审核主机指纹,写入受控的 known_hosts 文件:

ssh-keyscan -t ed25519 app-01.example.com

ssh-keyscan 只负责获取远端报告的公钥,不证明该公钥真实可信。应通过云平台控制台、带外管理、首次安装日志或其他可信渠道核对指纹后再保存。

批量任务可以使用专用 known_hosts 文件:

Host internal-*
    UserKnownHostsFile ~/.ssh/known_hosts.internal
    StrictHostKeyChecking yes

这样既不会污染默认文件,也不会为了自动化而关闭校验。若服务器重装后主机密钥变化,客户端可能出现:

WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!

此时不能直接执行 ssh-keygen -R 作为无条件修复。应先判断是:

  • 服务器确实重装并更换了密钥;
  • DNS 或地址指向发生变化;
  • 网络被劫持;
  • 主机密钥文件被篡改。

确认变更合法后再更新记录。


七、批量执行:非交互、并发和退出状态

7.1 一个可控的顺序执行脚本

假设 hosts.txt 内容为:

app-01
app-02
app-03

可以使用:

#!/usr/bin/env bash
set -u

while IFS= read -r host; do
    [ -z "$host" ] && continue
    case "$host" in
        \#*) continue ;;
    esac

    printf '==> %s\n' "$host"

    if ssh \
        -o BatchMode=yes \
        -o ConnectTimeout=10 \
        "$host" \
        'sudo systemctl is-active --quiet nginx'
    then
        printf '%s: nginx active\n' "$host"
    else
        rc=$?
        printf '%s: failed, ssh/remote exit=%d\n' "$host" "$rc" >&2
    fi
done < hosts.txt

这里有几个重要边界:

  • BatchMode=yes 确保任务不会卡在密码提示上;
  • ConnectTimeout 只限制连接阶段;
  • 远程命令的退出码通常会成为 ssh 的退出码;
  • SSH 自身失败也会返回非零状态,因此日志中应区分连接失败和远程命令失败;
  • sudo 可能要求密码或终端,自动化账户应使用受控的 sudoers 规则,而不是在脚本中保存密码。

例如只允许服务账户重启 nginx:

deploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart nginx, /usr/bin/systemctl is-active nginx

sudoers 修改后必须用:

sudo visudo

避免语法错误导致权限策略失效。

7.2 有界并发,而不是无限启动 SSH

串行执行安全但慢;无限并发会同时压垮:

  • 本地文件描述符;
  • 跳板机连接数;
  • 目标主机 MaxStartups
  • 网络和 CPU;
  • 应用自身的变更承受能力。

使用 GNU xargs 时可以限制并发数:

xargs -r -n 1 -P 4 -I {} \
  ssh -o BatchMode=yes -o ConnectTimeout=10 {} \
  'uname -r' < hosts.txt

-P 4 表示最多四个任务并行,不代表一定同时有四个成功连接。生产变更还需要考虑滚动批次、失败停止条件和幂等性。读取主机名时,不要把未经验证的复杂字符串直接拼成 shell 代码;主机清单应限制为已批准的别名或地址格式。

7.3 sshscpsftprsync

文件复制可以使用:

scp ./release.tar.gz app-01:/tmp/

scp 更适合简单复制。需要增量同步、保留属性或清晰控制删除行为时,常使用:

rsync -az --delete \
  -e 'ssh -o BatchMode=yes -o ConnectTimeout=10' \
  ./config/ app-01:/etc/myapp/

--delete 会删除目标端源目录中不存在的文件,风险很高。第一次执行应先使用:

rsync -azn --delete ./config/ app-01:/etc/myapp/

-n 是 dry-run,只显示计划变化。无论使用哪种工具,都应先确认目标路径、用户权限和备份策略。


八、自动化中的连接复用与批量算例

假设需要依次执行三个只读检查:

ssh app-01 'hostname'
ssh app-01 'uname -r'
ssh app-01 'systemctl is-active nginx'

未启用复用时,通常会产生三次完整连接和认证。启用:

Host app-*
    ControlMaster auto
    ControlPersist 60
    ControlPath ~/.ssh/cm/%C

第一次命令:

  1. 创建 TCP 连接;
  2. 验证主机密钥;
  3. 完成用户认证;
  4. 执行 hostname
  5. 命令结束,但主连接保留 60 秒。

第二、三次命令:

  1. 找到控制套接字;
  2. 连接到主 SSH 进程;
  3. 请求新的逻辑会话;
  4. 执行命令;
  5. 关闭逻辑会话,底层主连接继续存在。

如果第一条成功、第二条失败,第三条仍可能成功,因为逻辑会话的退出状态彼此独立。若主连接在第二条之前断开,后续客户端可能重新建立主连接;如果配置了 ControlMaster auto, 这种恢复行为通常是自动的。

启用复用并不等于应无限延长连接。ControlPersist yes 会让主连接长期存在,可能导致:

  • 旧认证上下文持续有效;
  • 网络策略变更不能立即生效;
  • 密钥撤销无法影响现有主连接;
  • 长时间空闲的控制套接字成为本地攻击面。

生产环境应根据任务窗口设置有限的 ControlPersist,并在敏感任务结束后显式执行 ssh -O exit


九、常见失败路径与诊断顺序

9.1 “Permission denied (publickey)”

诊断顺序:

ssh -G app-01 | grep -E '^(user|hostname|port|identityfile|identitiesonly)'
ssh -vvv app-01
ssh-add -l

重点确认:

  1. 连接的是不是正确的 HostName
  2. 登录用户是否正确;
  3. 客户端是否真的读取了目标私钥;
  4. 私钥是否已解锁或 Agent 是否可用;
  5. 公钥是否完整写入目标用户的 authorized_keys
  6. 目标用户家目录和 .ssh 权限是否过宽;
  7. 服务端是否启用了 PubkeyAuthentication
  8. Agent 中密钥太多导致服务端提前达到认证尝试上限。

如果日志只显示:

Offering public key: ...

却没有:

Server accepts key

通常说明服务端没有接受该公钥,可能是授权文件、用户、权限或服务端配置问题,而不是私钥口令错误。

9.2 “跳板机能登录,但目标主机失败”

分别测试:

ssh bastion
ssh internal-01

查看目标配置:

ssh -G internal-01 | grep -E '^(proxyjump|hostname|user|identityfile)'

确认跳板机具备到目标地址和端口的网络访问能力。需要注意,ProxyJump 的目标地址可能是私网地址,本地不必能直接解析或访问它;但跳板机必须能访问。

9.3 “第一次手工成功,批处理卡住”

常见原因:

  • 等待主机密钥确认;
  • 等待私钥口令;
  • 等待远程密码;
  • sudo 等待密码;
  • 远程命令需要 TTY;
  • DNS、网络连接无限等待。

自动化连接可以显式设置:

ssh \
  -o BatchMode=yes \
  -o ConnectTimeout=10 \
  -o StrictHostKeyChecking=yes \
  app-01 'command'

BatchMode=yes 不能替代正确的密钥、known_hosts 和 sudo 策略;它只负责让缺少交互条件的任务快速失败。

9.4 “删除密钥后仍能执行命令”

优先检查:

ssh -O check app-01

如果主连接仍在运行,关闭它:

ssh -O exit app-01

还应检查是否存在:

  • 其他用户的授权公钥;
  • 账户密码认证;
  • 证书认证;
  • 其他 authorized_keys 文件;
  • 通过跳板机或云控制台进入的替代路径;
  • 已建立的端口转发或远程会话。

密钥治理不能只关注某个 .pub 文件;它必须覆盖认证来源和已建立连接。


十、生产取舍:把连接便利性限制在可审计范围内

10.1 对人和对机器使用不同身份

人工管理员和自动化任务不应共用一把长期私钥。至少应区分:

  • 人员身份:可审计到具体用户,短时加载到 Agent;
  • 自动化身份:固定用途、最小权限、强制命令或受控 sudo;
  • 应急身份:平时禁用或离线保存,启用过程需要审计。

多人共用 deploy 账户和同一把私钥,会破坏归因能力。即使最终需要使用同一个 Unix 用户,也应尽量通过不同公钥、不同注释、不同命令限制和外部审计系统区分来源。

10.2 轮换不是“生成新钥匙并复制过去”

一个可恢复的轮换流程是:

  1. 生成新密钥,并为私钥设置口令;
  2. 通过现有授权路径把新公钥加入目标账户;
  3. 使用新密钥建立独立测试连接;
  4. 验证普通命令、跳板连接、文件传输和自动化任务;
  5. 切换配置中的 IdentityFile
  6. 观察日志和失败任务;
  7. 删除旧公钥;
  8. 关闭旧的 Agent 条目和 ControlMaster;
  9. 保存轮换记录和撤销时间。

关键是先添加后删除。若先删除旧密钥而新密钥尚未验证,可能把自己锁在主机外。

10.3 服务端加固必须与自动化兼容性一起验证

常见加固方向包括:

PasswordAuthentication no
PermitRootLogin no
PubkeyAuthentication yes
AllowGroups sshusers

这些设置的实际名称和默认值取决于 OpenSSH 版本及发行版配置。修改前应确认:

  • 是否还有依赖密码登录的应急流程;
  • 自动化账户是否有公钥;
  • root 登录是否确实可由受控普通账户加 sudo 替代;
  • 是否有云初始化、目录服务或集中认证覆盖本地设置;
  • sshd -t 是否通过;
  • 新连接是否可以成功建立。

Linux 内核安全文档关注的是更广泛的系统安全边界,而 SSH 的具体客户端和服务端行为应以目标 OpenSSH 版本的手册页为准。生产环境不能只依据某一发行版教程中的默认值推断所有系统都相同。


十一、一个可落地的最小配置

本地 ~/.ssh/config

Host bastion
    HostName bastion.example.com
    User jump
    IdentityFile ~/.ssh/jump_ed25519
    IdentitiesOnly yes
    UserKnownHostsFile ~/.ssh/known_hosts.bastion
    StrictHostKeyChecking yes

Host app-*
    User deploy
    ProxyJump bastion
    IdentityFile ~/.ssh/ops_ed25519
    IdentitiesOnly yes
    UserKnownHostsFile ~/.ssh/known_hosts.internal
    StrictHostKeyChecking yes
    BatchMode yes
    ConnectTimeout 10
    ServerAliveInterval 30
    ServerAliveCountMax 3
    ControlMaster auto
    ControlPersist 60
    ControlPath ~/.ssh/cm/%C

Host app-01
    HostName 10.10.1.21

Host app-02
    HostName 10.10.1.22

初始化本地权限:

install -d -m 700 ~/.ssh ~/.ssh/cm
chmod 600 ~/.ssh/config
chmod 600 ~/.ssh/jump_ed25519 ~/.ssh/ops_ed25519
chmod 644 ~/.ssh/known_hosts.bastion ~/.ssh/known_hosts.internal

验证配置但不执行命令:

ssh -G app-01

验证跳板和目标连接:

ssh -vvv app-01 'hostname && id'

验证复用状态:

ssh -O check app-01

任务结束后关闭控制连接:

ssh -O exit app-01

这套配置的关键性质是:

  • 跳板机只负责转发,不需要保存目标私钥;
  • 目标主机使用独立的用户密钥;
  • 主机身份校验没有被关闭;
  • 非交互任务不会等待密码;
  • 连接可以在短时间内复用;
  • 控制连接可以被显式检查和关闭;
  • 连接参数可以通过 ssh -G 复现和审计。

SSH 自动化的可靠性来自边界清晰:Config 管理连接意图,ProxyJump 管理网络路径,ControlMaster 管理传输连接生命周期,用户密钥和 authorized_keys 管理认证授权,known_hosts 管理服务器身份。批量运维脚本只有在这些边界都被验证后,才适合进入生产变更流程。


系列导航与关联阅读

官方资料

本文依据 Linux 内核、systemd 与主流发行版官方文档重新梳理;正文与实验由 WR BLOG 编写。