目录

Linux-16 SSH 远程访问原理与实战

前置阅读:Linux-03 用户、权限与安全模型Linux-13 网络配置与排查

SSH 是每天都在用的工具,但大部分人只用到了 ssh user@host。这一篇讲清协议原理那些能显著提升效率的功能——端口转发、多跳、连接复用、以及安全加固。

本篇与下一篇的分工:这里讲原理、配置和排查,密钥的生成/多账号管理/known_hosts 细节在 Linux-17 SSH 密钥管理与多账号实战

1. SSH 是什么

一句话:**SSH(Secure Shell)是一个加密的网络协议,用于在不安全的网络上安全地远程登录和传输数据。**默认端口 22

它取代了早期的 telnet、rlogin、rsh——那些协议把密码和数据以明文传输,同一网段随便抓包就能拿到 root 密码。

SSH 提供三重保障

保障 机制
机密性 所有流量对称加密(AES、ChaCha20)
完整性 MAC 校验,篡改能被检测
身份认证 双向——客户端验证服务器(host key),服务器验证客户端(密码或密钥)

SSH 不只是远程 shell,它是一个通用的加密传输层,上面跑着多种应用:

SSH 协议
 +-- 远程 shell            ssh user@host
 +-- 远程命令执行          ssh user@host 'df -h'
 +-- 文件传输              scp / sftp / rsync -e ssh
 +-- 端口转发(隧道)      -L / -R / -D
 +-- Git 传输              git@github.com:...
 +-- VPN(TUN 设备)       -w(很少用)

1.1 三阶段握手

理解这三个阶段能解释后面所有的报错:

① 协议协商与密钥交换(Key Exchange)
   - 双方交换支持的算法列表,选出交集里最强的
   - 用 Diffie-Hellman / ECDH 协商出【会话密钥】
   - 之后所有流量用这个对称密钥加密
   v
② 服务器认证(Host Authentication)
   - 服务器出示自己的【host key】(公钥)
   - 客户端查 ~/.ssh/known_hosts 比对指纹
   - 不匹配 -> 报警并拒绝连接    <- "REMOTE HOST IDENTIFICATION HAS CHANGED"
   v
③ 用户认证(User Authentication)
   - 客户端按顺序尝试:publickey -> keyboard-interactive -> password
   - 全部失败 -> "Permission denied"

关键:阶段 ② 和 ③ 的认证方向是相反的——② 是客户端验证服务器,③ 是服务器验证客户端。搞清这一点,就知道 Host key verification failed(阶段②)和 Permission denied (publickey)(阶段③)是完全不同的两类问题。

# 用 -v 观察三个阶段(排查任何 SSH 问题的第一步)
ssh -v user@server
# debug1: kex: algorithm: curve25519-sha256          <- ① 密钥交换
# debug1: Server host key: ssh-ed25519 SHA256:xxx    <- ② 服务器认证
# debug1: Authentications that can continue: publickey,password
# debug1: Offering public key: /home/lhx/.ssh/id_ed25519   <- ③ 用户认证
# debug1: Authentication succeeded (publickey).

-v / -vv / -vvv 三个级别,日常排查 -v 就够,看密钥选择过程用 -vv

2. 密码登录的原理与风险

2.1 流程

Client                              Server
  |                                   |
  |  ① 发起连接,完成密钥交换        |
  |---------------------------------->|
  |                                   |
  |  ② 返回服务器 host key(公钥)   |
  |<----------------------------------|
  |     客户端比对 known_hosts        |
  |                                   |
  |  ③ 在【已加密的通道】里发送密码  |
  |---------------------------------->|
  |                                   |  ④ 与 /etc/shadow 里的哈希比对
  |<-------- 登录成功 / 失败 ---------|

注意第 ③ 步:密码是在已经加密的通道里传输的,所以不会被网络嗅探。密码登录的问题不在传输,而在下面两点。

2.2 风险一:中间人攻击

SSH 的 host key 是自签发的,没有 CA 背书,客户端无法自动验证服务器身份。这是 SSH 与 HTTPS 最大的信任模型差异。

第一次连接时:

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

这个提示的本质是:「我不认识这台服务器,你自己确认」。输入 yes 后指纹存入 ~/.ssh/known_hosts,之后每次连接都比对——这叫 TOFU(Trust On First Use,首次使用即信任)

风险就在「首次」:如果第一次连接时就已经被中间人劫持,你信任的是攻击者的密钥,而且之后都不会再有任何警告。

正确做法是带外验证指纹

# 在服务器上(通过其他可信途径,如云控制台)打印指纹
ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub
# 256 SHA256:+DiY3wvvV6TuJJhbpZisF/zLDA0zPMSvHdkr4UvCOqU root@server (ED25519)

# 客户端连接时把指纹粘进去,而不是输 yes
# Are you sure you want to continue connecting (yes/no/[fingerprint])? SHA256:+DiY3...
#                                                     ^^^^^^^^^^^^^ 直接输指纹,匹配才连

GitHub 等大厂会公开发布自己的 host key 指纹,可以直接预置:

# 提前把已知的 host key 写进 known_hosts,避免 TOFU 窗口
ssh-keyscan -t ed25519 github.com >> ~/.ssh/known_hosts
# 然后与官方公布的指纹核对
ssh-keygen -lf ~/.ssh/known_hosts | grep github

host key 变化时的报错

@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@    WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!     @
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
# 确认是服务器重装 / IP 复用导致的,才清除旧记录
ssh-keygen -R 192.168.1.100
ssh-keygen -R "[192.168.1.100]:2222"      # 非默认端口的写法

# ❌ 不要不加思考就 -R,那正是中间人攻击想让你做的事

2.3 风险二:可被暴力破解

密码登录可以被在线爆破——公网上的 22 端口每天会收到成千上万次尝试:

# 看看你的机器被爆破得多厉害
grep "Failed password" /var/log/auth.log | wc -l
# 89234

grep "Failed password" /var/log/auth.log \
  | grep -oE '([0-9]{1,3}\.){3}[0-9]{1,3}' \
  | sort | uniq -c | sort -rn | head -5
#  23891 45.227.xxx.xxx
#  12034 61.177.xxx.xxx

所以生产环境的标准做法是彻底关闭密码登录(见 §7)。密钥登录无法被在线爆破——攻击者必须先拿到私钥文件。

3. 密钥登录的原理

3.1 流程

Client                                  Server
  |                                       |
  |  ① 声明用户名和要用的公钥指纹        |
  |-------------------------------------->|
  |                                       |  ② 在 ~/.ssh/authorized_keys
  |                                       |     里查找这个公钥
  |  ③ 「我认识公钥,请证明你有私钥」    |     生成随机数据 R
  |<--------------------------------------|
  |                                       |
  |  ④ 用【私钥】对 (R + session_id)     |
  |     计算数字签名,发回                |
  |-------------------------------------->|
  |                                       |  ⑤ 用【公钥】验证签名
  |<----------- 登录成功 / 失败 ----------|

核心:私钥从不离开本机,服务器永远拿不到它。

服务器只有你的公钥,验证的方式是「让你用私钥签名一段随机数据,我用公钥验签」——这是数字签名,不是「用公钥加密再让你解密」。

注意:很多资料(包括早期教程)把第 ③④ 步描述为「服务器用公钥加密随机串,客户端用私钥解密后发回」。这在 RSA 上勉强说得通,但现代 SSH 实际用的是签名机制publickey 认证方法),Ed25519 这类算法根本不能用来加密,只能签名。理解成「签名+验签」才准确。

3.2 为什么密钥登录更安全

密码 密钥
能否被在线爆破 不能(需先获取私钥文件)
熵(可猜测性) 低(人记得住的密码熵很低) 极高(256 位随机)
服务器是否存储敏感信息 存密码哈希(可离线爆破) 只存公钥(泄露无害)
泄露影响范围 一个密码可能被复用到多处 一个密钥可只用于一台机器
是否可撤销单个凭证 删一行 authorized_keys 即可
能否自动化 需要明文存密码(危险) 天然适合自动化

「服务器只存公钥」是关键优势:即使服务器被完全攻破、authorized_keys 泄露,攻击者也无法用它登录任何地方。而 /etc/shadow 泄露后,弱密码在几小时内就能被离线爆破出来。

3.3 部署公钥

# 方式一:ssh-copy-id(推荐,会自动处理权限)
ssh-copy-id -i ~/.ssh/id_ed25519.pub user@server

# 方式二:手动(ssh-copy-id 不可用时,如目标只允许特定命令)
cat ~/.ssh/id_ed25519.pub | ssh user@server \
  "mkdir -p ~/.ssh && chmod 700 ~/.ssh && \
   cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"

权限要求是强制的,SSH 会拒绝使用权限过松的文件(这是第 3 篇讲的权限模型的实际应用):

chmod 700 ~/.ssh
chmod 600 ~/.ssh/authorized_keys
chmod 600 ~/.ssh/id_ed25519          # 私钥
chmod 644 ~/.ssh/id_ed25519.pub      # 公钥可以公开
chmod 644 ~/.ssh/known_hosts
chmod 600 ~/.ssh/config

为什么 SSH 这么严格:如果 ~/.ssh 是 777,同机的其他用户就能往你的 authorized_keys 里加自己的公钥,从而以你的身份登录。所以 SSH 宁可拒绝服务也不冒这个风险。

# 常见报错
# Permissions 0644 for '/home/lhx/.ssh/id_ed25519' are too open.
# It is required that your private key files are NOT accessible by others.

3.4 authorized_keys 的高级限制

这是个被严重低估的功能——可以在公钥前面加限制项,实现精细的授权:

# ~/.ssh/authorized_keys

# 限制这个密钥只能执行一条固定命令(备份专用密钥)
command="/opt/scripts/backup-only.sh",no-port-forwarding,no-agent-forwarding,no-pty ssh-ed25519 AAAA... backup@ci

# 限制来源 IP
from="10.0.0.0/24,192.168.1.5" ssh-ed25519 AAAA... deploy@office

# 只用于 git(禁掉 shell 和转发)
restrict,command="git-shell" ssh-ed25519 AAAA... git@ci
限制项 作用
command="..." 无论客户端请求什么,只执行这条命令
from="..." 限制来源 IP/网段
no-port-forwarding 禁止端口转发
no-agent-forwarding 禁止 agent 转发
no-pty 禁止分配伪终端(拿不到交互 shell)
restrict 一次性禁用所有转发和 pty(OpenSSH 7.2+,推荐)
expiry-time="20261231" 密钥过期时间(8.2+)

command= 配合 restrict 是 CI/CD 密钥的正确姿势

# CI 只需要触发部署,不需要 shell
restrict,command="/opt/deploy.sh" ssh-ed25519 AAAA... ci@jenkins

这样即使 CI 系统被攻破、私钥泄露,攻击者也只能触发部署脚本,拿不到 shell、不能转发端口。比给一个完整的 shell 权限安全得多。

# 原始命令仍可通过环境变量拿到,用于实现受限的多功能入口
# $SSH_ORIGINAL_COMMAND

4. 客户端配置:~/.ssh/config

这是提升日常效率最直接的一件事。

# ~/.ssh/config
Host prod-web
    HostName 192.168.1.100
    User ubuntu
    Port 2222
    IdentityFile ~/.ssh/id_ed25519_work
ssh prod-web
# 等价于 ssh -i ~/.ssh/id_ed25519_work -p 2222 ubuntu@192.168.1.100

4.1 常用参数

参数 说明
HostName 实际地址(Host 只是别名)
User 登录用户
Port 端口
IdentityFile 私钥路径
IdentitiesOnly yes 只用指定的密钥,见第 17 篇
ProxyJump 跳板机
ServerAliveInterval 心跳间隔(秒),防超时断开
ServerAliveCountMax 心跳失败几次后断开
ForwardAgent 是否转发 agent(有风险,见 §6.4)
StrictHostKeyChecking host key 检查策略
ControlMaster 连接复用,见 §4.3
RequestTTY 是否强制分配终端
LogLevel 日志级别

4.2 匹配规则与通配符

Host 支持通配符,且是「第一次匹配到的值生效」

# ~/.ssh/config

# 具体规则放【前面】
Host prod-*
    User ubuntu
    IdentityFile ~/.ssh/id_ed25519_prod
    ServerAliveInterval 60

Host 10.0.*
    User root
    ProxyJump bastion

# 通用规则放【最后】
Host *
    ServerAliveInterval 60
    ServerAliveCountMax 3
    AddKeysToAgent yes
    HashKnownHosts yes
    Compression no

顺序至关重要:SSH 对每个参数取第一次遇到的值,不是最后一次。所以 Host * 必须放在文件最后——放在最前面会让所有具体配置失效。

验证最终生效的配置(不确定时必用):

ssh -G prod-web | head -15
#      ^^ 打印最终解析结果,不实际连接
# host 192.168.1.100
# user ubuntu
# port 2222
# identityfile ~/.ssh/id_ed25519_work
# serveraliveinterval 60

ssh -G 是排查「config 写了不生效」的最快方法——它展示所有配置合并后的真实值。

4.3 连接复用:ControlMaster

这个功能能显著加速频繁的 SSH 操作,但知道的人不多。

# ~/.ssh/config
Host *
    ControlMaster auto
    ControlPath ~/.ssh/cm-%r@%h:%p
    ControlPersist 10m
原理:
  第一次 ssh -> 建立连接,同时创建一个 Unix socket(ControlPath)
  后续的 ssh/scp/rsync -> 复用这个已有的 TCP 连接和已完成的认证
                          【不需要重新握手、重新认证】
  最后一次连接断开后,主连接再保持 10 分钟(ControlPersist)

效果对比

# 未开启复用
time ssh prod-web true
# real 0m0.812s        <- 每次都要完整握手 + 认证

# 开启复用后的第二次
time ssh prod-web true
# real 0m0.043s        <- 快 19 倍

这对以下场景是质变

  • Ansible / 部署脚本(成百上千次 SSH 调用)
  • git 频繁 push/pull(GitHub 也支持)
  • 循环里对多台机器执行命令
# 管理复用连接
ssh -O check prod-web        # 检查主连接是否存在
ssh -O exit prod-web         # 关闭主连接
ls ~/.ssh/cm-*               # 看有哪些复用 socket

坑:ControlPath 里必须包含 %r@%h:%p(用户@主机:端口),否则不同目标会共用同一个 socket 导致连错机器。另外 socket 文件路径有长度限制(约 100 字符),路径太长会报 unix_listener: too long for Unix domain socket——用 %C(哈希值)代替可以缩短:ControlPath ~/.ssh/cm-%C

5. 服务端配置:sshd_config

# /etc/ssh/sshd_config
# 改完【必须先测试语法】再重启
sshd -t                      # 或 sshd -T 打印最终配置
systemctl restart sshd

sshd -t 是必做的一步——配置语法错误会导致 sshd 启动失败,如果你正通过 SSH 操作远程机器,就把自己锁在门外了。

更安全的改配置流程

# ① 测试语法
sshd -t || { echo "配置有错,不要重启"; exit 1; }

# ② 在【新端口】上另起一个 sshd 实例测试,不动现有服务
/usr/sbin/sshd -D -p 2223 -f /etc/ssh/sshd_config_new &

# ③ 用新端口连接验证
ssh -p 2223 user@localhost

# ④ 验证通过后才重启主服务
systemctl restart sshd

# ⑤ 【保持当前会话不要退出】,另开一个终端验证能连上
#    确认无误后再关闭旧会话

「保持当前会话」是关键纪律——现有的 SSH 连接不受 restart sshd 影响,它是你在配错时唯一的救命通道。

5.1 关键配置项

# ---------- 网络 ----------
Port 22
ListenAddress 0.0.0.0
AddressFamily inet                    # 只用 IPv4

# ---------- 认证 ----------
PermitRootLogin no                    # 禁止 root 直接登录
PubkeyAuthentication yes
PasswordAuthentication no             # 关闭密码登录(核心加固项)
KbdInteractiveAuthentication no       # 新名字,旧名 ChallengeResponseAuthentication
PermitEmptyPasswords no
MaxAuthTries 3                        # 认证失败次数上限
LoginGraceTime 30                     # 认证超时(秒)
MaxSessions 10
MaxStartups 10:30:60                  # 防 DoS:未认证连接数限制

# ---------- 访问控制 ----------
AllowUsers deploy ubuntu              # 白名单(比 DenyUsers 更安全)
AllowGroups sshusers
# DenyUsers root guest

# ---------- 会话保活 ----------
ClientAliveInterval 300               # 服务端主动探测客户端
ClientAliveCountMax 2                 # 探测失败 2 次就断开

# ---------- 功能开关 ----------
AllowTcpForwarding no                 # 不需要端口转发就关掉
AllowAgentForwarding no
X11Forwarding no
PermitTunnel no
GatewayPorts no                       # 远程转发是否监听所有网卡

# ---------- 其他 ----------
UseDNS no                             # 关掉反向 DNS 查询,加速登录
PrintLastLog yes
Banner /etc/ssh/banner
Subsystem sftp internal-sftp

UseDNS no 值得单独说——它默认开启时,sshd 会对每个连接做反向 DNS 查询。如果 DNS 不可达或很慢,登录会卡 10~30 秒。这是「ssh 连接特别慢但连上后一切正常」的最常见原因。

# 验证是不是 DNS 导致的慢
ssh -v user@server 2>&1 | ts -i '%.s'      # 看哪一步耗时最长
# 或者直接对比
time ssh -o UseDNS=no ...    # 服务端选项,需在服务端改

另一个登录慢的原因是 GSSAPI

# 客户端侧
Host *
    GSSAPIAuthentication no

5.2 Match 块:差异化配置

# 对特定用户/组/来源应用不同规则
Match User deploy
    PasswordAuthentication no
    AllowTcpForwarding no
    ForceCommand /opt/deploy.sh

Match Group sftponly
    ChrootDirectory /data/sftp/%u      # 限制在自己的目录里
    ForceCommand internal-sftp
    AllowTcpForwarding no
    X11Forwarding no

Match Address 10.0.0.0/8
    PasswordAuthentication yes          # 内网允许密码,公网不允许

Match Group sftponly + ChrootDirectory 是「只给 SFTP 不给 shell」的标准做法——适合给外部合作方开传文件的账号。

注意:Match 块之后的所有配置都属于该块,直到下一个 Match 或文件结束。所以 Match 块必须放在文件最后,否则会把全局配置吃进去。

6. 端口转发:SSH 最强大的功能

三种转发本质都是通过 SSH 加密隧道转发 TCP 流量

6.1 本地转发 -L:把远端服务映射到本地

ssh -L [本地监听地址:]本地端口:目标地址:目标端口 user@ssh服务器
本机:3306 --加密隧道--> ssh服务器 --明文--> 目标地址:3306

场景一:访问只监听 127.0.0.1 的服务

# 服务器上的 MySQL 只监听 127.0.0.1:3306(第 13 篇讲的常见配置)
ssh -L 3306:127.0.0.1:3306 user@db-server

# 之后在本机像访问本地 MySQL 一样操作
mysql -h 127.0.0.1 -P 3306 -u root -p

这是最实用的场景——数据库不需要暴露到网络上,仍然能用本地的 GUI 工具(DataGrip、Navicat)连接。

场景二:通过跳板机访问内网

# 目标地址是【从 ssh 服务器视角】可达的地址
ssh -L 8080:10.0.0.100:80 user@bastion
# 本机 localhost:8080 -> bastion -> 内网 10.0.0.100:80

关键理解:目标地址:端口 是在 SSH 服务器上解析和连接的,不是在本机。所以可以填只有服务器能访问的内网地址。

# 让局域网其他机器也能用这个隧道(默认只监听 127.0.0.1)
ssh -L 0.0.0.0:8080:10.0.0.100:80 user@bastion
#        ^^^^^^^ 显式指定监听地址

6.2 远程转发 -R:内网穿透

ssh -R [远端监听地址:]远端端口:目标地址:目标端口 user@ssh服务器
公网服务器:8080 --加密隧道--> 本机 --> localhost:3000
# 本机没有公网 IP,想让外部访问本机的 3000 端口服务
ssh -R 8080:localhost:3000 user@public-server
# 外部访问 public-server:8080 -> 隧道 -> 本机:3000

默认只监听 127.0.0.1,外部访问不了。要让外部可达需要服务端配合:

# /etc/ssh/sshd_config
GatewayPorts yes             # 或 clientspecified(更安全,由客户端指定)

坑:-R 是把内网服务暴露到公网,安全风险很高。生产环境应该用 GatewayPorts clientspecified + 明确的监听地址,或者干脆用专门的隧道工具(frp、Cloudflare Tunnel)配合认证。临时调试用 -R 是可以的,用完记得断开。

6.3 动态转发 -D:SOCKS5 代理

ssh -D 1080 user@server
# 然后把浏览器/系统代理设为 SOCKS5 127.0.0.1:1080
# 所有流量经 server 出去,相当于简易 VPN
# 命令行工具走这个代理
curl --socks5-hostname 127.0.0.1:1080 https://internal.example.com
export ALL_PROXY=socks5h://127.0.0.1:1080

socks5hh 表示让代理服务器做 DNS 解析——访问内网域名时必须用这个,否则本地解析不了。

这是访问内网系统最轻量的方案:不需要装 VPN 客户端,只要有一台跳板机的 SSH 权限。

6.4 常用参数组合与保活

# 后台建立隧道,不执行远程命令
ssh -fNL 3306:127.0.0.1:3306 user@server
#     ||+- 本地转发
#     |+-- N: 不执行远程命令(只做转发)
#     +--- f: 后台运行

# 生产级的隧道命令:加保活和失败检测
ssh -fN \
    -L 3306:127.0.0.1:3306 \
    -o ServerAliveInterval=30 \
    -o ServerAliveCountMax=3 \
    -o ExitOnForwardFailure=yes \
    user@server

ExitOnForwardFailure=yes 很重要:不加的话,即使端口转发失败(如本地端口已被占用),SSH 连接仍然建立成功——你以为隧道通了,实际没有。

长期隧道用 autossh 自动重连

autossh -M 0 -fN -L 3306:127.0.0.1:3306 \
    -o ServerAliveInterval=30 -o ServerAliveCountMax=3 \
    -o ExitOnForwardFailure=yes user@server
#       ^^^^ -M 0 表示不用额外的监控端口,靠 ServerAlive 检测

更好的做法是交给 systemd 管理(第 14 篇):

# /etc/systemd/system/tunnel-db.service
[Unit]
Description=SSH tunnel to database
After=network-online.target
Wants=network-online.target

[Service]
User=tunnel
# 注意不加 -f,让 ssh 在前台跑,由 systemd 管理生命周期
ExecStart=/usr/bin/ssh -N \
    -o ServerAliveInterval=30 -o ServerAliveCountMax=3 \
    -o ExitOnForwardFailure=yes -o StrictHostKeyChecking=yes \
    -L 3306:127.0.0.1:3306 user@db-server
Restart=always
RestartSec=10

[Install]
WantedBy=multi-user.target

不加 -f 是关键——systemd 需要在前台跟踪进程(第 14 篇讲的 Type=simple),加了 -f 会让 systemd 认为服务立刻退出了。

6.5 Agent Forwarding 的风险

ssh -A user@bastion       # 转发 ssh-agent,让跳板机能用本机的私钥

它解决的问题:从跳板机再连内网机器时,不用把私钥复制到跳板机上。

但它有真实的安全风险跳板机上的 root(或任何能读你 agent socket 的人)可以借用你的 agent 去认证任何地方——相当于在你连接期间获得了你私钥的使用权。

# 攻击者在跳板机上
ls -l /tmp/ssh-*/agent.*         # 找到你的 agent socket
SSH_AUTH_SOCK=/tmp/ssh-XXX/agent.1234 ssh victim@other-server
#                                     <- 用你的身份登录别处

替代方案(推荐):用 ProxyJump 代替 -A

# ~/.ssh/config
Host inner
    HostName 10.0.0.50
    User root
    ProxyJump bastion

ProxyJump 的原理完全不同:它把 bastion 只当作一个 TCP 转发通道,认证是本机直接与 inner 做的,私钥和 agent 从不暴露给 bastion。

-A(Agent Forwarding):
  本机 --认证--> bastion --【借用本机 agent 认证】--> inner
                    ^ bastion 能滥用你的 agent

ProxyJump:
  本机 --认证--> bastion(仅转发 TCP)
  本机 ---------- 直接认证 ----------> inner
                    ^ bastion 只看到加密流量,无法滥用

**规律:需要多跳时一律用 ProxyJump,不要用 -A。**只有在跳板机上需要执行 git clone 之类操作时才不得不用 -A,此时应该配合 ssh-add -c(每次使用需确认)。

7. 多跳连接

# 命令行
ssh -J user@bastion user@inner
ssh -J user@jump1,user@jump2 user@target        # 多级

# config(推荐)
Host inner
    HostName 10.0.0.50
    User root
    ProxyJump bastion

Host bastion
    HostName 1.2.3.4
    User ubuntu
    IdentityFile ~/.ssh/id_ed25519_work
ssh inner            # 自动经过跳板机
scp file inner:/tmp/ # scp、rsync 也自动走跳板机

ProxyJump 与老式 ProxyCommand 的关系

# ProxyJump(7.3+,推荐)
ProxyJump bastion

# 等价的 ProxyCommand 写法(老,但更灵活)
ProxyCommand ssh -W %h:%p bastion

# 需要用 nc 的极老写法(不推荐,依赖目标机有 nc)
ProxyCommand ssh bastion nc %h %p

-W 的优势是不依赖跳板机上有 nc,直接用 SSH 内建的转发能力。

8. 文件传输

8.1 scp

scp file.txt user@server:/path/           # 上传
scp user@server:/path/file.txt ./         # 下载
scp -r ./mydir user@server:/path/         # 递归
scp -P 2222 file.txt user@server:/path/   # 注意是大写 -P
scp file.txt prod:/tmp/                   # 用 config 别名
scp -3 host1:/f host2:/f                  # 两台远端之间传(经本机中转)

注意:OpenSSH 9.0+ 起 scp 底层默认改用 SFTP 协议,因为原来的 SCP 协议有安全缺陷(CVE-2020-15778 等)。这带来一个兼容性变化:某些依赖旧 SCP 通配符展开行为的脚本可能失效,可以用 -O 强制走旧协议(不推荐)。

8.2 rsync:比 scp 好得多

# 增量同步,只传输差异部分
rsync -avz --progress ./src/ user@server:/dst/

# 通过跳板机
rsync -avz -e 'ssh -J bastion' ./src/ user@inner:/dst/

# 断点续传大文件
rsync -avzP --partial ./bigfile user@server:/dst/

# 镜像(危险:会删除目标端多余的文件)
rsync -avz --delete ./src/ user@server:/dst/

rsync 相比 scp 的优势:增量传输(只传变化的部分)、支持断点续传、能保留权限属主(-a)、可以 --dry-run 预演、传输中断后重跑代价小。

第 2 篇 §4.1 讲的尾斜杠陷阱在这里同样适用rsync -a src dst/ 会创建 dst/src/,而 rsync -a src/ dst/ 是把 src 的内容放进 dst。

8.3 不用 scp 的传输技巧

# 打包并直接通过管道传输(不落地临时文件)
tar czf - ./mydir | ssh user@server "tar xzf - -C /dst/"

# 反向:从远端拉取
ssh user@server "tar czf - /remote/dir" | tar xzf - -C ./local/

# 单个文件
ssh user@server "cat /etc/nginx/nginx.conf" > nginx.conf

# 传输并显示进度(需要 pv)
tar czf - ./mydir | pv | ssh user@server "tar xzf - -C /dst/"

这个 tar | ssh 模式在传输大量小文件时比 scp -r 快得多——scp 逐个文件建立传输,而 tar 流是一个连续的字节流,避免了每个文件的往返开销。

9. 安全加固清单

# ---------- 服务端 /etc/ssh/sshd_config ----------
# ① 关闭密码登录(最重要的一项)
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitEmptyPasswords no

# ② 禁止 root 直接登录
PermitRootLogin no
# 或者只允许密钥登录 root(自动化场景)
# PermitRootLogin prohibit-password

# ③ 白名单限制用户
AllowUsers deploy ubuntu
AllowGroups sshusers

# ④ 限制认证尝试
MaxAuthTries 3
LoginGraceTime 30
MaxStartups 10:30:60

# ⑤ 关掉不需要的功能
AllowTcpForwarding no
AllowAgentForwarding no
X11Forwarding no
PermitTunnel no

# ⑥ 加速与减少信息泄露
UseDNS no
# ---------- 网络层 ----------
# 改端口(只能减少扫描噪音,不是真正的安全措施)
Port 22222

# 用防火墙限制来源(第 13 篇)—— 比改端口有效得多
iptables -A INPUT -p tcp --dport 22 -s 10.0.0.0/8 -j ACCEPT
iptables -A INPUT -p tcp --dport 22 -j DROP

# 或者干脆只允许通过跳板机访问,业务机不开公网 22

「改端口」的作用要正确认识:它只能过滤掉无脑的自动化扫描,对有针对性的攻击毫无作用(nmap 几秒就扫出来了)。真正有效的是「关闭密码登录 + 限制来源 IP」。

9.1 fail2ban

apt install fail2ban

# /etc/fail2ban/jail.local
cat > /etc/fail2ban/jail.local <<'EOF'
[DEFAULT]
bantime  = 1h
findtime = 10m
maxretry = 5
# 白名单:不要把自己封了
ignoreip = 127.0.0.1/8 10.0.0.0/8

[sshd]
enabled = true
port    = 22
backend = systemd
EOF

systemctl enable --now fail2ban
# 查看状态
fail2ban-client status sshd
# Status for the jail: sshd
# |- Currently failed: 3
# |- Total failed:     8923
# `- Banned IP list:   45.227.xxx.xxx 61.177.xxx.xxx

# 手动解封(把自己封了的时候)
fail2ban-client set sshd unbanip 1.2.3.4

ignoreip 一定要配上自己的办公网段——不然某次输错几次密码就把自己封在外面了,而且如果这是唯一的访问途径,只能等 bantime 过期或上控制台。

9.2 双因素认证

# 密钥 + TOTP 双因素(高安全要求场景)
apt install libpam-google-authenticator
google-authenticator            # 为当前用户生成密钥和二维码

# /etc/ssh/sshd_config
KbdInteractiveAuthentication yes
AuthenticationMethods publickey,keyboard-interactive
#                     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 逗号 = 【都要满足】

# /etc/pam.d/sshd 里加
# auth required pam_google_authenticator.so

AuthenticationMethods 用逗号表示「都要」,用空格分隔多组表示「任一组」

AuthenticationMethods publickey,keyboard-interactive    # 密钥 AND 验证码
AuthenticationMethods publickey password                # 密钥 OR 密码

9.3 硬件密钥

OpenSSH 8.2+ 支持 FIDO2/U2F 硬件密钥,私钥无法被复制出硬件

# 生成绑定硬件的密钥(需要插入 YubiKey 等设备)
ssh-keygen -t ed25519-sk -C "yubikey"
# 每次使用需要触摸设备确认

# 常驻型(密钥存在设备里,可在任何机器上用)
ssh-keygen -t ed25519-sk -O resident -O verify-required

这是目前最强的 SSH 认证方式——即使工作机被完全攻破,攻击者也拿不到私钥(它在硬件里),而且每次使用需要物理触摸。

10. 常见问题排查

10.1 Permission denied (publickey)

这是阶段 ③(用户认证)失败。

# 第一步永远是 -v
ssh -v user@server 2>&1 | grep -A2 -i "offering\|authentications"
# debug1: Authentications that can continue: publickey
# debug1: Offering public key: /home/lhx/.ssh/id_ed25519 ED25519 SHA256:xxx
# debug1: Authentications that can continue: publickey
#         ^ 提供了密钥但被拒绝

按可能性排序的原因

# ① 服务端权限不对(最常见)
ssh user@server "ls -ld ~/.ssh ~/.ssh/authorized_keys"
# drwx------ ~/.ssh                    <- 必须 700
# -rw------- ~/.ssh/authorized_keys    <- 必须 600
# 家目录本身也不能是 group/other 可写!
ssh user@server "ls -ld ~"
# drwxr-xr-x <- 正确;drwxrwxr-x 会导致 SSH 拒绝

# ② 公钥没正确写入
ssh-keygen -lf ~/.ssh/id_ed25519.pub                      # 本地公钥指纹
ssh user@server "ssh-keygen -lf ~/.ssh/authorized_keys"   # 服务端记录的
# 对比指纹是否一致

# ③ 用错了密钥(本地有多个密钥时)
ssh -v ... | grep "Offering"          # 看实际提供了哪个
# 解法见第 17 篇的 IdentitiesOnly

# ④ 服务端配置
sshd -T | grep -iE "pubkeyauth|authorizedkeysfile|permitrootlogin|allowusers"

# ⑤ SELinux(RHEL 系)
ssh user@server "restorecon -Rv ~/.ssh"
ausearch -m avc -ts recent | grep ssh

# ⑥ 服务端日志(最直接的答案)
ssh other-way-in "sudo journalctl -u sshd -n 20"
# Authentication refused: bad ownership or modes for directory /home/user
#                         ^^^^^^^^^^^^^^^^^^^^^^^^^^^ 明确说了原因

服务端的 journalctl -u sshd 往往直接给出原因——比在客户端猜快得多。如果还有其他途径能登上服务器(控制台、另一个账号),优先看服务端日志。

10.2 连接很慢(10~30 秒才进去)

# 用 -v 看卡在哪一步
ssh -vv user@server 2>&1 | ts -i '%.s' | sort -rn | head -5

三个最常见原因

原因 现象 解法
服务端反向 DNS 卡在 debug1: SSH2_MSG_SERVICE_ACCEPT 之后 服务端 UseDNS no
GSSAPI 认证尝试 卡在认证阶段开头 客户端 GSSAPIAuthentication no
服务端 DNS 不可达 同第一条 /etc/resolv.conf 或关 UseDNS
# 客户端侧的快速缓解
ssh -o GSSAPIAuthentication=no -o PreferredAuthentications=publickey user@server

10.3 连接一段时间后断开

# 客户端主动保活(推荐)
# ~/.ssh/config
Host *
    ServerAliveInterval 60        # 每 60 秒发一个心跳
    ServerAliveCountMax 3         # 3 次无响应才断开

# 服务端也可以配(对所有客户端生效)
# /etc/ssh/sshd_config
ClientAliveInterval 60
ClientAliveCountMax 3

ServerAliveInterval(客户端)和 ClientAliveInterval(服务端)是两个不同方向的机制,名字容易混:

ServerAliveInterval  -> 客户端配置,客户端主动探测服务器
ClientAliveInterval  -> 服务端配置,服务器主动探测客户端

断开的常见原因是中间的 NAT 或防火墙清理了空闲连接表项——心跳能让连接保持活跃。云上的 NLB/SLB 通常有 300 秒或 900 秒的空闲超时,所以 ServerAliveInterval 60 是安全的选择。

10.4 其他常见报错

# 「使用密钥仍然要输密码」
# -> 输的是【私钥的 passphrase】,不是系统密码
ssh-add ~/.ssh/id_ed25519        # 加入 agent,只需输一次(详见第 17 篇)

# 「Too many authentication failures」
# -> 本地密钥太多,agent 逐个尝试超过了 MaxAuthTries
ssh -o IdentitiesOnly=yes -i ~/.ssh/specific_key user@server

# 「kex_exchange_identification: Connection closed by remote host」
# -> 可能是 fail2ban 封了你,或 MaxStartups 限流
fail2ban-client status sshd | grep -A2 Banned

# 「no matching host key type found」(连老设备时)
# -> 老设备只支持已废弃的算法,OpenSSH 8.8+ 默认禁用了 ssh-rsa
ssh -o HostKeyAlgorithms=+ssh-rsa -o PubkeyAcceptedAlgorithms=+ssh-rsa user@old-device

11. 实用技巧

# 执行远程命令,不进入交互
ssh user@server "df -h; free -m"

# 需要 sudo 时强制分配终端
ssh -t user@server "sudo systemctl restart nginx"
#     ^^ 不加 -t 会报 "sudo: no tty present"

# 批量在多台机器执行(简单场景够用,复杂的用 Ansible)
for h in web1 web2 web3; do
    echo "===== $h ====="
    ssh -o ConnectTimeout=5 "$h" "uptime" 2>&1
done

# 并行执行(第 5 篇的 xargs -P)
echo "web1 web2 web3" | tr ' ' '\n' | \
    xargs -P 8 -I{} sh -c 'echo "== {} =="; ssh {} uptime'

# 测试连接是否可用(不执行命令)
ssh -T git@github.com
# Hi username! You've successfully authenticated...

# 只测试认证能否成功
ssh -o BatchMode=yes -o ConnectTimeout=5 user@server true && echo "OK"
#      ^^^^^^^^^^^^^ 禁止任何交互提示,适合脚本

# 在远端保持任务运行(第 6 篇讲的 tmux)
ssh -t user@server "tmux new -As work"
#                          ^^^^ 存在就 attach,不存在就创建

# 看 SSH 登录记录
last -n 20                        # 成功登录
lastb -n 20                       # 失败登录
journalctl -u sshd --since today | grep -i accepted
who                               # 当前在线用户

ssh -o BatchMode=yes 在脚本里很重要——它禁止所有交互提示(密码输入、host key 确认),认证失败就直接返回非 0,不会挂在那里等输入把脚本卡死。

12. 面试题

Q:SSH 的连接过程分几个阶段?

三个阶段。① 密钥交换:双方协商加密算法,用 DH/ECDH 算出会话密钥,之后所有流量对称加密。② 服务器认证:服务器出示 host key,客户端查 ~/.ssh/known_hosts 比对指纹——这是客户端验证服务器,失败报 Host key verification failed。③ 用户认证:客户端按 publickey → keyboard-interactive → password 顺序尝试——这是服务器验证客户端,失败报 Permission denied注意 ② 和 ③ 的认证方向是相反的,搞清这一点才能区分两类报错。排查任何 SSH 问题的第一步都是 ssh -v,它会明确显示卡在哪个阶段。

Q:SSH 密钥登录的原理是什么?服务器会拿到私钥吗?

不会,私钥从不离开本机。流程是:客户端声明要用的公钥 → 服务器在 ~/.ssh/authorized_keys 里查找该公钥,找到后生成一段随机数据 → 客户端用私钥对(随机数据 + session_id)做数字签名并发回 → 服务器用公钥验签。所以本质是「签名 + 验签」,不是「公钥加密、私钥解密」——很多老教程的描述不准确,而且 Ed25519 这类算法根本不能用于加密,只能签名。这个设计的关键优势是:服务器只存公钥,即使服务器被完全攻破、authorized_keys 泄露,攻击者也无法用它登录任何地方;而 /etc/shadow 泄露后弱密码几小时内就能离线爆破出来。

Q:为什么第一次 SSH 连接会提示确认指纹?这个机制有什么问题?

因为 SSH 的 host key 是自签发的,没有 CA 背书,客户端无法像 HTTPS 那样通过证书链自动验证服务器身份。所以采用 TOFU(Trust On First Use):第一次连接由用户人工确认,指纹存入 known_hosts,之后自动比对。问题就在「首次」——如果第一次连接时已经被中间人劫持,你信任的是攻击者的密钥,而且之后再也不会有任何警告。正确做法是带外验证指纹:通过云控制台或其他可信途径在服务器上执行 ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub 拿到指纹,连接时把指纹粘进提示框而不是输 yes;或者用 ssh-keyscan 提前预置并与官方公布的指纹核对(GitHub 等大厂会公开发布)。

Q:ssh -Lssh -R 的区别?分别用在什么场景?

-L(本地转发)把本地端口的流量通过隧道转发到从 SSH 服务器视角可达的地址。典型场景:数据库只监听 127.0.0.1:3306 时,ssh -L 3306:127.0.0.1:3306 user@db-server 就能用本地 GUI 工具连接,数据库完全不需要暴露到网络上;或者通过跳板机访问内网 ssh -L 8080:10.0.0.100:80 user@bastion-R(远程转发)反过来,把远端端口的流量转发到本机可达的地址,用于内网穿透——本机没公网 IP 时让公网服务器代理流量进来。关键理解:目标地址:端口 是在隧道的另一端解析和连接的。另外 -R 默认只监听远端的 127.0.0.1,要让外部访问需要服务端配 GatewayPorts但这会把内网服务暴露到公网,风险很高,生产环境应该用专门的隧道工具配合认证。

Q:ssh -A(Agent Forwarding)有什么风险?有更好的替代方案吗?

风险是:跳板机上的 root 或任何能读你 agent socket 的人,可以在你连接期间借用你的 agent 去认证任何地方——相当于临时获得了你私钥的使用权。攻击者只需找到 /tmp/ssh-*/agent.*,设置 SSH_AUTH_SOCK 就能以你的身份登录其他服务器。更好的方案是 ProxyJump-J):它把跳板机只当作一个 TCP 转发通道,认证是本机直接与最终目标做的,私钥和 agent 从不暴露给跳板机,跳板机只能看到加密流量。**规律:需要多跳时一律用 ProxyJump,不用 -A。**只有在跳板机上确实需要执行 git clone 之类操作时才不得不用 -A,此时应配合 ssh-add -c(每次使用需人工确认)。

Q:Permission denied (publickey) 怎么排查?

ssh -v 确认是用户认证阶段失败,看 Offering public key 显示实际提供了哪个密钥。然后按可能性排查:① 服务端权限(最常见)——~/.ssh 必须 700、authorized_keys 必须 600,而且家目录本身不能是 group/other 可写drwxrwxr-x 就会被拒绝);② 公钥没正确写入——用 ssh-keygen -lf 对比本地公钥和服务端 authorized_keys 的指纹;③ 用错了密钥(本地有多个时,见第 17 篇的 IdentitiesOnly);④ 服务端 sshd -T | grep pubkeyauth 确认配置;⑤ SELinux 上下文(restorecon -Rv ~/.ssh)。最快的办法是看服务端日志 journalctl -u sshd -n 20——它经常直接给出 Authentication refused: bad ownership or modes for directory /home/user 这样的明确原因。

Q:SSH 连接特别慢(十几秒才进去),可能是什么原因?

最常见是服务端的反向 DNS 查询:sshd 默认会对每个连接的来源 IP 做反向解析,如果 DNS 不可达或很慢,登录会卡 10~30 秒。解法是服务端配 UseDNS no。第二常见是客户端尝试 GSSAPI 认证,配 GSSAPIAuthentication noPreferredAuthentications=publickey。排查手法是 ssh -vv 配合时间戳看卡在哪一步。注意这类问题的特征是「连上之后一切正常」——如果是网络带宽或丢包问题,连上后操作也会卡。

Q:怎么加固一台对公网开放 SSH 的服务器?

按有效性排序:① 关闭密码登录PasswordAuthentication no + KbdInteractiveAuthentication no),这是最重要的一项——密钥无法被在线爆破;② 限制来源 IP,用 iptables 或云安全组只放通办公网段/跳板机,或者业务机干脆不开公网 22;③ 禁止 root 直接登录PermitRootLogin no);④ 白名单用户AllowUsers/AllowGroups);⑤ 限制认证尝试(MaxAuthTries 3MaxStartups);⑥ 装 fail2ban 自动封禁爆破 IP(记得配 ignoreip 白名单,否则可能把自己封在外面);⑦ 关掉不需要的功能(AllowTcpForwarding noX11Forwarding no)。改端口的作用要正确认识——它只能过滤无脑扫描,nmap 几秒就能扫出来,不是真正的安全措施。更高要求的场景可以用 AuthenticationMethods publickey,keyboard-interactive 做双因素,或用 FIDO2 硬件密钥(ssh-keygen -t ed25519-sk,私钥无法从硬件导出)。

Q:改 sshd_config 时怎么避免把自己锁在外面?

四条纪律。① 改完先 sshd -t 验证语法——语法错误会导致 sshd 启动失败;② systemctl restart sshd 不影响已建立的连接,所以当前会话千万不要退出,它是唯一的救命通道;③ 重启后另开一个终端验证能连上,确认无误才关闭旧会话;④ 更稳妥的做法是先在另一个端口起一个测试实例 /usr/sbin/sshd -D -p 2223 -f /etc/ssh/sshd_config_new,验证通过再动主服务。另外注意 Match 块必须放在文件最后——它之后的所有配置都属于该块,放在中间会把全局配置吃进去。

Q:ControlMaster 是什么?有什么收益?

SSH 连接复用。开启后第一次连接会创建一个 Unix socket,后续所有到同一目标的 ssh/scp/rsync 复用已有的 TCP 连接和已完成的认证,不需要重新握手。实测第二次连接从 800ms 降到 40ms,快约 20 倍。配置:ControlMaster auto + ControlPath ~/.ssh/cm-%C + ControlPersist 10m。这对 Ansible、部署脚本、频繁 git push 是质变——成百上千次 SSH 调用的场景下节省的时间非常可观。ControlPath 必须包含用户/主机/端口的区分(用 %r@%h:%p 或哈希 %C),否则不同目标会共用 socket 连错机器;socket 路径过长会报 unix_listener: too long,用 %C 可以缩短。


上一篇:Linux-15 定时任务、日志与包管理 | 下一篇:Linux-17 SSH 密钥管理与多账号实战