Linux-16 SSH 远程访问原理与实战
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
socks5h 的 h 表示让代理服务器做 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 -L 和 ssh -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 no 或 PreferredAuthentications=publickey。排查手法是 ssh -vv 配合时间戳看卡在哪一步。注意这类问题的特征是「连上之后一切正常」——如果是网络带宽或丢包问题,连上后操作也会卡。
Q:怎么加固一台对公网开放 SSH 的服务器?
按有效性排序:① 关闭密码登录(PasswordAuthentication no + KbdInteractiveAuthentication no),这是最重要的一项——密钥无法被在线爆破;② 限制来源 IP,用 iptables 或云安全组只放通办公网段/跳板机,或者业务机干脆不开公网 22;③ 禁止 root 直接登录(PermitRootLogin no);④ 白名单用户(AllowUsers/AllowGroups);⑤ 限制认证尝试(MaxAuthTries 3、MaxStartups);⑥ 装 fail2ban 自动封禁爆破 IP(记得配 ignoreip 白名单,否则可能把自己封在外面);⑦ 关掉不需要的功能(AllowTcpForwarding no、X11Forwarding 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 密钥管理与多账号实战
xingliuhua