目录

Linux-16 SSH 与远程工作流:密钥认证原理、config 的威力、三种端口转发

SSH 是服务端工程师每天都在用、但大多数人只用到 10% 的工具。这一篇把剩下的 90% 补上。

先看五个问题:

  1. 密钥认证的完整过程是什么?为什么说私钥永远不会离开本机
  2. 第一次连接时那句 The authenticity of host ... can't be established 是在防什么?
  3. ~/.ssh/config 能帮你省掉哪些事?
  4. -L-R-D 三种端口转发分别解决什么问题?
  5. 为什么 ssh 有时候要卡十几秒才出现密码提示?

1. SSH 的两层认证

一次 SSH 连接实际上做了两次方向相反的认证,很多人只关注后者:

   ① 服务端认证(客户端验证服务器)        ← 防【中间人攻击】
      服务器出示 host key,客户端在 ~/.ssh/known_hosts 里核对
              |
              v
   ② 密钥交换与加密通道建立               ← ECDH/DH,协商出会话密钥
              |
              v
   ③ 客户端认证(服务器验证你)            ← 密码 / 公钥 / 键盘交互 / GSSAPI

第 ① 步是最容易被忽略但最关键的:如果不验证服务器身份,攻击者可以伪装成目标服务器,你把密码或命令都发给了他。这就是 known_hosts 存在的全部意义。

1.1 开篇第一问:公钥认证的过程

核心结论:私钥永远不会被发送出去,服务器也永远不需要知道你的私钥。

   客户端                                          服务端
      |                                              |
      |  ① "我想用这个公钥登录"(发公钥指纹)           |
      |--------------------------------------------->|
      |                                              |  ② 在 ~/.ssh/authorized_keys
      |                                              |     里查找这个公钥
      |                                              |     找到了 -> 继续
      |  ③ 发来一个随机数(challenge)                 |
      |<---------------------------------------------|
      |                                              |
      |  ④ 用【私钥】对这个随机数签名                   |
      |     (私钥只用于计算,不发送)                  |
      |                                              |
      |  ⑤ 把签名发回去                               |
      |--------------------------------------------->|
      |                                              |  ⑥ 用【公钥】验证签名
      |                                              |     验证通过 = 对方确实持有私钥
      |  ⑦ 认证成功                                   |
      |<---------------------------------------------|

这个设计的三个直接推论

  1. 服务器被入侵,攻击者也拿不到你的私钥(它那里只有公钥)—— 这是密钥认证比密码认证安全得多的根本原因
  2. 同一个公钥可以放到一百台服务器上,泄漏其中任何一台的 authorized_keys 都无所谓
  3. 私钥文件必须严格保护600),因为它就是你的全部身份凭证
# 实际观察这个过程
ssh -v host 2>&1 | grep -E 'Offering|Server accepts|Authentication succeeded'
# debug1: Offering public key: /home/lhx/.ssh/id_ed25519 ED25519 SHA256:xxx
# debug1: Server accepts key: /home/lhx/.ssh/id_ed25519 ED25519 SHA256:xxx
# debug1: Authentication succeeded (publickey).

2. 密钥管理

2.1 生成密钥:选哪种算法

# ✅ 推荐:ed25519(现代默认)
ssh-keygen -t ed25519 -C "lhx@laptop-2026"

# 需要兼容老系统(OpenSSH < 6.5)时用 RSA,且至少 3072 位
ssh-keygen -t rsa -b 4096 -C "lhx@laptop-2026"
算法 密钥长度 安全性 速度 兼容性 建议
ed25519 固定 256 位 最快 OpenSSH 6.5+(2014) 首选
ed25519-sk 高 + 硬件 OpenSSH 8.2+ 需要 FIDO2 硬件密钥时
ecdsa 256/384/521 广泛 ⚠️ 依赖 NIST 曲线,社区信任度不如 ed25519
rsa ≥3072(4096 更好) 中高 最好 兼容老系统时用
dsa 1024 已被 OpenSSH 7.0 默认禁用

为什么 ed25519 更好:密钥和签名都很短(公钥只有一行 68 字符)、签名验签快一个数量级、抗侧信道攻击、且不依赖有争议的 NIST 曲线参数。

2.2 ssh-keygen 参数

参数 作用
-t TYPE 算法(ed25519/rsa/ecdsa
-b BITS 密钥长度(仅 RSA/ECDSA 需要)
-C COMMENT 注释(建议写 用户@设备-年份,便于日后清理)
-f FILE 输出文件名
-N PASS 密码短语(-N '' 表示无密码,自动化场景用)
-p 修改已有私钥的密码短语
-y 从私钥重新导出公钥(公钥丢了不用重新生成!)
-l -f KEY 显示指纹
-lv -f KEY 显示指纹 + ASCII 艺术图
-a N KDF 轮数(默认 16,越大越抗暴力破解,如 -a 100
-o 用新的 OpenSSH 私钥格式(现在是默认)
-R HOST 从 known_hosts 里移除某主机
-F HOST 在 known_hosts 里查找某主机
-H 把 known_hosts 里的主机名哈希化
-s/-I/-n 用 CA 签发证书(见 3.4)
# ── 常用操作 ──
ssh-keygen -t ed25519 -a 100 -C "lhx@laptop-2026" -f ~/.ssh/id_ed25519
ssh-keygen -l -f ~/.ssh/id_ed25519.pub          # 看指纹
# 256 SHA256:abc123... lhx@laptop-2026 (ED25519)
ssh-keygen -y -f ~/.ssh/id_ed25519 > id.pub     # ✅ 从私钥恢复公钥
ssh-keygen -p -f ~/.ssh/id_ed25519              # 改私钥的密码短语
ssh-keygen -t ed25519 -N '' -f ./deploy_key     # 无密码密钥(CI/自动化用)

# 为不同用途生成不同密钥(推荐做法)
ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519_github -C "github"
ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519_prod  -C "prod-servers"
ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519_ci    -C "ci-deploy" -N ''
# 好处:某个用途的密钥泄漏时,只需更换那一个

2.3 部署公钥

# ✅ 最简单:ssh-copy-id
ssh-copy-id -i ~/.ssh/id_ed25519.pub user@host
ssh-copy-id -i ~/.ssh/id_ed25519.pub -p 2222 user@host

# 手动方式(没有 ssh-copy-id 时)
cat ~/.ssh/id_ed25519.pub | ssh user@host \
  'mkdir -p ~/.ssh && chmod 700 ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys'

# 从 GitHub 拉某人的公钥(团队协作时方便)
curl -fsS https://github.com/username.keys >> ~/.ssh/authorized_keys
ssh-import-id gh:username                       # Ubuntu 上的工具

# 验证
ssh -o PreferredAuthentications=publickey -o PasswordAuthentication=no user@host 'echo OK'
#    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 强制只用公钥,确认密钥真的生效了

2.4 authorized_keys 的高级选项

这个文件不只是「一行一个公钥」,每行前面可以加限制选项,这是做受限访问的关键:

cat ~/.ssh/authorized_keys
# 基本格式:[选项] 算法 公钥 注释
ssh-ed25519 AAAAC3Nza... lhx@laptop

# ✅ 限制这个密钥只能执行特定命令(部署、备份场景的核心手段)
command="/usr/local/bin/deploy.sh",no-port-forwarding,no-agent-forwarding,no-pty,no-X11-forwarding ssh-ed25519 AAAA... ci-deploy
# 用这个密钥登录时,无论客户端敲什么命令,服务端都只执行 deploy.sh
# 原始命令存在 $SSH_ORIGINAL_COMMAND 环境变量里(脚本可以据此分派)

# ✅ 限制来源 IP
from="10.0.0.0/8,192.168.1.5" ssh-ed25519 AAAA... office-only

# ✅ 限制端口转发(只允许转发到特定目标)
permitopen="127.0.0.1:5432" ssh-ed25519 AAAA... db-tunnel-only

# 其他选项
restrict                     # ✅ 一次性禁用所有转发、pty、agent(然后按需 permit)
no-pty                       # 不分配伪终端(防止交互式 shell)
no-port-forwarding
no-agent-forwarding
no-X11-forwarding
expiry-time="20261231"       # 密钥过期时间(OpenSSH 8.2+)
environment="FOO=bar"        # 设置环境变量(需 sshd 开 PermitUserEnvironment)
tunnel="0"                   # 允许 tun 设备转发

一个实用的受限部署账号配置

# 服务端 ~deploy/.ssh/authorized_keys
restrict,command="/usr/local/bin/ci-deploy.sh",from="10.20.0.0/16" ssh-ed25519 AAAA... ci
#^^^^^^^^ 先全禁,command 限定动作,from 限定来源

# /usr/local/bin/ci-deploy.sh 里根据原始命令分派
#!/bin/bash
set -euo pipefail
case "$SSH_ORIGINAL_COMMAND" in
  "deploy "*)  exec /opt/scripts/deploy.sh "${SSH_ORIGINAL_COMMAND#deploy }" ;;
  "rollback")  exec /opt/scripts/rollback.sh ;;
  "status")    exec systemctl status myapp ;;
  *)           echo "不允许的命令: $SSH_ORIGINAL_COMMAND" >&2; exit 1 ;;
esac

2.5 权限要求:为什么这么严

chmod 700 ~/.ssh
chmod 600 ~/.ssh/id_ed25519           # 私钥
chmod 644 ~/.ssh/id_ed25519.pub       # 公钥
chmod 600 ~/.ssh/authorized_keys
chmod 600 ~/.ssh/config
chmod 644 ~/.ssh/known_hosts
chmod go-w ~                          # ✅ 家目录不能被组/其他人写

为什么家目录不能组可写? 因为如果别人能写你的家目录,他就能删掉你的 .ssh 目录再换成自己的,从而伪造 authorized_keys 登录你的账号。sshd 会主动检查这一点并拒绝服务:

# 客户端看到的现象(很有迷惑性:明明配了密钥却要输密码)
ssh -v host
# debug1: Authentications that can continue: publickey,password
# ... 然后要求输密码

# 服务端日志里才有真正的原因
sudo journalctl -u sshd | tail
# Authentication refused: bad ownership or modes for directory /home/lhx
# Authentication refused: bad ownership or modes for file /home/lhx/.ssh/authorized_keys

# 私钥权限太松时客户端会直接拒绝
ssh host
# @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
# @    WARNING: UNPROTECTED PRIVATE KEY FILE!          @
# Permissions 0644 for '/home/lhx/.ssh/id_ed25519' are too open.
chmod 600 ~/.ssh/id_ed25519           # 修复

# 一键修复所有权限
chmod 700 ~/.ssh && chmod 600 ~/.ssh/id_* ~/.ssh/authorized_keys 2>/dev/null
chmod 644 ~/.ssh/*.pub ~/.ssh/known_hosts 2>/dev/null
chmod go-w ~

2.6 ssh-agent:只输一次密码短语

私钥应该设密码短语(防止笔记本丢了直接被人用),但每次连接都输很烦。ssh-agent 把解密后的私钥放在内存里代为签名:

eval "$(ssh-agent -s)"                    # 启动 agent(会设置两个环境变量)
# Agent pid 12345
echo $SSH_AUTH_SOCK                      # /tmp/ssh-XXXX/agent.12345  <- 通信用的 socket

ssh-add ~/.ssh/id_ed25519                # 加载私钥(此时输一次密码短语)
ssh-add -l                               # ✅ 列出已加载的密钥
ssh-add -L                               # 列出公钥内容
ssh-add -d ~/.ssh/id_ed25519             # 移除一个
ssh-add -D                               # 移除全部
ssh-add -t 3600 ~/.ssh/id_ed25519        # ✅ 1 小时后自动移除(更安全)
ssh-add -x / -X                          # 锁定 / 解锁 agent(需密码)

# macOS:存进 Keychain,重启后自动加载
ssh-add --apple-use-keychain ~/.ssh/id_ed25519

# Linux 上通常由桌面环境或 systemd 用户服务自动启动
systemctl --user status ssh-agent
# 或在 ~/.bashrc 里做惰性启动
[[ -z "$SSH_AUTH_SOCK" ]] && eval "$(ssh-agent -s)" >/dev/null

agent 转发(-A)的风险,必须知道

ssh -A user@jumphost                     # 把本地 agent「转发」给远程主机
# 好处:在跳板机上可以直接 ssh 到内网机器,不用把私钥拷到跳板机
# ⚠️ 风险:跳板机上的 root(或攻破跳板机的攻击者)能通过那个 socket
#         【用你的身份签名】,即冒充你登录任何你有权限的机器
#         虽然拿不到私钥本身,但等于临时借用了你的身份

# ✅ 更安全的替代:ProxyJump(-J),见 5.4
ssh -J user@jumphost user@target         # 认证在【本地】完成,跳板机只做 TCP 转发

结论:不要习惯性使用 -A 需要经过跳板机时用 -J(ProxyJump),它不会把你的身份暴露给中间节点。

3. known_hosts 与主机认证

3.1 开篇第二问:第一次连接的指纹确认

ssh newhost
# The authenticity of host 'newhost (10.0.0.5)' can't be established.
# ED25519 key fingerprint is SHA256:abc123def456...
# This key is not known by any other names.
# Are you sure you want to continue connecting (yes/no/[fingerprint])?

这在防什么?中间人攻击(MITM)。攻击者如果能劫持网络流量,可以伪装成目标服务器:你以为在和生产服务器通信,实际上在和攻击者通信,他转发流量的同时窃取你的密码和所有命令。

SSH 的对策是 TOFU(Trust On First Use,首次使用即信任):第一次连接时把服务器的 host key 指纹记下来,之后每次连接都核对。指纹不匹配就报警。

# 严谨的做法是【通过可信渠道核对指纹】而不是直接敲 yes
# 在服务器上(通过云控制台/其他可信途径)执行:
sudo ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub
# 256 SHA256:abc123def456... root@server (ED25519)
#            ^^^^^^^^^^^^^^^ 与客户端提示的比对,一致才敲 yes

# 也可以直接输入指纹来确认(OpenSSH 8.x+ 支持)
# Are you sure you want to continue connecting (yes/no/[fingerprint])? SHA256:abc123...

3.2 known_hosts 的管理

cat ~/.ssh/known_hosts
# |1|hash1=|hash2= ssh-ed25519 AAAAC3Nza...
# ^^^^^^^^^^^^^^^^ 主机名被哈希了(HashKnownHosts yes,Debian 系默认)
# 10.0.0.5 ssh-ed25519 AAAAC3Nza...       <- 未哈希的样子

ssh-keygen -F 10.0.0.5                    # ✅ 查找某主机的记录(哈希了也能查)
ssh-keygen -R 10.0.0.5                    # ✅ 移除某主机(重装服务器后用)
ssh-keygen -H                             # 把现有记录哈希化
ssh-keyscan -t ed25519 10.0.0.5           # 获取服务器的 host key
ssh-keyscan -H 10.0.0.5 >> ~/.ssh/known_hosts     # ⚠️ 批量添加(跳过了指纹核对!)

# 系统级的 known_hosts(对所有用户生效)
sudo ssh-keyscan -H host >> /etc/ssh/ssh_known_hosts

3.3 主机密钥变更:不要无脑删掉

ssh host
# @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
# @    WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!     @
# @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
# IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!
# Offending ECDSA key in /home/lhx/.ssh/known_hosts:42

这个警告有两种可能,必须区分清楚

可能 概率 判断方式
服务器重装/重建(host key 重新生成) 你知道自己刚重装了、或云上重建了实例
IP 被复用(云上 IP 回收给了别人的机器) 检查是不是很久没连、IP 是动态分配的
中间人攻击 低但后果严重 通过其他渠道核对真实指纹
# ✅ 正确处理流程
# ① 先通过【可信渠道】拿到服务器的真实指纹(云控制台、带外管理、同事确认)
# ② 与警告里显示的指纹比对
# ③ 一致 -> 删掉旧记录
ssh-keygen -R host
ssh host                                 # 重新确认新指纹

# ❌ 不要养成这个习惯(等于放弃了 MITM 防护)
ssh -o StrictHostKeyChecking=no host

3.4 SSH 证书:大规模场景的正解

主机多了以后,known_hosts 的管理会失控(每台机器都要确认、重装就要更新所有人的文件)。SSH 证书用 CA 签名替代逐台信任:

# ── 一次性:创建 CA ──
ssh-keygen -t ed25519 -f host_ca -C 'Host CA'      # 主机 CA
ssh-keygen -t ed25519 -f user_ca -C 'User CA'      # 用户 CA

# ── 给主机签发证书 ──
ssh-keygen -s host_ca -I server01 -h -n server01.example.com,10.0.0.5 \
           -V +52w /etc/ssh/ssh_host_ed25519_key.pub
#          ^^ -h 表示这是主机证书  -n 是有效的主机名  -V 有效期
# 服务端 /etc/ssh/sshd_config:
#   HostCertificate /etc/ssh/ssh_host_ed25519_key-cert.pub
# 客户端 ~/.ssh/known_hosts 里只需一行:
#   @cert-authority *.example.com ssh-ed25519 AAAA...(host_ca 的公钥)
# ✅ 之后所有 *.example.com 的机器都自动被信任,重装只需重新签发

# ── 给用户签发证书 ──
ssh-keygen -s user_ca -I 'lhx@company' -n lhx,deploy -V +8h ~/.ssh/id_ed25519.pub
#                                         ^^^^^^^^^^ 允许登录的用户名  ^^^^ 8 小时后过期
# 服务端 sshd_config:
#   TrustedUserCAKeys /etc/ssh/user_ca.pub
# ✅ 之后不用维护 authorized_keys,且证书自动过期(离职/泄漏的风险窗口很小)

ssh-keygen -L -f ~/.ssh/id_ed25519-cert.pub        # 查看证书内容与有效期

这套机制是 Netflix BLESS、HashiCorp Vault SSH、Teleport 等方案的基础。核心价值是「凭证短期有效 + 集中签发」,比长期有效的 authorized_keys 安全得多。

4. ~/.ssh/config:省掉一半的敲键

4.1 开篇第三问:它能省掉什么

# ❌ 没有 config 时,每次都要写全
ssh -p 2222 -i ~/.ssh/id_ed25519_prod -o ServerAliveInterval=30 \
    -J bastion.example.com deploy@10.20.30.40

# ✅ 有了 config
ssh prod-web1
vim ~/.ssh/config          # 权限要 600
# ── 全局默认(放在文件【最后】,因为 SSH 采用「首次匹配优先」)──

Host bastion
    HostName bastion.example.com
    User lhx
    Port 22
    IdentityFile ~/.ssh/id_ed25519_prod

Host prod-*
    User deploy
    Port 2222
    IdentityFile ~/.ssh/id_ed25519_prod
    ProxyJump bastion                    # ✅ 自动经过跳板机
    IdentitiesOnly yes

Host prod-web1
    HostName 10.20.30.40
Host prod-web2
    HostName 10.20.30.41
Host prod-db1
    HostName 10.20.31.10
    LocalForward 15432 127.0.0.1:5432    # ✅ 连上就自动建立数据库隧道

Host github.com
    User git
    IdentityFile ~/.ssh/id_ed25519_github
    IdentitiesOnly yes

# 通配的全局设置
Host *
    ServerAliveInterval 30               # 每 30 秒发一次保活
    ServerAliveCountMax 3                # 3 次无响应就断开
    ControlMaster auto                   # ✅ 连接复用(见第 6 章)
    ControlPath ~/.ssh/cm-%r@%h:%p
    ControlPersist 10m
    AddKeysToAgent yes                   # 首次用到时自动加进 agent
    HashKnownHosts yes
    Compression no                       # 内网不压缩(CPU 更贵)
    TCPKeepAlive yes

4.2 常用字段

字段 作用
Host 别名(支持通配 *?! 排除)
HostName 真实主机名/IP(支持 %h 占位)
User 登录用户
Port 端口
IdentityFile 私钥路径(可多个)
IdentitiesOnly yes 只用指定的密钥(重要!见下)
ProxyJump 跳板机(等价 -J,可多级用逗号分隔)
ProxyCommand 自定义代理命令(更灵活,见 5.4)
LocalForward 本地端口转发(等价 -L
RemoteForward 远程转发(-R
DynamicForward 动态转发(-D
ServerAliveInterval 客户端保活间隔(秒)
ServerAliveCountMax 无响应几次后断开
TCPKeepAlive TCP 层保活
ControlMaster/ControlPath/ControlPersist 连接复用(第 6 章)
ForwardAgent agent 转发(默认 no,建议保持
StrictHostKeyChecking yes/no/ask/accept-new
UserKnownHostsFile 自定义 known_hosts 位置
LogLevel QUIET/INFO/DEBUG
Compression 压缩(慢速链路开,内网关
PreferredAuthentications 认证方式的顺序
AddKeysToAgent 自动加载密钥到 agent
RequestTTY yes/no/force/auto
RemoteCommand 连上后自动执行的命令
SetEnv / SendEnv 传递环境变量
Include 包含其他配置文件
Match 条件匹配(比 Host 更灵活)

IdentitiesOnly yes 为什么重要:不加它时,ssh 会把 agent 里所有密钥依次试一遍。如果你有 5 个密钥,而服务器的 MaxAuthTries 是 3,那么在试到正确的那个之前就已经被断开了 —— 表现为「明明配了正确的 IdentityFile 却认证失败」。

# 两个实用技巧
Host *
    Include ~/.ssh/config.d/*            # ✅ 按项目/客户拆分配置文件

Match host *.internal exec "nc -z bastion 22"
    ProxyJump bastion
# ✅ Match + exec:只在跳板机可达时才用它(在公司内网直连,在外网走跳板)

# 验证配置的实际生效值(排查配置没生效时用)
ssh -G prod-web1                         # ✅ 打印所有最终生效的选项
ssh -G prod-web1 | grep -E 'hostname|user|port|identityfile|proxyjump'

5. 端口转发

开篇第四问。三种转发解决三类完全不同的问题,用一个统一的记法理解:-L 是「把远端的服务搬到本地」,-R 是「把本地的服务送到远端」,-D 是「把整个网络出口搬到远端」

5.1 -L 本地转发:访问内网服务

   你的笔记本                    跳板机/服务器              内网数据库
   +--------------+              +----------+             +-----------+
   | localhost    |   SSH 隧道   |          |             |           |
   | :15432 ──────┼──────────────┼─────────►┼────────────►│ :5432     |
   +--------------+              +----------+             +-----------+
        ^                                                 10.0.1.20
        └── 本地程序连 127.0.0.1:15432,实际到了 10.0.1.20:5432
ssh -L 15432:10.0.1.20:5432 user@bastion
#      ^^^^^ ^^^^^^^^^^^^^^^^
#      本地端口  从【服务端】视角能访问到的目标地址:端口

# 之后本地连数据库
psql -h 127.0.0.1 -p 15432 -U dbuser mydb
mysql -h 127.0.0.1 -P 13306 -u root -p

# 常见用法
ssh -L 15432:127.0.0.1:5432 user@dbserver      # 目标就是 ssh 服务器自己
ssh -L 8080:internal-web:80 user@bastion       # 访问内网 Web
ssh -L 16379:redis.internal:6379 user@bastion  # Redis
ssh -NL 15432:127.0.0.1:5432 user@host         # ✅ -N 不执行远程命令(只做隧道)
ssh -fNL 15432:127.0.0.1:5432 user@host        # ✅ -f 转到后台
ssh -L 0.0.0.0:15432:db:5432 user@host         # ⚠️ 监听所有网卡(局域网其他人也能用)

# 在 config 里固化
Host prod-db
    HostName 10.0.1.20
    ProxyJump bastion
    LocalForward 15432 127.0.0.1:5432

5.2 -R 远程转发:把本地服务暴露出去

   你的笔记本                    远程服务器
   +--------------+              +---------------------+
   | 本地开发服务  |   SSH 隧道   |  localhost:9000     |
   | :3000  ◄─────┼──────────────┼──────────           |
   +--------------+              +---------------------+
                                  ^ 服务器上访问 127.0.0.1:9000
                                    实际到了你笔记本的 :3000
ssh -R 9000:127.0.0.1:3000 user@remote
#      ^^^^ 【远程】监听的端口  ^^^^^^^^^^^^^^ 从【客户端】视角的目标

# 典型场景
# ① 让远程服务器回调你本地的开发服务(调试 webhook)
ssh -R 9000:localhost:3000 user@remote
# 远程执行 curl localhost:9000 会打到你本地的 3000

# ② 内网机器没有公网 IP,做反向隧道让外部能访问
# 在内网机器上执行(连到有公网 IP 的服务器)
ssh -fNR 2222:localhost:22 user@public-server
# 之后在 public-server 上:ssh -p 2222 localhost  就连回了内网机器

# ⚠️ 默认远程只监听 127.0.0.1,要让其他机器也能访问需要服务端配合
# 服务端 sshd_config: GatewayPorts yes(或 clientspecified)
ssh -R 0.0.0.0:9000:localhost:3000 user@remote

5.3 -D 动态转发:SOCKS 代理

ssh -D 1080 user@bastion                 # 在本地开一个 SOCKS5 代理
ssh -fND 1080 user@bastion               # 后台运行

# 之后把流量指向它
curl --socks5-hostname 127.0.0.1:1080 https://internal.example.com
export ALL_PROXY=socks5h://127.0.0.1:1080     # h 表示【DNS 也走代理】(重要)
git config --global http.proxy socks5h://127.0.0.1:1080
# 浏览器设置 SOCKS5 代理 127.0.0.1:1080

# 用途:临时访问整个内网(不用为每个服务单独 -L)
# ⚠️ 注意 socks5 vs socks5h 的区别:
#    socks5  -> DNS 在本地解析(内网域名解析不了)
#    socks5h -> DNS 也通过代理解析  ✅

5.4 -J ProxyJump:跳板机

# ✅ 现代写法(OpenSSH 7.3+)
ssh -J user@bastion user@target
ssh -J user@bastion1,user@bastion2 user@target       # 多级跳板

# 等价的老写法
ssh -o ProxyCommand='ssh -W %h:%p user@bastion' user@target
ssh -o ProxyCommand='ssh user@bastion nc %h %p' user@target   # 更老(需要对端有 nc)

# config 里
Host target
    HostName 10.0.1.50
    ProxyJump bastion

-J 相比 ssh 到跳板机再 ssh 的三个优势

  1. 认证在本地完成 —— 私钥不用拷到跳板机,也不用开 agent 转发(跳板机上的 root 无法冒充你)
  2. scp/rsync/sftp 可以直接用 —— 因为它是一条端到端的连接
  3. 跳板机只做 TCP 转发,看不到你和目标之间的任何内容(端到端加密)
# 有了 ProxyJump,这些都能直接用
scp -J user@bastion file user@target:/path/
rsync -av -e 'ssh -J user@bastion' /local/ user@target:/remote/
sftp -J user@bastion user@target

5.5 相关参数

-N          # 不执行远程命令(纯隧道,不会给你 shell)
-f          # 后台运行(通常配 -N)
-T          # 不分配伪终端
-t          # 强制分配伪终端(远程命令需要交互时)
-q          # 静默
-v/-vv/-vvv # 调试输出

# 组合记忆
ssh -fNL 端口转发 host        # 后台跑一条本地转发隧道
ssh -fND 1080 host           # 后台跑一个 SOCKS 代理

# 管理后台隧道
ps aux | grep 'ssh -fN'
pkill -f 'ssh -fNL 15432'

# ✅ 更好的方式:用 autossh 自动重连
autossh -M 0 -fNL 15432:127.0.0.1:5432 user@host
#        ^^^^^ 不用监控端口,靠 ServerAliveInterval 检测断线

# 或者交给 systemd 管(生产上的隧道应该这样做)
# /etc/systemd/system/db-tunnel.service
# [Service]
# ExecStart=/usr/bin/ssh -NL 15432:127.0.0.1:5432 -o ServerAliveInterval=30 \
#           -o ExitOnForwardFailure=yes -i /etc/ssh/tunnel_key user@host
# Restart=always
# RestartSec=10

6. 连接复用与「为什么连接慢」

6.1 ControlMaster:第二次连接快到几乎瞬间

   没有复用:每次 ssh 都要完整走一遍
      TCP 握手 -> 密钥交换 -> 认证 -> 建立通道      (典型 300ms ~ 1s)

   有了复用:第一条连接建立后,后续连接【共享同一条 TCP+加密通道】
      直接开一个新 channel                        (典型 10ms)
# ~/.ssh/config
Host *
    ControlMaster auto                   # 自动创建/复用主连接
    ControlPath ~/.ssh/cm-%r@%h:%p       # socket 文件路径(%r 用户 %h 主机 %p 端口)
    ControlPersist 10m                   # ✅ 主连接在最后一个会话退出后再保持 10 分钟
# 效果验证
time ssh host true          # real 0m0.6s     第一次(建主连接)
time ssh host true          # real 0m0.02s    第二次(复用)  ← 快 30 倍

# 管理主连接
ssh -O check host                        # 检查主连接是否存在
ssh -O exit host                         # ✅ 关闭主连接
ssh -O stop host                         # 停止接受新的复用请求
ls ~/.ssh/cm-*                           # 看有哪些主连接

# 手动建一条主连接
ssh -MNf -S ~/.ssh/cm-manual host        # -M 做 master,-S 指定 socket
ssh -S ~/.ssh/cm-manual host 'uptime'    # 复用它

收益最明显的三个场景git push/pull(每次操作都要连一次)、Ansible/批量脚本(对同一台机器执行几十条命令)、rsync + 多次调用。

两个注意点

# ⚠️ ① ControlPath 太长会失败(Unix socket 路径有 108 字符限制)
ControlPath ~/.ssh/cm-%C                 # ✅ %C 是 (本地主机,远端主机,端口,用户) 的哈希,很短

# ⚠️ ② 主连接断了,所有复用的会话会一起断
#      不稳定网络上配合 ServerAliveInterval 使用

6.2 开篇第五问:为什么连接慢

按概率排列:

# 先定位慢在哪一步
ssh -v host 2>&1 | ts -i '%.s'           # 每行前面加上距上一行的耗时(moreutils)
# 或者
time ssh -v host true 2>&1 | tail -30

# ── 原因① 服务端 DNS 反查(最常见)──
# sshd 默认可能对客户端 IP 做反向 DNS 解析,DNS 不通就要等超时
# 服务端 /etc/ssh/sshd_config:
UseDNS no                                # ✅ 关掉(现代版本默认已是 no)

# ── 原因② GSSAPI 认证尝试 ──
# 客户端会先尝试 Kerberos,环境里没有 KDC 就要等超时
# 客户端 ~/.ssh/config:
GSSAPIAuthentication no                  # ✅

# ── 原因③ 试了太多密钥 ──
ssh -v host 2>&1 | grep -c 'Offering public key'    # 试了几个
IdentitiesOnly yes                       # ✅ 只用指定的密钥
IdentityFile ~/.ssh/id_ed25519_prod

# ── 原因④ 服务端登录脚本慢 ──
# ~/.bashrc 或 /etc/profile.d/ 里有慢操作(网络请求、大量 shell 补全、conda 初始化)
time ssh host 'true'                     # 不启动 shell
time ssh host                            # 启动完整登录 shell(差值就是 profile 的耗时)
ssh host 'bash -x -l -c true' 2>&1 | ts -i '%.s' | sort -rn | head   # 找出慢在哪一行

# ── 原因⑤ MOTD / pam_motd 慢 ──
# Ubuntu 的 update-motd 会检查系统更新、云元数据,可能卡住
sudo chmod -x /etc/update-motd.d/*       # 禁掉
ssh -o LogLevel=QUIET host               # 或者客户端不显示

# ── 原因⑥ IPv6 优先但不通 ──
ssh -4 host                              # 强制 IPv4
# config: AddressFamily inet

# ── 原因⑦ 服务端 entropy 不足(老系统/虚拟机)──
cat /proc/sys/kernel/random/entropy_avail    # < 200 就可能有问题
sudo apt install haveged                     # 补熵(现代内核已很少遇到)

一份「快速连接」的客户端配置

Host *
    GSSAPIAuthentication no
    IdentitiesOnly yes
    ControlMaster auto
    ControlPath ~/.ssh/cm-%C
    ControlPersist 10m
    ServerAliveInterval 30
    ServerAliveCountMax 3
    Compression no
    AddressFamily inet

6.3 保活:防止连接被中断

# 客户端侧(推荐)
ServerAliveInterval 30                   # 每 30 秒发一个「你还在吗」
ServerAliveCountMax 3                    # 3 次没回应就断开(即 90 秒判死)

# 服务端侧
# /etc/ssh/sshd_config
ClientAliveInterval 30
ClientAliveCountMax 3

# TCPKeepAlive 与上面的区别
TCPKeepAlive yes            # TCP 层的 keepalive,能被 NAT/防火墙识别,但间隔由内核决定(默认 2 小时)
ServerAliveInterval 30      # ✅ SSH 应用层的,走加密通道,间隔可控,更可靠

# 为什么需要:NAT 网关/云负载均衡通常会在【空闲 5~30 分钟】后清掉连接跟踪表项,
#            此时连接会「静默死亡」—— 你敲键盘没反应,也没有报错

7. 文件传输

# ── scp(简单但已不推荐)──
scp file user@host:/path/
scp -r dir user@host:/path/
scp -P 2222 -i ~/.ssh/key file user@host:/path/
scp user@host:/path/file ./                     # 拉取
scp -3 host1:/f host2:/f                        # 两台远程机之间(经过本地)
scp -C file host:/path                          # 压缩

# ⚠️ OpenSSH 9.0+ 默认改用 SFTP 协议实现 scp,有行为变化:
#    - 远程通配符展开的行为不同(老版本在远程 shell 展开,新版本不展开)
#    - 某些边界情况下的路径处理不同
scp -O file host:/path                          # -O 强制用老的 scp 协议
# ✅ 结论:新代码一律用 rsync 或 sftp

# ── rsync over ssh(推荐,见 12 篇)──
rsync -avP -e 'ssh -p 2222' /local/ user@host:/remote/
rsync -avP -e 'ssh -J bastion' /local/ user@target:/remote/
rsync -avP --rsync-path='sudo rsync' /local/ user@host:/root-only/

# ── sftp(交互式/批处理)──
sftp user@host
# get / put / ls / lls / cd / lcd / mkdir / rm / bye
sftp -b - user@host <<'EOF'
cd /upload
put local.tar.gz
chmod 644 local.tar.gz
EOF

# ── 通过 ssh 直接传流(不落中间文件,12 篇讲过)──
tar -czf - /data | ssh host 'tar -xzf - -C /restore'
ssh host 'cat /var/log/big.log' | gzip > local.log.gz
mysqldump db | ssh host 'mysql db'
dd if=/dev/sda bs=4M | ssh host 'dd of=/backup/disk.img bs=4M'

8. sshd 加固

8.1 改配置前的保命步骤

# ⚠️ sshd 配置改错会把自己锁在外面。铁律:
# ① 先测语法
sudo sshd -t                                    # ✅ 测试配置文件语法
sudo sshd -T                                    # 打印所有最终生效的配置值
sudo sshd -T | grep -iE 'permitrootlogin|passwordauth|port'

# ② 保留当前会话不要退出,用【新窗口】测试新连接
# ③ 或者加一个定时回滚的保险
sudo cp /etc/ssh/sshd_config /etc/ssh/sshd_config.bak
sudo sh -c 'sleep 600 && cp /etc/ssh/sshd_config.bak /etc/ssh/sshd_config && systemctl reload sshd' &
# 改完并确认新连接可用后,kill 掉这个保险任务

# ④ 用 reload 而不是 restart(reload 不会断开已有连接)
sudo systemctl reload sshd
# ⑤ 在【新终端】验证
ssh -v user@host 'echo OK'

8.2 加固清单

sudo vim /etc/ssh/sshd_config
# ── 认证 ──
PermitRootLogin no                  # ✅ 禁止 root 直接登录(用普通用户 + sudo)
# PermitRootLogin prohibit-password  # 或者只允许 root 用密钥(自动化场景)
PasswordAuthentication no           # ✅ 禁用密码认证(改用密钥)—— 单条最有效的加固
KbdInteractiveAuthentication no     # 一并禁掉键盘交互(否则 PasswordAuthentication no 可能被绕过)
ChallengeResponseAuthentication no  # 老版本的写法
PubkeyAuthentication yes
PermitEmptyPasswords no
MaxAuthTries 3                      # ✅ 认证失败 3 次就断开
MaxSessions 5
MaxStartups 10:30:60                # 未认证连接的并发限制(防资源耗尽)
LoginGraceTime 30                   # ✅ 30 秒内没完成认证就断开

# ── 访问控制 ──
AllowUsers lhx deploy               # ✅ 白名单(只列出的用户能登录)
AllowGroups ssh-users               # 或按组(更好维护)
DenyUsers root guest
# ⚠️ AllowUsers/AllowGroups 只要设了,未列出的用户【一律拒绝】

# ── 网络 ──
Port 22                             # 改成非标准端口能减少扫描噪音,但不是真正的安全措施
AddressFamily inet                  # 只监听 IPv4(不需要 v6 时)
ListenAddress 10.0.0.5              # ✅ 只监听内网网卡(生产上很有效)
UseDNS no                           # ✅ 不做反向 DNS(加速连接)

# ── 转发与隧道 ──
AllowTcpForwarding no               # 不需要端口转发时关掉
AllowAgentForwarding no             # ✅ 建议关(agent 转发有风险)
X11Forwarding no                    # ✅ 服务器不需要图形转发
PermitTunnel no
GatewayPorts no                     # 远程转发只绑 127.0.0.1

# ── 保活与超时 ──
ClientAliveInterval 300
ClientAliveCountMax 2               # 300×2=600 秒无响应就断开(也起到空闲超时的作用)

# ── 加密算法(禁用弱算法,满足合规要求)──
KexAlgorithms curve25519-sha256,curve25519-sha256@libssh.org,diffie-hellman-group16-sha512
Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com
MACs hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com
HostKeyAlgorithms ssh-ed25519,rsa-sha2-512,rsa-sha2-256
# 检查当前支持的算法
ssh -Q kex; ssh -Q cipher; ssh -Q mac

# ── 日志 ──
LogLevel VERBOSE                    # ✅ 记录密钥指纹(审计时能知道用了哪个密钥登录)
SyslogFacility AUTH

# ── 其他 ──
Subsystem sftp internal-sftp        # 用内置 sftp(便于 chroot)
PrintMotd no
Banner /etc/ssh/banner              # 登录前的警告横幅(某些合规要求)
StrictModes yes                     # ✅ 检查家目录/密钥权限(保持开启)
IgnoreRhosts yes
HostbasedAuthentication no

8.3 Match 块:针对特定对象放宽或收紧

# 主配置之后(Match 块必须放在文件末尾,它会一直生效到下一个 Match)

# 给备份账号开一个只能 sftp 的受限环境
Match User backup
    ForceCommand internal-sftp -d /data
    ChrootDirectory /srv/sftp/%u        # ✅ 关进 chroot(看不到系统其他部分)
    AllowTcpForwarding no
    X11Forwarding no
    PasswordAuthentication no

# 允许内网用密码登录(外网仍强制密钥)
Match Address 10.0.0.0/8
    PasswordAuthentication yes

# 给 CI 账号放开转发
Match User ci-deploy
    AllowTcpForwarding yes
    PermitOpen 127.0.0.1:5432

# ⚠️ ChrootDirectory 的要求:该目录及其所有父目录必须【属主是 root 且不可被其他人写】
sudo chown root:root /srv/sftp/backup && sudo chmod 755 /srv/sftp/backup
sudo mkdir -p /srv/sftp/backup/data && sudo chown backup:backup /srv/sftp/backup/data

8.4 fail2ban:自动封禁暴力破解

sudo apt install fail2ban
sudo tee /etc/fail2ban/jail.d/sshd.local > /dev/null <<'EOF'
[sshd]
enabled = true
port = 22
maxretry = 5
findtime = 600
bantime = 3600
bantime.increment = true          # 重犯者封更久
ignoreip = 127.0.0.1/8 10.0.0.0/8
EOF
sudo systemctl enable --now fail2ban

sudo fail2ban-client status sshd              # 查看状态与已封 IP
sudo fail2ban-client set sshd unbanip 1.2.3.4 # 解封
sudo fail2ban-client set sshd banip 1.2.3.4   # 手动封

# 查看攻击情况
sudo journalctl -u sshd | grep -c 'Failed password'
sudo lastb | head -20                          # 失败的登录尝试
sudo journalctl -u sshd | grep 'Failed password' | \
     grep -oP 'from \K[0-9.]+' | sort | uniq -c | sort -rn | head
#    ^^ 统计哪些 IP 在爆破

# ✅ 更彻底的方案:从根本上减少暴露面
# ① PasswordAuthentication no(爆破无从下手)
# ② ListenAddress 只绑内网 + 通过 VPN/跳板机访问
# ③ 云安全组只放行办公网 IP

8.5 审计

# 登录成功/失败
sudo journalctl -u sshd --since today | grep -E 'Accepted|Failed'
# Accepted publickey for lhx from 10.0.0.9 port 51234 ssh2: ED25519 SHA256:abc...
#                                                            ^^^^^^^^^^^^^^^^^^^^
#          ✅ LogLevel VERBOSE 才会记录指纹 —— 能追溯到具体是哪个密钥

# 谁在线、历史登录
who -a
w
last -20
lastlog                                       # 每个用户最后登录时间
sudo lastb -20                                # 失败尝试

# 当前的 ssh 会话
sudo ss -tnp 'sport = :22'
ps -eo pid,user,tty,cmd | grep sshd

# 强制踢掉某个会话
sudo pkill -t pts/2                           # 按终端
sudo kill <sshd 子进程 PID>

9. tmux:远程工作流的必备

# ── 为什么必须用 ──
# 网络断开 = ssh 会话中断 = 正在跑的任务被 SIGHUP 杀掉(13 篇 6.2)
# tmux 让会话【活在服务器上】,断线重连后一切照旧

tmux new -s work                  # 创建名为 work 的会话
tmux ls                           # 列出所有会话
tmux a -t work                    # attach 到会话(tmux a 连最近的)
tmux kill-session -t work
tmux new -s work -d 'long-task.sh'    # 后台创建并直接跑命令

# ── 核心快捷键(前缀键默认 Ctrl-B)──
# Ctrl-B d      detach(离开但会话继续跑)  ← 最重要
# Ctrl-B c      新建窗口
# Ctrl-B n / p  下一个 / 上一个窗口
# Ctrl-B 数字   切到指定窗口
# Ctrl-B ,      重命名窗口
# Ctrl-B w      窗口列表
# Ctrl-B %      左右分屏
# Ctrl-B "      上下分屏
# Ctrl-B 方向键 在窗格间移动
# Ctrl-B z      放大/还原当前窗格
# Ctrl-B x      关闭当前窗格
# Ctrl-B [      进入复制模式(可翻页、搜索,q 退出)
# Ctrl-B ?      查看所有快捷键

# ── 一份够用的 ~/.tmux.conf ──
cat > ~/.tmux.conf <<'EOF'
set -g mouse on                   # 鼠标可选择窗格、滚动
set -g history-limit 50000        # 回滚缓冲
set -g base-index 1               # 窗口从 1 开始编号
setw -g pane-base-index 1
set -sg escape-time 10            # 减少 Esc 延迟(vim 用户必调)
set -g default-terminal "screen-256color"
bind r source-file ~/.tmux.conf \; display "reloaded"
bind | split-window -h            # 更直观的分屏键
bind - split-window -v
EOF

# ── 断线自动重连的工作流 ──
ssh -t host 'tmux new -A -s main'
#        ^^ -t 强制分配 tty
#                     ^^ -A = attach 如果存在,否则创建(幂等,非常实用)
# 放进 config
Host prod
    RequestTTY yes
    RemoteCommand tmux new -A -s main

# ── mosh:比 tmux 更进一步(移动网络/高延迟场景)──
# 基于 UDP,能扛住 IP 变化和长时间断网,本地回显让输入不卡
mosh user@host
mosh --ssh='ssh -p 2222' user@host
# ⚠️ 需要服务端也装 mosh-server,且开放 UDP 60000-61000

10. 知识点扩展

10.1 命令速查

需求 命令
生成密钥 ssh-keygen -t ed25519 -a 100 -C "用户@设备"
部署公钥 ssh-copy-id -i ~/.ssh/id_ed25519.pub user@host
看指纹 ssh-keygen -lf ~/.ssh/id_ed25519.pub
从私钥恢复公钥 ssh-keygen -y -f ~/.ssh/id_ed25519
改私钥密码 ssh-keygen -p -f ~/.ssh/id_ed25519
移除 known_hosts 记录 ssh-keygen -R host
加载到 agent ssh-add -t 3600 ~/.ssh/id_ed25519
看配置实际生效值 ssh -G host
调试连接 ssh -vvv host
跳板机 ssh -J user@bastion user@target
本地转发 ssh -fNL 15432:127.0.0.1:5432 host
远程转发 ssh -fNR 9000:localhost:3000 host
SOCKS 代理 ssh -fND 1080 host
关闭复用主连接 ssh -O exit host
测 sshd 配置 sudo sshd -t / sudo sshd -T
重载 sshd sudo systemctl reload sshd(不断开现有连接)
只用公钥测试 ssh -o PreferredAuthentications=publickey -o PasswordAuthentication=no host
支持的算法 ssh -Q kex / ssh -Q cipher / ssh -Q key
断线可恢复的会话 ssh -t host 'tmux new -A -s main'

10.2 权限要求速查

~/                       755 或 750(关键:go-w,不能被组/其他人写)
~/.ssh/                  700
~/.ssh/id_*              600      (私钥)
~/.ssh/*.pub             644
~/.ssh/authorized_keys   600
~/.ssh/config            600
~/.ssh/known_hosts       644
/etc/ssh/sshd_config     600 root:root
/etc/ssh/ssh_host_*_key  600 root:root

10.3 排查「密钥认证失败」的流程

# ① 客户端:确认用了哪个密钥、服务器接受了吗
ssh -vvv host 2>&1 | grep -E 'Offering|Server accepts|Authentications that can continue'

# ② 客户端:确认配置生效
ssh -G host | grep -E 'identityfile|user|port|hostname'

# ③ 服务端:看真正的拒绝原因(客户端看不到!)
sudo journalctl -u sshd -f
# Authentication refused: bad ownership or modes for directory /home/lhx   -> 权限问题
# Failed publickey for lhx from ... -> 公钥不匹配
# User lhx not allowed because not listed in AllowUsers -> 访问控制

# ④ 检查权限(最高频原因)
ls -ld ~ ~/.ssh ~/.ssh/authorized_keys
namei -l ~/.ssh/authorized_keys          # 03 篇的逐层权限检查

# ⑤ 确认公钥真的在服务端
ssh-keygen -lf ~/.ssh/id_ed25519.pub                    # 本地指纹
ssh host 'ssh-keygen -lf ~/.ssh/authorized_keys'        # 服务端的(对比)

# ⑥ 确认服务端配置
sudo sshd -T | grep -iE 'pubkeyauth|authorizedkeysfile|strictmodes|allowusers'

11. 面试题

Q:SSH 公钥认证的完整过程是什么?为什么说私钥不会离开本机?

五步:① 客户端声明「我要用这个公钥登录」;② 服务端在 ~/.ssh/authorized_keys 里查找这个公钥,找到则继续;③ 服务端发来一个随机数(challenge);④ 客户端用私钥对这个随机数签名;⑤ 服务端用公钥验证签名,通过即认证成功。

私钥只参与本地的签名计算,从不发送。三个直接推论:服务器被入侵也拿不到你的私钥(那里只有公钥),这是密钥认证远优于密码认证的根本原因;同一个公钥可以放到一百台机器上而不增加风险;私钥文件必须 600 保护,它就是你的全部身份。

补充:一次 SSH 连接实际有两个方向的认证 —— 除了服务器验证你,还有客户端验证服务器(host key + known_hosts),后者防的是中间人攻击。

Q:The authenticity of host can't be established 是什么机制?主机密钥变更警告该怎么处理?

这是 TOFU(Trust On First Use):首次连接时记下服务器 host key 的指纹存入 known_hosts,之后每次连接都核对。它防的是中间人攻击 —— 攻击者伪装成目标服务器,你把密码和所有命令都发给了他。

严谨做法不是直接敲 yes,而是通过可信渠道核对指纹(在服务器上执行 ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub,或从云控制台获取)后再确认。

主机密钥变更警告有三种可能:服务器重装(概率最高)、云上 IP 被回收给了别人真的遭遇中间人。正确流程是先通过其他渠道核对真实指纹,一致后再 ssh-keygen -R host 删掉旧记录。不要养成 StrictHostKeyChecking=no 的习惯,那等于彻底放弃 MITM 防护。

规模大了以后正解是 SSH 证书:用 CA 签发主机证书,客户端 known_hosts 里只放一行 @cert-authority *.example.com <CA公钥>,之后所有机器自动被信任,重装只需重新签发。

Q:ssh -A(agent 转发)有什么风险?应该用什么替代?

-A 把本地 ssh-agent 的 socket 转发到远程主机,好处是在跳板机上可以直接 ssh 到内网而不用把私钥拷过去。

风险是:跳板机上的 root(或攻破跳板机的攻击者)能通过那个 socket 用你的身份签名 —— 虽然拿不到私钥文件本身,但等于在 agent 存活期间可以冒充你登录任何你有权限的机器

替代方案是 ssh -J(ProxyJump):认证在本地完成,跳板机只做 TCP 转发,看不到任何内容(端到端加密)。它还有两个额外好处:scp/rsync/sftp 可以直接用(因为是一条端到端连接),以及支持多级跳板 -J b1,b2

Q:-L-R-D 三种端口转发分别解决什么问题?

统一记法:-L 把远端的服务搬到本地,-R 把本地的服务送到远端,-D 把整个网络出口搬到远端。

  • -L 15432:10.0.1.20:5432(本地转发):本地 15432 → 通过 SSH 隧道 → 从服务端视角访问 10.0.1.20:5432。典型用途是访问内网数据库(本地 psql -h 127.0.0.1 -p 15432
  • -R 9000:localhost:3000(远程转发):远端 9000 → 隧道 → 从客户端视角localhost:3000。典型用途是让远程服务器回调本地开发服务(调试 webhook),或给没有公网 IP 的内网机器做反向隧道
  • -D 1080(动态转发):本地开一个 SOCKS5 代理,所有走它的流量都从远端出去。用途是临时访问整个内网,不用为每个服务单独 -L

注意 -D 配代理时要用 socks5h:// 而不是 socks5:// —— 前者让 DNS 也走代理,否则内网域名解析不了。生产上的常驻隧道应该用 -fN + ExitOnForwardFailure=yes 交给 systemd 管,或用 autossh 自动重连。

Q:明明配好了密钥,为什么还是要求输密码?

按概率排查五个原因:

  1. 权限问题(最常见):~ 被组/其他人可写、~/.ssh 不是 700、authorized_keys 不是 600。sshd 的 StrictModes 会因此拒绝密钥认证。关键是客户端看不到真正原因,必须看服务端日志:journalctl -u sshd 会有 Authentication refused: bad ownership or modes for directory /home/xxx
  2. 公钥没真正部署成功(复制时换行被破坏、贴到了错误的用户家目录)
  3. 试了太多密钥被 MaxAuthTries 掐断 —— agent 里有 5 个密钥而服务端只允许 3 次尝试,正确的那个还没试到就断了。解法是 IdentitiesOnly yes + 指定 IdentityFile
  4. 服务端 AuthorizedKeysFile 被改到了别的路径sshd -T | grep authorizedkeysfile 确认)
  5. 家目录在 NFS 上且开了 root_squash,sshd 读不到 authorized_keys

排查主力是 ssh -vvv host 看客户端视角(Offering public key / Server accepts key),以及服务端的 journalctl -u sshd 看真实拒绝原因。

Q:ssh 连接要卡十几秒才出现提示,可能是什么原因?

按概率:

  1. 服务端反向 DNS 解析UseDNS yes 且 DNS 不通,要等超时)→ 服务端设 UseDNS no
  2. 客户端尝试 GSSAPI/Kerberos(环境里没有 KDC)→ 客户端设 GSSAPIAuthentication no
  3. 试了太多密钥IdentitiesOnly yes
  4. 服务端的登录脚本慢.bashrc 里有网络请求、conda 初始化、大量补全脚本)→ 对比 time ssh host true(不启动 shell)和 time ssh host(完整登录)的差值定位
  5. MOTD 生成慢(Ubuntu 的 update-motd 会查系统更新和云元数据)
  6. IPv6 优先但不通ssh -4 或配 AddressFamily inet

顺带提一个大幅提速的手段:ControlMaster auto + ControlPersist 10m 让后续连接复用同一条 TCP+加密通道,第二次连接从 600ms 降到 20ms —— 对 git push、Ansible 批量执行、多次 rsync 的收益非常明显。

Q:怎么让一个密钥只能执行特定命令?

authorized_keys 里给那一行加限制选项

restrict,command="/usr/local/bin/ci-deploy.sh",from="10.20.0.0/16" ssh-ed25519 AAAA... ci
  • command="...":无论客户端敲什么命令,服务端都只执行这个。原始命令保存在 $SSH_ORIGINAL_COMMAND 环境变量里,脚本可以据此做安全的分派(case "$SSH_ORIGINAL_COMMAND" in "deploy "*) ...
  • restrict:一次性禁用所有转发、pty、agent 转发、X11(然后按需用 permitopen 等放开单项)
  • from="CIDR":限制来源 IP
  • permitopen="127.0.0.1:5432":只允许转发到指定目标
  • expiry-time="20261231":密钥过期时间(OpenSSH 8.2+)

这是 CI 部署账号、备份账号的标准做法 —— 比给一个完整 shell 权限安全得多。更彻底的隔离可以用 sshd 的 Match User backup + ForceCommand internal-sftp + ChrootDirectory

Q:sshd 加固的关键几条是什么?改配置要注意什么?

按有效性排序:

  1. PasswordAuthentication no(单条最有效 —— 暴力破解直接无从下手),配合 KbdInteractiveAuthentication no
  2. PermitRootLogin no(用普通用户 + sudo,日志能追溯到人)
  3. AllowUsers/AllowGroups 白名单(未列出的一律拒绝)
  4. ListenAddress 只绑内网 + 通过 VPN/跳板机访问(比改端口有效得多)
  5. MaxAuthTries 3LoginGraceTime 30MaxStartups
  6. AllowAgentForwarding noX11Forwarding no、按需 AllowTcpForwarding no
  7. 禁用弱加密算法(KexAlgorithms/Ciphers/MACs
  8. LogLevel VERBOSE(会记录登录用的密钥指纹,审计时能追溯到具体密钥)

改配置的铁律:sudo sshd -t 测语法保留当前会话不退出systemctl reload sshd(reload 不断开现有连接)→ 在新终端验证 → 才关掉旧会话。高风险环境再加一个定时回滚的保险(sleep 600 && cp 备份 && reload)。


小结

  • 公钥认证的私钥永不外传(服务端发随机数,客户端签名,公钥验签)—— 所以服务器被入侵也拿不到你的身份
  • known_hosts 防的是中间人攻击(TOFU 模型);主机密钥变更要先核对真实指纹再删记录;大规模场景用 SSH 证书
  • 权限是密钥认证失败的头号原因,而真正的错误只在服务端日志里journalctl -u sshd
  • ~/.ssh/config 是最大的效率杠杆ProxyJumpLocalForwardControlMasterIdentitiesOnly 都在这里固化
  • -J 而不是 -A —— 前者认证在本地完成,跳板机偷不到你的身份
  • 三种转发-L 搬远端服务到本地、-R 送本地服务到远端、-D 整个出口搬到远端(配代理记得用 socks5h
  • ControlMaster + ControlPersist 让重复连接快 30 倍
  • 连接慢查四处UseDNSGSSAPIAuthentication、密钥数量、服务端登录脚本
  • authorized_keyscommand= + restrict + from= 是受限访问的核心手段
  • 改 sshd 配置:sshd -t 测语法 + 保留旧会话 + reload + 新终端验证

下一篇是上手部分的最后一篇:Shell 脚本工程化 —— set -euo pipefail 每一项在防什么、为什么 [[ ]][ ] 好、引号规则的完整决策树、trap 做清理、getopts 解析参数、以及用 ShellCheck 把一堆隐患自动查出来。