Linux-16 SSH 与远程工作流:密钥认证原理、config 的威力、三种端口转发
SSH 是服务端工程师每天都在用、但大多数人只用到 10% 的工具。这一篇把剩下的 90% 补上。
先看五个问题:
- 密钥认证的完整过程是什么?为什么说私钥永远不会离开本机?
- 第一次连接时那句
The authenticity of host ... can't be established是在防什么? ~/.ssh/config能帮你省掉哪些事?-L、-R、-D三种端口转发分别解决什么问题?- 为什么
ssh有时候要卡十几秒才出现密码提示?
1. SSH 的两层认证
一次 SSH 连接实际上做了两次方向相反的认证,很多人只关注后者:
① 服务端认证(客户端验证服务器) ← 防【中间人攻击】
服务器出示 host key,客户端在 ~/.ssh/known_hosts 里核对
|
v
② 密钥交换与加密通道建立 ← ECDH/DH,协商出会话密钥
|
v
③ 客户端认证(服务器验证你) ← 密码 / 公钥 / 键盘交互 / GSSAPI
第 ① 步是最容易被忽略但最关键的:如果不验证服务器身份,攻击者可以伪装成目标服务器,你把密码或命令都发给了他。这就是 known_hosts 存在的全部意义。
1.1 开篇第一问:公钥认证的过程
核心结论:私钥永远不会被发送出去,服务器也永远不需要知道你的私钥。
客户端 服务端
| |
| ① "我想用这个公钥登录"(发公钥指纹) |
|--------------------------------------------->|
| | ② 在 ~/.ssh/authorized_keys
| | 里查找这个公钥
| | 找到了 -> 继续
| ③ 发来一个随机数(challenge) |
|<---------------------------------------------|
| |
| ④ 用【私钥】对这个随机数签名 |
| (私钥只用于计算,不发送) |
| |
| ⑤ 把签名发回去 |
|--------------------------------------------->|
| | ⑥ 用【公钥】验证签名
| | 验证通过 = 对方确实持有私钥
| ⑦ 认证成功 |
|<---------------------------------------------|
这个设计的三个直接推论:
- 服务器被入侵,攻击者也拿不到你的私钥(它那里只有公钥)—— 这是密钥认证比密码认证安全得多的根本原因
- 同一个公钥可以放到一百台服务器上,泄漏其中任何一台的
authorized_keys都无所谓 - 私钥文件必须严格保护(
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 的三个优势:
- 认证在本地完成 —— 私钥不用拷到跳板机,也不用开 agent 转发(跳板机上的 root 无法冒充你)
scp/rsync/sftp可以直接用 —— 因为它是一条端到端的连接- 跳板机只做 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:明明配好了密钥,为什么还是要求输密码?
按概率排查五个原因:
- 权限问题(最常见):
~被组/其他人可写、~/.ssh不是 700、authorized_keys不是 600。sshd 的StrictModes会因此拒绝密钥认证。关键是客户端看不到真正原因,必须看服务端日志:journalctl -u sshd会有Authentication refused: bad ownership or modes for directory /home/xxx - 公钥没真正部署成功(复制时换行被破坏、贴到了错误的用户家目录)
- 试了太多密钥被
MaxAuthTries掐断 —— agent 里有 5 个密钥而服务端只允许 3 次尝试,正确的那个还没试到就断了。解法是IdentitiesOnly yes+ 指定IdentityFile - 服务端
AuthorizedKeysFile被改到了别的路径(sshd -T | grep authorizedkeysfile确认) - 家目录在 NFS 上且开了 root_squash,sshd 读不到
authorized_keys
排查主力是 ssh -vvv host 看客户端视角(Offering public key / Server accepts key),以及服务端的 journalctl -u sshd 看真实拒绝原因。
Q:ssh 连接要卡十几秒才出现提示,可能是什么原因?
按概率:
- 服务端反向 DNS 解析(
UseDNS yes且 DNS 不通,要等超时)→ 服务端设UseDNS no - 客户端尝试 GSSAPI/Kerberos(环境里没有 KDC)→ 客户端设
GSSAPIAuthentication no - 试了太多密钥 →
IdentitiesOnly yes - 服务端的登录脚本慢(
.bashrc里有网络请求、conda 初始化、大量补全脚本)→ 对比time ssh host true(不启动 shell)和time ssh host(完整登录)的差值定位 - MOTD 生成慢(Ubuntu 的
update-motd会查系统更新和云元数据) - 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":限制来源 IPpermitopen="127.0.0.1:5432":只允许转发到指定目标expiry-time="20261231":密钥过期时间(OpenSSH 8.2+)
这是 CI 部署账号、备份账号的标准做法 —— 比给一个完整 shell 权限安全得多。更彻底的隔离可以用 sshd 的 Match User backup + ForceCommand internal-sftp + ChrootDirectory。
Q:sshd 加固的关键几条是什么?改配置要注意什么?
按有效性排序:
PasswordAuthentication no(单条最有效 —— 暴力破解直接无从下手),配合KbdInteractiveAuthentication noPermitRootLogin no(用普通用户 + sudo,日志能追溯到人)AllowUsers/AllowGroups白名单(未列出的一律拒绝)ListenAddress只绑内网 + 通过 VPN/跳板机访问(比改端口有效得多)MaxAuthTries 3、LoginGraceTime 30、MaxStartupsAllowAgentForwarding no、X11Forwarding no、按需AllowTcpForwarding no- 禁用弱加密算法(
KexAlgorithms/Ciphers/MACs) LogLevel VERBOSE(会记录登录用的密钥指纹,审计时能追溯到具体密钥)
改配置的铁律:sudo sshd -t 测语法 → 保留当前会话不退出 → systemctl reload sshd(reload 不断开现有连接)→ 在新终端验证 → 才关掉旧会话。高风险环境再加一个定时回滚的保险(sleep 600 && cp 备份 && reload)。
小结
- 公钥认证的私钥永不外传(服务端发随机数,客户端签名,公钥验签)—— 所以服务器被入侵也拿不到你的身份
known_hosts防的是中间人攻击(TOFU 模型);主机密钥变更要先核对真实指纹再删记录;大规模场景用 SSH 证书- 权限是密钥认证失败的头号原因,而真正的错误只在服务端日志里(
journalctl -u sshd) ~/.ssh/config是最大的效率杠杆:ProxyJump、LocalForward、ControlMaster、IdentitiesOnly都在这里固化- 用
-J而不是-A—— 前者认证在本地完成,跳板机偷不到你的身份 - 三种转发:
-L搬远端服务到本地、-R送本地服务到远端、-D整个出口搬到远端(配代理记得用socks5h) ControlMaster+ControlPersist让重复连接快 30 倍- 连接慢查四处:
UseDNS、GSSAPIAuthentication、密钥数量、服务端登录脚本 authorized_keys的command=+restrict+from=是受限访问的核心手段- 改 sshd 配置:
sshd -t测语法 + 保留旧会话 +reload+ 新终端验证
下一篇是上手部分的最后一篇:Shell 脚本工程化 —— set -euo pipefail 每一项在防什么、为什么 [[ ]] 比 [ ] 好、引号规则的完整决策树、trap 做清理、getopts 解析参数、以及用 ShellCheck 把一堆隐患自动查出来。
xingliuhua