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

OpenSSH 完整指南:密钥、Agent、跳板、隧道和服务端加固

OpenSSH 是 Linux 上最常用的远程管理和安全传输组件。它通常包含以下几类程序:

  • ssh:建立交互式登录、执行远程命令、创建转发通道。
  • sshd:服务端守护进程,监听 SSH 端口并完成认证、授权和会话管理。
  • ssh-keygen:生成和管理密钥、主机密钥、证书。
  • ssh-agent:在本地进程间保存已解锁的私钥,并代替用户完成签名。
  • ssh-add:向 Agent 加载或删除密钥。
  • scpsftp:通过 SSH 传输文件。
  • ssh-keyscan:读取远程主机的主机公钥,常用于初始化或审计 known_hosts

SSH 不只是“远程登录工具”。它同时处理四个不同问题:

  1. 加密传输:防止会话内容被窃听或篡改。
  2. 主机认证:确认连接到的确实是目标主机,而不是中间人。
  3. 用户认证:确认连接者拥有某个账号的合法凭据。
  4. 会话与转发:在认证后运行命令、传输文件,或转发 TCP 连接。

理解这四层的边界,是正确配置密钥、Agent、跳板和隧道的前提。


一、SSH 连接到底发生了什么

一次典型的 SSH 连接可以抽象为:

客户端 ssh
   │
   │ 1. TCP 连接
   ▼
服务端 sshd
   │
   │ 2. 协议版本与算法协商
   │ 3. 密钥交换,建立会话密钥
   │ 4. 主机公钥认证
   │ 5. 用户认证
   │ 6. 创建会话或转发通道
   ▼
远程 shell / 命令 / SFTP / TCP 转发

1. TCP 连接不是 SSH 认证

执行:

ssh admin@server.example.com

时,客户端首先连接服务端的 TCP 端口,默认是 22。这一阶段只能说明:

某个地址上的某个端口接受了 TCP 连接。

它不能说明连接对象就是目标主机。攻击者可以在网络中伪装一个同样监听 22 端口的主机。

2. 密钥交换建立会话加密

SSH 会先协商密钥交换算法、主机密钥算法、对称加密算法和完整性保护算法。现代 OpenSSH 通常使用基于 Diffie–Hellman 或椭圆曲线的密钥交换。

以 Diffie–Hellman 的抽象形式说明:

  • 双方公开一个大素数 pp 和生成元 gg
  • 客户端选择私密随机数 aa,发送:

A=gamodpA = g^a \bmod p

  • 服务端选择私密随机数 bb,发送:

B=gbmodpB = g^b \bmod p

  • 客户端计算:

K=Bamodp=gabmodpK = B^a \bmod p = g^{ab} \bmod p

  • 服务端计算:

K=Abmodp=gabmodpK = A^b \bmod p = g^{ab} \bmod p

双方得到相同的共享秘密 KK,但窃听者只看到 ppggAABB,不能直接求出 aabb

现代 SSH 不会直接把这个共享秘密当作应用层密钥,而是结合双方的初始报文、会话标识和协商结果派生出多个密钥,用于加密和完整性保护。

关键边界是:

  • 密钥交换解决“如何建立加密会话”;
  • 它本身不充分解决“你连接的是谁”;
  • 主机公钥签名把密钥交换绑定到某个服务端身份。

3. 主机认证:known_hosts 的作用

服务端有自己的主机密钥,通常位于:

/etc/ssh/ssh_host_ed25519_key
/etc/ssh/ssh_host_ed25519_key.pub

客户端首次连接某主机时,可能看到:

The authenticity of host 'server.example.com' can't be established.
ED25519 key fingerprint is SHA256:....
Are you sure you want to continue connecting (yes/no/[fingerprint])?

如果输入 yes,客户端会把该主机的公钥或其指纹写入:

~/.ssh/known_hosts

以后再次连接时,客户端会比较服务端提供的主机公钥和本地记录:

  • 相同:继续连接;
  • 不同:报错并阻止连接;
  • 没有记录:视为首次见到,通常需要用户确认。

典型报错:

WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!

这可能表示:

  1. 服务器确实重装或更换了主机密钥;
  2. DNS 或 IP 指向了另一台机器;
  3. 负载均衡后端没有统一主机密钥;
  4. 网络中存在中间人攻击。

不能机械地使用:

ssh-keygen -R server.example.com

来消除告警。正确流程应是通过可信控制台、云平台元数据、配置管理系统或带外渠道核对新的指纹,再更新 known_hosts

查看某条主机记录:

ssh-keygen -F server.example.com

以指纹形式查看公钥:

ssh-keygen -lf ~/.ssh/known_hosts

服务端管理员可以在控制台上查看主机公钥指纹:

ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub

4. 用户认证和主机认证是两件事

即使客户端确认了服务端身份,仍然需要证明用户有权登录目标账号。用户认证可能使用:

  • 公钥认证;
  • 密码认证;
  • 键盘交互认证,常与 PAM、OTP 或 MFA 集成;
  • GSSAPI/Kerberos;
  • FIDO/U2F 硬件支持的 sk-* 密钥;
  • 证书认证。

因此,下面两个问题必须分别回答:

  • 主机认证:我连接的是不是正确服务器?
  • 用户认证:我是否有权作为这个账号登录?

禁用密码认证不会替代主机认证;预置 known_hosts 也不会替代用户授权。


二、用户密钥:从生成、安装到认证成功

1. 公钥认证的完整过程

假设用户在客户端生成一对密钥:

私钥:~/.ssh/id_ed25519
公钥:~/.ssh/id_ed25519.pub

公钥被安装到服务端账号的:

~/.ssh/authorized_keys

连接时,流程大致如下:

  1. 客户端请求使用某个公钥认证;
  2. 服务端检查目标账号的 authorized_keys 是否包含该公钥;
  3. 服务端发送一个待签名的数据摘要;
  4. 客户端使用私钥对该数据签名;
  5. 服务端使用已保存的公钥验证签名;
  6. 验证成功后创建登录会话。

私钥不会发送到服务端。服务端只保存公钥,通常也无法从公钥反推出私钥。

可以把认证条件写成:

登录成功=私钥签名有效公钥被目标账号授权服务端策略允许该认证方法\text{登录成功} = \text{私钥签名有效} \land \text{公钥被目标账号授权} \land \text{服务端策略允许该认证方法}

这三个条件缺一不可。例如:

  • 私钥正确,但公钥放在了错误用户的 authorized_keys 中:失败;
  • 公钥正确,但 PubkeyAuthentication no:失败;
  • 公钥和配置都正确,但私钥文件不可读:客户端无法签名,失败;
  • 签名成功,但账号被锁定、Shell 为 /sbin/nologin 或 PAM 拒绝:仍可能失败。

2. 生成现代用户密钥

推荐首先使用 Ed25519:

ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519

交互过程通常是:

Enter passphrase (empty for no passphrase):
Enter same passphrase again:

生成后:

ls -l ~/.ssh/id_ed25519*

可能得到:

-rw------- 1 user user  464 ... id_ed25519
-rw-r--r-- 1 user user  104 ... id_ed25519.pub

私钥应设置为仅所有者可读写:

chmod 700 ~/.ssh
chmod 600 ~/.ssh/id_ed25519
chmod 644 ~/.ssh/id_ed25519.pub

私钥口令和 Linux 文件权限解决的是不同问题:

  • 文件权限防止同机其他普通用户直接读取私钥;
  • 私钥口令防止私钥文件泄露后被立即使用。

没有口令的私钥一旦泄露,攻击者可以直接尝试使用它。

如果需要兼容较旧的软件,可以使用 RSA:

ssh-keygen -t rsa -b 3072 -f ~/.ssh/id_rsa

现代系统通常不需要为了兼容性默认使用 DSA 或短 RSA 密钥。具体可用算法仍取决于客户端和服务端版本,不能只根据文件扩展名判断安全性。

3. 把公钥安装到服务端

最简单的方式是:

ssh-copy-id -i ~/.ssh/id_ed25519.pub admin@server.example.com

它通常会先使用密码登录,再把公钥追加到远程账号的 ~/.ssh/authorized_keys

没有 ssh-copy-id 时,也可以手工执行:

cat ~/.ssh/id_ed25519.pub | \
ssh admin@server.example.com \
'umask 077; mkdir -p ~/.ssh; cat >> ~/.ssh/authorized_keys'

这条命令的关键点:

  • umask 077 使新建目录和文件默认只允许用户访问;
  • mkdir -p 创建 ~/.ssh
  • >> 是追加,而不是覆盖已有授权;
  • 远程命令仍依赖一次已有的登录方式,例如密码或另一把密钥。

服务端检查:

stat -c '%A %U:%G %n' ~/.ssh ~/.ssh/authorized_keys

常见安全权限是:

drwx------ user:user /home/user/.ssh
-rw------- user:user /home/user/.ssh/authorized_keys

服务端的 sshd 默认会执行严格权限检查,相关行为通常受 StrictModes 影响。以下情况可能导致公钥被忽略:

  • ~/.ssh 对组或其他用户可写;
  • authorized_keys 对其他用户可写;
  • 家目录由其他用户可写;
  • 文件所有者不是登录用户;
  • SELinux 标签不正确。

修复传统权限:

chmod 700 ~/.ssh
chmod 600 ~/.ssh/authorized_keys
chown -R user:user ~/.ssh

在启用 SELinux 的系统上,如果文件是通过异常路径或恢复操作创建的,还可能需要:

restorecon -Rv ~/.ssh

不能把 chmod 777 当作排错手段。它可能暂时绕过部分权限问题,却直接扩大了私钥授权文件被篡改的风险。

4. 用 authorized_keys 限制密钥能力

authorized_keys 不只接受一个裸公钥,还可以带限制选项:

restrict,command="/usr/local/bin/backup-receiver",no-pty ssh-ed25519 AAAA... backup-key

常见选项包括:

  • command="...":强制执行指定命令;
  • no-pty:禁止分配终端;
  • no-agent-forwarding:禁止 Agent 转发;
  • no-port-forwarding:禁止 TCP 转发;
  • no-X11-forwarding:禁止 X11 转发;
  • from="192.0.2.0/24":限制来源地址。

例如,备份系统只需要接收固定协议,不需要 Shell:

restrict,command="/usr/local/bin/backup-receiver" ssh-ed25519 AAAA... backup-key

强制命令脚本应检查环境变量和参数,不应直接把 $SSH_ORIGINAL_COMMAND 拼接进 Shell。否则,攻击者可能通过命令注入突破限制。

5. 使用指定私钥测试

客户端有多把密钥时,可以明确指定:

ssh -i ~/.ssh/id_ed25519 -o IdentitiesOnly=yes user@server.example.com

IdentitiesOnly=yes 的作用是:

只尝试配置中明确指定的身份,而不是把 Agent 中所有密钥都发送给服务端尝试。

这能避免因密钥过多触发:

Too many authentication failures

也能减少把不相关公钥暴露给远程服务端的机会。


三、SSH Agent:缓存签名能力,而不是“存放密码”

1. Agent 解决了什么问题

如果私钥设置了口令,每次 SSH 连接都要求输入口令会很麻烦。ssh-agent 提供一个本地 UNIX socket:

SSH_AUTH_SOCK=/tmp/ssh-XXXXXX/agent.<pid>

客户端 ssh 连接服务端时,可以向 Agent 请求:

请使用已加载的某个私钥,对这段认证数据签名。

Agent 返回签名结果,但通常不把私钥交给 ssh 客户端。私钥一直保留在 Agent 的进程内存中。

启动 Agent:

eval "$(ssh-agent -s)"

可能输出:

Agent pid 12345

加载私钥:

ssh-add ~/.ssh/id_ed25519

检查已加载的公钥:

ssh-add -l

可能输出:

256 SHA256:xxxx... user@work (ED25519)

显示公钥内容:

ssh-add -L

删除一把密钥:

ssh-add -d ~/.ssh/id_ed25519

删除全部密钥:

ssh-add -D

2. Agent 的生命周期和风险

Agent 中的密钥通常会一直存在,直到:

  • 执行 ssh-add -D
  • Agent 退出;
  • 机器关机;
  • 密钥达到生命周期限制;
  • 用户或管理员终止 Agent。

可以设置自动过期:

ssh-add -t 1h ~/.ssh/id_ed25519

这表示该密钥在 Agent 中最多保留一小时。也可以要求每次签名都确认:

ssh-add -c ~/.ssh/id_ed25519

不同 OpenSSH 版本对更细粒度的 Agent 约束支持不同,例如目的地约束、智能卡密钥限制等,部署前应以本机 ssh-add(1) 手册为准。

Agent 缓存的是使用私钥签名的能力。如果攻击者能够控制本地用户会话或访问 SSH_AUTH_SOCK,可能请求 Agent 对任意认证数据签名。因此:

  • 不要让不可信进程继承 SSH_AUTH_SOCK
  • 不要把 Agent socket 放在其他用户可写的位置;
  • 不要在共享跳板机上长期运行包含高权限密钥的 Agent;
  • ssh-add -t 缩短高权限密钥的缓存时间;
  • 不使用时执行 ssh-add -D 或退出 Agent。

3. Agent 转发:私钥不离开本地,但远程主机可使用 Agent

执行:

ssh -A user@jump.example.com

会把本地 Agent 的访问接口转发到跳板机。跳板机上的 ssh 可以使用本地 Agent 中的密钥继续连接第三台机器:

本地电脑
  └── Agent
       ▲
       │ Agent forwarding
       ▼
跳板机
       │
       └── ssh db.internal

重要事实是:

私钥通常不会复制到跳板机,但跳板机上的进程可以通过转发的 Agent 请求签名。

如果跳板机被攻击,攻击者可能在 Agent 转发仍有效期间冒用你的身份连接其他主机。攻击者不一定能导出私钥,但“不能导出私钥”不等于“不能使用密钥”。

因此,默认不要使用全局 ForwardAgent yes。可以只对受信任主机开启:

Host jump.example.com
    ForwardAgent yes

Host *
    ForwardAgent no

更安全的替代方案通常是 ProxyJump,因为它转发 SSH 的 TCP 连接,而不是把 Agent 能力暴露给跳板机。


四、SSH 客户端配置:避免重复输入和误用密钥

客户端配置位于:

~/.ssh/config

系统级配置通常位于:

/etc/ssh/ssh_config

配置文件权限:

chmod 700 ~/.ssh
chmod 600 ~/.ssh/config

一个可维护的配置示例:

Host prod-web
    HostName web01.prod.example.com
    User deploy
    Port 22
    IdentityFile ~/.ssh/id_ed25519_prod
    IdentitiesOnly yes
    ForwardAgent no
    ServerAliveInterval 30
    ServerAliveCountMax 3

Host prod-db
    HostName 10.20.30.15
    User dba
    ProxyJump prod-bastion
    IdentityFile ~/.ssh/id_ed25519_prod
    IdentitiesOnly yes
    ForwardAgent no

Host prod-bastion
    HostName bastion.prod.example.com
    User ops
    IdentityFile ~/.ssh/id_ed25519_prod
    IdentitiesOnly yes

测试最终生效配置:

ssh -G prod-db

查看连接调试过程:

ssh -vvv prod-db

ssh -G 显示客户端经过匹配和合并后的配置,比只查看配置文件更可靠。ssh -vvv 可观察:

  • 使用了哪些配置;
  • 解析到哪个地址;
  • 尝试了哪些主机密钥;
  • 使用了哪些用户认证方法;
  • 是否建立了跳板连接;
  • 失败发生在网络、主机认证还是用户认证阶段。

五、跳板机:ProxyJumpProxyCommand 与 Agent 转发的区别

1. 跳板机解决网络可达性问题

假设拓扑如下:

笔记本 ──可达──> bastion.example.com ──可达──> db.internal
笔记本 ──不可达──────────────────────> db.internal

数据库只允许跳板机所在网络访问。传统做法是先登录跳板机,再从跳板机执行 SSH;现代 OpenSSH 更推荐:

ssh -J ops@bastion.example.com dba@db.internal

或者:

Host db.internal
    User dba
    ProxyJump ops@bastion.example.com

2. ProxyJump 的数据流

ProxyJump 通常通过跳板机上的 direct-tcpip 通道建立到目标主机的 TCP 连接:

本地 ssh
  │
  │ SSH 连接 A:本地 → 跳板机
  ▼
跳板机 sshd
  │
  │ direct-tcpip:跳板机 → 目标 22 端口
  ▼
目标 sshd

目标主机的 SSH 会话仍然由本地客户端完成。跳板机主要提供网络转发,不需要在跳板机上执行第二次交互式 ssh

这带来两个重要效果:

  1. 目标主机看到的 SSH 用户认证来自本地客户端;
  2. 不需要开启 Agent 转发,也不需要把私钥复制到跳板机。

完整命令:

ssh -J ops@bastion.example.com \
    -i ~/.ssh/id_ed25519_prod \
    -o IdentitiesOnly=yes \
    dba@db.internal

3. 跳板机必须允许什么

为了让 ProxyJump 工作,跳板机的 sshd 通常需要允许 TCP 转发:

AllowTcpForwarding yes

如果服务端配置为:

AllowTcpForwarding no

常见失败表现是跳板连接能够建立,但目标连接失败。服务端日志可能包含:

channel open failed: administratively prohibited

如果跳板机只应作为跳转节点,而不允许用户登录 Shell,可以使用:

AllowTcpForwarding local
PermitTTY no
ForceCommand /bin/false

不过 ForceCommand 会影响所有通过该账号建立的会话,需要结合实际认证方式和 authorized_keys 限制设计。更细粒度的限制可以放在特定公钥上:

restrict,port-forwarding ssh-ed25519 AAAA... jump-key

具体可用选项应以当前 OpenSSH 的 authorized_keys(5) 为准。

4. ProxyJumpProxyCommand -W

下面是传统等价写法:

ssh -o ProxyCommand="ssh -W %h:%p ops@bastion.example.com" dba@db.internal

-W %h:%p 要求跳板机上的 SSH 客户端请求跳板机服务端打开到目标主机和端口的通道。ProxyJump 是更直观、可组合、较不容易发生 Shell 引号错误的形式。

多级跳板:

ssh -J ops@bastion-a,ops@bastion-b dba@db.internal

或:

Host db.internal
    ProxyJump bastion-a,bastion-b

每一级跳板都必须能到达下一级目标,并且各自的主机认证、用户认证和转发策略都必须通过。

5. 跳板机不等于安全边界

跳板机可以减少暴露面,但它仍是高价值目标:

  • 它能看到连接元数据;
  • 它可能被用作内网扫描和横向移动起点;
  • 如果开启 Agent 转发,入侵者可能滥用 Agent;
  • 如果允许任意 TCP 转发,用户可能把它变成内网代理。

生产环境通常需要结合:

  • 仅允许管理来源地址;
  • 仅允许必要账号;
  • 禁止密码认证;
  • 限制 TCP 转发目标;
  • 记录登录、转发和权限使用审计;
  • 定期轮换或撤销密钥;
  • 使用 SELinux、AppArmor 和主机审计策略。

六、SSH 隧道:三种方向和一个重要判别方法

SSH 隧道本质上是:

在已认证的 SSH 连接中创建一个或多个 channel,把某一端接收的字节流转发到另一端的 TCP 连接。

它不等同于 VPN。SSH 隧道通常只转发 TCP,不能自动承载任意 IP 协议,也不会自动改变应用层认证和授权。

1. 本地端口转发:-L

语法:

ssh -L [本地绑定地址:]本地端口:目标地址:目标端口 user@ssh-server

示例:

ssh -N -L 127.0.0.1:15432:db.internal:5432 ops@bastion.example.com

数据流:

本地客户端 → 127.0.0.1:15432
             │
             ▼
       本地 ssh 客户端
             │ SSH channel
             ▼
       bastion sshd
             │ TCP connect
             ▼
       db.internal:5432

这里的 db.internal 是由跳板机解析和连接的,不是由本地机器连接的。

-N 表示不执行远程命令,只建立转发。另开终端访问:

psql -h 127.0.0.1 -p 15432 -U appuser appdb

如果只绑定 127.0.0.1,同一台机器上的其他主机不能直接访问该端口。不要随意写成:

-L 0.0.0.0:15432:db.internal:5432

因为这会使本地转发端口监听所有本地接口。若防火墙未限制,局域网其他主机可能访问到这个端口。

后台运行:

ssh -fN \
  -o ExitOnForwardFailure=yes \
  -L 127.0.0.1:15432:db.internal:5432 \
  ops@bastion.example.com

ExitOnForwardFailure=yes 很重要:如果本地端口绑定失败或转发初始化失败,SSH 不应静默进入后台。

验证监听:

ss -ltnp | grep 15432

测试端口:

nc -vz 127.0.0.1 15432

2. 远程端口转发:-R

语法:

ssh -R [远程绑定地址:]远程端口:目标地址:目标端口 user@ssh-server

示例:

ssh -N -R 127.0.0.1:18080:127.0.0.1:8080 ops@bastion.example.com

数据流方向是:

bastion:127.0.0.1:18080
       │
       ▼
跳板机 sshd ── SSH channel ──> 本地 ssh 客户端
                                      │
                                      ▼
                            本地 127.0.0.1:8080

这里远程端口的监听发生在服务端。服务端有人连接 127.0.0.1:18080 后,连接会经 SSH 通道回到客户端,再由客户端连接自己的 127.0.0.1:8080

默认情况下,远程转发通常只绑定服务端回环地址。若配置:

ssh -N -R 0.0.0.0:18080:127.0.0.1:8080 ops@bastion.example.com

是否真的能从外部访问,还取决于服务端:

GatewayPorts
  • GatewayPorts no:通常强制远程转发只监听回环地址;
  • GatewayPorts yes:允许客户端请求非回环绑定;
  • GatewayPorts clientspecified:尊重客户端指定的绑定地址。

开放远程监听会把本地服务暴露给远端网络,应同时配置防火墙和访问控制。更安全的做法是使用明确的回环地址,并由服务端本地反向代理或受控程序访问。

3. 动态转发:-D

语法:

ssh -N -D [本地绑定地址:]本地端口 user@ssh-server

示例:

ssh -N -D 127.0.0.1:1080 ops@bastion.example.com

这会创建 SOCKS 代理。支持 SOCKS 的应用可以把目标连接请求交给 SSH,再由跳板机发起连接。

浏览器或命令行工具需要显式配置代理,例如:

curl --socks5-hostname 127.0.0.1:1080 https://internal.example.com/

--socks5-hostnamehostname 很关键,它让域名解析也通过 SOCKS 代理完成。若使用普通 --socks5,域名可能先在本地解析,导致:

  • 内部域名无法解析;
  • DNS 请求泄露到本地网络;
  • 解析结果与跳板机网络视图不一致。

动态转发不是自动代理所有系统流量。未配置 SOCKS 的程序仍会直接连接网络。

4. -L-R-D 的判别

可以用“谁监听、谁发起最终 TCP 连接”来判断:

类型 监听端 最终目标连接通常由谁发起
-L SSH 客户端所在机器 SSH 服务端所在机器
-R SSH 服务端所在机器 SSH 客户端所在机器
-D SSH 客户端所在机器 根据 SOCKS 请求,通常由 SSH 服务端所在机器连接

5. 转发失败的常见路径

端口转发失败可能发生在不同阶段:

  1. 本地端口已经被占用;
  2. SSH 认证失败;
  3. 服务端禁止 TCP forwarding;
  4. 服务端无法解析目标地址;
  5. 服务端无法访问目标端口;
  6. SSH 通道建立后,目标服务主动关闭连接;
  7. 远端监听地址或防火墙规则不允许访问。

使用调试命令:

ssh -vvv -N \
  -o ExitOnForwardFailure=yes \
  -L 127.0.0.1:15432:db.internal:5432 \
  ops@bastion.example.com

服务端限制通常可在 sshd 日志中看到。日志位置依发行版而异,常见查询方式:

journalctl -u sshd

有些发行版服务名是 ssh

journalctl -u ssh

七、连接保活、并发和故障恢复

SSH 会话包含多个独立层次:

TCP 连接
  └── SSH 加密传输
       ├── 交互式 shell channel
       ├── exec channel
       ├── SFTP channel
       └── port-forward channel

一个 channel 关闭,不一定意味着整个 SSH 连接关闭;反过来,底层 TCP 断开会使全部 channel 失败。

客户端保活配置:

Host prod-*
    ServerAliveInterval 30
    ServerAliveCountMax 3

含义是:

  • 每 30 秒向服务端发送一次协议级请求;
  • 连续 3 次没有收到响应后,客户端认为连接失效并退出。

这不是“让网络变快”,而是帮助检测中间防火墙、NAT 或无线网络造成的半开连接。

服务端也可以配置:

ClientAliveInterval 60
ClientAliveCountMax 3

它用于服务端检测客户端是否仍然响应。两端同时配置时,应根据网络质量和会话类型调整。过于激进会误杀高延迟链路,过于宽松则会留下大量失效会话。

对长时间运行的交互任务,单靠 SSH 连接保活不能防止终端断线导致任务结束。可以使用:

tmux new -s deploy

或:

screen

把任务运行在远程会话管理器中。这样即使 SSH 断开,进程仍可在该会话中继续运行。


八、服务端配置:sshd_config 的解析与修改方法

服务端主配置通常是:

/etc/ssh/sshd_config

注意:

  • ssh_config 是客户端配置;
  • sshd_config 是服务端配置;
  • ssh -G 检查客户端最终配置;
  • sshd -T 检查服务端最终配置。

1. 配置生效规则

OpenSSH 配置不是任意键值覆盖系统。许多参数采用“读取到的第一个值生效”的规则,Match 还会引入条件作用域。因此不能简单假定文件底部一定覆盖顶部。

检查语法:

sudo sshd -t

检查展开后的有效配置:

sudo sshd -T

检查特定用户、来源地址和目标地址下的配置:

sudo sshd -T \
  -C user=deploy,addr=192.0.2.10,host=server.example.com

修改前备份:

sudo cp -a /etc/ssh/sshd_config \
  /etc/ssh/sshd_config.$(date +%F-%H%M%S).bak

应用配置通常使用 reload,而不是 restart:

sudo systemctl reload sshd

部分发行版服务名是:

sudo systemctl reload ssh

reload 通常让新连接使用新配置,现有会话继续运行;restart 可能中断现有会话。修改前必须保留一个已登录的管理会话,并从第二个终端验证新连接:

ssh -o BatchMode=yes deploy@server.example.com true

确认新连接成功后,才关闭旧会话。

2. 认证策略的基本配置

一个以公钥为主、禁止密码登录的示例:

PubkeyAuthentication yes
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitEmptyPasswords no

但如果系统依赖 PAM 提供 OTP 或 MFA,不能盲目关闭 KbdInteractiveAuthentication。例如,某些 MFA 流程通过 keyboard-interactive 进入 PAM。此时应根据实际认证链配置:

UsePAM yes
AuthenticationMethods publickey,keyboard-interactive:pam

这表示用户必须先通过公钥,再通过 PAM 的键盘交互认证。逗号表示同一连接中需要依次满足多个认证方法。

修改认证策略前,必须确认:

  • 至少有一把已验证可用的公钥;
  • PAM 配置不会拒绝所有用户;
  • sudo 和 SSH 登录是两个独立流程;
  • 远程控制台或带外恢复渠道可用。

3. root 登录策略

常见配置:

PermitRootLogin prohibit-password

它禁止 root 使用密码登录,但允许其他认证方式,例如公钥。更严格的配置是:

PermitRootLogin no

生产环境通常让普通运维账号登录,再通过:

sudo -i

获取管理权限。这样可以把“登录身份”和“提权行为”分离,由 sudoers、PAM 和审计记录提权过程。

但这不是绝对规则:

  • 紧急恢复环境可能需要 root SSH;
  • 自动化系统可能使用受限 root 密钥;
  • sudo 配置损坏时,完全禁用 root 可能增加恢复难度。

若必须允许自动化 root 密钥,应在 authorized_keys 中加入最小能力限制,例如禁止终端、转发和任意命令。

4. 限制允许登录的账号

AllowGroups ssh-login

或:

AllowUsers ops deploy

AllowGroupsAllowUsers 会直接影响登录授权。启用后,必须确认账号确实属于对应组:

id ops
getent group ssh-login

更精细的条件可以使用:

Match User deploy
    PasswordAuthentication no
    AllowTcpForwarding no

Match 后续配置的作用范围直到下一个 Match 或文件结束,因此排查时应使用:

sshd -T -C user=deploy,addr=192.0.2.10,host=server.example.com

而不是只凭肉眼阅读配置文件。

5. 降低不必要的会话能力

如果服务器只用于命令执行,不需要图形转发:

X11Forwarding no

如果不需要 SSH Agent 转发:

AllowAgentForwarding no

如果不需要端口转发:

AllowTcpForwarding no

如果只需要某一方向,可以使用:

AllowTcpForwarding local

或:

AllowTcpForwarding remote

如果不使用 tun/tap 类型的 VPN 隧道:

PermitTunnel no

这些设置不是为了“让 SSH 更安全”而无条件启用,而是为了减少服务端被滥用的能力。禁用前必须确认业务确实不依赖对应功能。

6. 连接资源和暴力尝试控制

可考虑:

MaxAuthTries 3
LoginGraceTime 30
MaxSessions 10

含义分别是:

  • MaxAuthTries:单个连接允许的认证尝试次数;
  • LoginGraceTime:完成认证的时间窗口;
  • MaxSessions:单个网络连接允许的会话数量。

这些参数不能替代入侵防护系统。对公网 SSH,还应结合:

  • 防火墙限制来源网段;
  • VPN 或专用管理网络;
  • 日志监控;
  • 失败次数封禁工具;
  • 账号和密钥生命周期管理;
  • 漏洞修复和系统更新。

“修改 SSH 端口”只能减少低质量扫描噪声,不能替代认证和访问控制。


九、密钥算法、主机密钥和 SSH 证书

1. 用户密钥和主机密钥不同

用户密钥用于证明“某个用户有权登录”。

主机密钥用于证明“某台 SSH 服务端的身份”。

它们的生命周期、保存位置和轮换方式不同:

用户私钥:~/.ssh/id_ed25519
服务端主机私钥:/etc/ssh/ssh_host_*

删除或更换服务端主机私钥会导致所有客户端出现主机身份变化告警。服务器迁移时,若逻辑主机身份保持不变,应妥善迁移主机密钥;若身份确实改变,应通过可信渠道更新客户端记录。

2. SSH 证书的模型

SSH 证书不是 TLS 证书,也不是把用户私钥上传到 CA。它通常包含:

  • 被签名的用户公钥;
  • 签发者 CA 的签名;
  • 用户名或主体;
  • 有效期;
  • 关键选项和扩展。

服务端配置受信任的用户 CA:

TrustedUserCAKeys /etc/ssh/ca/user_ca.pub

客户端仍然使用自己的私钥完成签名,但服务端根据证书签发者判断该公钥是否受信任。

证书可以缩短撤销和轮换周期,但不会自动解决:

  • 私钥被盗;
  • CA 私钥被盗;
  • 主机身份验证错误;
  • 账号授权边界设计错误。

CA 私钥必须离线或在严格控制的签发系统中保存。

3. FIDO 安全密钥

现代 OpenSSH 支持部分 FIDO/U2F 硬件生成的安全密钥,例如:

ssh-keygen -t ed25519-sk

这类密钥的安全性依赖硬件和本地交互,例如触摸确认。它们可以降低纯软件私钥被复制后的风险,但需要注意:

  • 客户端 OpenSSH、底层库和硬件必须支持;
  • 自动化任务通常不适合要求人工触摸;
  • 丢失硬件时必须有备用认证方式和撤销流程;
  • 不同发行版的软件版本和编译选项可能不同。

应以本机:

ssh -V
ssh-keygen -? 

以及对应手册确认支持情况。


十、服务端加固的验证与恢复流程

1. 加固不是一次性改配置

一个安全变更至少应包含:

备份配置
  ↓
检查语法
  ↓
检查展开后的有效配置
  ↓
reload
  ↓
保留旧会话
  ↓
新终端验证登录、sudo、转发策略
  ↓
观察日志
  ↓
确认恢复方案

示例:

sudo cp -a /etc/ssh/sshd_config /root/sshd_config.bak
sudo sshd -t
sudo sshd -T | egrep \
  'passwordauthentication|pubkeyauthentication|permitrootlogin|allowtcpforwarding|allowagentforwarding'
sudo systemctl reload sshd

然后在另一终端:

ssh -vvv ops@server.example.com

检查服务端日志:

sudo journalctl -u sshd -n 100 --no-pager

2. 失败诊断应按层次进行

网络层

getent hosts server.example.com
nc -vz server.example.com 22

可能的问题:

  • DNS 解析错误;
  • 路由不可达;
  • 防火墙丢弃;
  • 服务端没有监听目标地址;
  • 云安全组未放行。

服务端检查:

sudo ss -lntp | grep ':22'

SSH 协议层

ssh -vvv user@server.example.com

如果出现:

no matching host key type found

或:

Unable to negotiate with ...

通常是两端算法策略不兼容。不要直接启用过时算法作为永久方案,应优先升级客户端或服务端,并确认兼容性要求。

主机认证层

Host key verification failed

先检查:

ssh-keygen -F server.example.com
ssh-keyscan -t ed25519 server.example.com

ssh-keyscan 只能获取网络上返回的公钥,不能证明它可信。必须通过独立可信渠道核对指纹。

用户认证层

Permission denied (publickey).

客户端检查:

ssh-add -l
ssh -vvv -o IdentitiesOnly=yes \
  -i ~/.ssh/id_ed25519 user@server.example.com

服务端检查:

sudo sshd -T | grep -i pubkeyauthentication
sudo journalctl -u sshd -n 100 --no-pager

再检查:

namei -l /home/user/.ssh/authorized_keys
stat -c '%A %U:%G %n' /home/user /home/user/.ssh /home/user/.ssh/authorized_keys

namei -l 很有用,因为它会逐级显示路径权限。即使 authorized_keys 本身正确,父目录可写或归属错误仍可能导致拒绝。

PAM、账号和 Shell 层

公钥验证成功后仍可能被拒绝,原因包括:

  • 账号过期;
  • 密码过期;
  • 账号被锁定;
  • PAM 访问规则拒绝;
  • SELinux 拒绝;
  • 登录 Shell 不存在或被设置为不可登录;
  • AllowUsersAllowGroupsMatch 规则不匹配。

检查账号:

getent passwd user
sudo chage -l user
sudo passwd -S user

SELinux 系统检查:

getenforce
sudo ausearch -m AVC -ts recent

AppArmor 系统则应查看:

sudo journalctl -k | grep -i apparmor

不能仅凭“文件权限看起来正确”就断定一定是 SSH 密钥问题。认证、授权、PAM 和强制访问控制是不同阶段。

3. 配置错误时的恢复

如果 reload 后新连接全部失败:

  1. 不要关闭仍可用的旧管理会话;
  2. 通过旧会话恢复备份配置;
  3. 重新执行 sshd -t
  4. 再 reload;
  5. 用第二个终端验证。

例如:

sudo cp -a /root/sshd_config.bak /etc/ssh/sshd_config
sudo sshd -t
sudo systemctl reload sshd

如果已失去所有 SSH 会话,只能依赖:

  • 云平台串口或 Web 控制台;
  • 虚拟机控制台;
  • 物理带外管理;
  • 救援系统;
  • 配置管理系统;
  • 预先部署的恢复账号。

生产加固必须把“如何恢复错误配置”作为变更设计的一部分,而不是配置失败后的临时补救。


十一、文件传输:scpsftp 的边界

1. scp

复制本地文件到远程:

scp ./app.tar.gz deploy@server.example.com:/var/tmp/

从远程复制到本地:

scp deploy@server.example.com:/var/tmp/app.tar.gz .

通过跳板机:

scp -o ProxyJump=ops@bastion.example.com \
    ./app.tar.gz deploy@db.internal:/var/tmp/

现代 OpenSSH 的 scp 默认使用 SFTP 协议实现,而不是早期的传统 SCP 协议。若必须与旧设备兼容,可能需要旧协议模式,但旧模式的路径解释和安全边界更复杂,不能在不验证的情况下使用。

2. sftp

交互式使用:

sftp deploy@server.example.com

常见命令:

put app.tar.gz
get logs/app.log
lcd ./downloads
lpwd
ls
pwd
bye

批处理模式:

sftp -b commands.txt deploy@server.example.com

脚本中应检查退出码:

sftp -b commands.txt deploy@server.example.com
status=$?

if [ "$status" -ne 0 ]; then
    echo "sftp failed: $status" >&2
    exit "$status"
fi

仅看到部分文件传输成功不代表整个批次成功。自动化流程应检查退出码、目标文件大小、校验和以及服务端后续处理结果。


十二、生产环境中的最小权限设计

SSH 加固不应只关注“能不能登录”,还要关注“登录后能做什么”。

1. 登录账号和提权账号分离

推荐让运维人员使用个人账号:

alice
bob

登录后通过 sudo 执行需要权限的操作,而不是多人共用:

root

这样可以在日志中区分:

  • 谁建立了 SSH 会话;
  • 谁执行了 sudo
  • 执行了什么命令;
  • 命令是否被 PAM 或 sudo 策略拒绝。

sudo 不是 SSH 的一部分。即使 SSH 使用公钥认证,sudo 仍可能要求密码、MFA 或重新认证;反之,禁止 root SSH 也不代表 root 权限不能通过 sudo 获得。

2. 自动化账号使用受限密钥

例如只允许某个部署接收器:

restrict,command="/usr/local/sbin/deploy-receiver",no-agent-forwarding,no-port-forwarding ssh-ed25519 AAAA... deploy-key

如果自动化账号需要执行有限命令,应让包装脚本:

  1. 验证输入格式;
  2. 使用固定路径调用程序;
  3. 设置安全的环境变量;
  4. 拒绝额外参数;
  5. 记录请求来源和结果;
  6. 以非 root 用户执行不需要 root 的步骤。

不要把以下形式作为受限自动化方案:

command="bash -c \"$SSH_ORIGINAL_COMMAND\""

它本质上仍然给了调用者 Shell 注入入口。

3. 通过密钥撤销访问

删除 authorized_keys 中对应公钥即可阻止未来使用该密钥登录,但不能自动终止已经建立的会话。撤销流程还需要:

  • 查找并终止现有会话;
  • 删除 Agent 中的旧密钥;
  • 检查是否存在同一私钥的副本;
  • 检查 sudo、云平台、CI 系统中的重复授权;
  • 保留审计记录。

查看登录会话:

who
w
ps -ef | grep '[s]shd'

终止特定会话前应准确确认 PID、用户和来源,避免误杀生产任务。


十三、常见误解和反例

误解一:私钥不会上传,所以 Agent 转发没有风险

错误。Agent 转发不一定暴露私钥文件,但远程主机上的进程可以请求 Agent 签名。被攻陷的跳板机仍可能在转发期间冒用身份。

误解二:把 SSH 端口改成 2222 就完成了加固

错误。端口变化只影响扫描噪声,不改变:

  • 主机认证;
  • 用户认证;
  • 密钥管理;
  • 转发权限;
  • 提权审计;
  • 漏洞风险。

误解三:关闭 PasswordAuthentication 就一定启用了 MFA

错误。MFA 可能通过 keyboard-interactive、PAM、硬件密钥或外部认证系统实现。应使用 AuthenticationMethods 明确要求的认证组合,并在实际客户端上验证。

误解四:known_hosts 中有记录就绝对安全

错误。known_hosts 记录的是客户端曾接受的主机身份。首次写入时如果未通过可信渠道核对指纹,中间人可能在首次连接阶段植入错误公钥。

误解五:-L 隧道只在本地使用,所以没有暴露风险

不完全正确。若绑定到 0.0.0.0 或其他非回环地址,本地转发端口可能被其他机器访问。即使绑定到回环地址,服务器端仍可能通过该通道访问内部敏感服务。

误解六:ssh -N 不执行命令,所以一定没有权限风险

错误。-N 只是不请求远程 Shell,端口转发仍可能访问大量内部服务。对隧道账号必须单独限制 AllowTcpForwarding、目标地址和端口。


十四、一套可验证的生产基线示例

下面是一份偏保守的示例,必须结合实际业务调整:

PubkeyAuthentication yes
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitEmptyPasswords no

PermitRootLogin no
AllowGroups ssh-login

X11Forwarding no
AllowAgentForwarding no
AllowTcpForwarding no
PermitTunnel no

MaxAuthTries 3
LoginGraceTime 30
ClientAliveInterval 60
ClientAliveCountMax 3

应用前执行:

sudo sshd -t
sudo sshd -T | egrep \
  'pubkeyauthentication|passwordauthentication|kbdinteractiveauthentication|permitrootlogin|allowgroups|x11forwarding|allowagentforwarding|allowtcpforwarding|permittunnel|maxauthtries|logingracetime'

然后:

sudo systemctl reload sshd

但如果该服务器是跳板机,不能直接使用:

AllowTcpForwarding no

因为 ProxyJump 可能依赖 TCP 转发。此时更合理的设计可能是:

AllowTcpForwarding local

并通过防火墙、账号组、authorized_keys 选项以及网络 ACL 限制用途。

如果该服务器依赖 PAM MFA,也不能直接关闭:

KbdInteractiveAuthentication no

而应先确认认证链,例如:

UsePAM yes
AuthenticationMethods publickey,keyboard-interactive:pam

最终配置是否正确,不取决于配置文件“看起来合理”,而取决于以下验证结果:

# 公钥登录
ssh -o PreferredAuthentications=publickey user@server.example.com true

# 确认不接受密码
ssh -o PreferredAuthentications=password user@server.example.com

# 检查转发是否按预期被允许或拒绝
ssh -vvv -N -L 127.0.0.1:15432:db.internal:5432 user@bastion.example.com

测试拒绝场景同样重要。一个“安全配置”如果阻断了唯一的恢复路径,也可能造成生产事故。


结语

OpenSSH 的安全边界可以归纳为四组关系:

  1. 主机公钥决定客户端是否信任服务端身份;
  2. **用户私钥和 authorized_keys**决定用户是否能完成公钥认证;
  3. Agent缓存签名能力,方便使用私钥,但会扩大被信任主机的影响范围;
  4. 跳板和隧道改变网络可达性,不会自动改变目标服务的认证和授权。

ProxyJump 通常比 Agent 转发更适合作为多级连接方案;-L-R-D 必须根据监听端和最终连接发起端来判断;服务端加固必须同时考虑 SSH 配置、Linux 权限、PAM、sudo、SELinux/AppArmor、网络访问控制和审计。

最可靠的运维流程不是简单地关闭几个选项,而是:

明确访问目的
→ 生成并保护密钥
→ 只授权必要账号和能力
→ 限制 Agent、转发和提权范围
→ 用 sshd -t、sshd -T 和实际连接验证
→ 保留旧会话与带外恢复路径
→ 持续审计、轮换和撤销凭据

这样才能把“能通过 SSH 登录”转化为可验证、可审计、可恢复的生产访问体系。


系列导航与关联阅读

官方资料

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