Appearance
SSH 客户端与密钥
SSH 是运维日常用得最多的远程工具。这篇文章梳理客户端侧的连接方式、密钥管理、跳板访问和端口转发,顺便记一下排查连接问题时走过的弯路。
一、一份 SSH 连接里有两层验证
敲下 ssh root@192.168.10.129 的时候,实际上发生了两次"验证对方是谁"的过程:
| 验证对象 | 验证的是什么 | 证据放在哪里 |
|---|---|---|
| 服务器身份 | 连接的这个 IP 是不是我以为的那台机器 | ~/.ssh/known_hosts |
| 登录用户身份 | 我有没有权限登录这个系统账号 | 私钥文件、密码 |
这两个容易搞混。打个比方:服务器身份验证像确认"这栋楼是我要去的那栋楼",登录身份验证像确认"我有这栋楼里某个房间的钥匙"。楼对了但没钥匙,进不去;有钥匙但找错了楼,更危险。
看一个最基本的连接命令:
bash
ssh root@192.168.10.129只执行一条命令,不进入交互式 Shell:
bash
ssh root@192.168.10.129 'hostname -I'脚本里用这种写法做批量检查时,判断退出码要留意:命令执行失败、SSH 认证失败、网络超时,都会返回非 0。如果要在脚本里区分"连接不通"和"远端命令执行失败",要把连接检测和命令执行拆成两步:
bash
if ssh -o ConnectTimeout=5 root@192.168.10.129 'systemctl is-active --quiet nginx'; then
echo "nginx active"
else
# 这里不区分是 SSH 不通还是 nginx inactive
echo "ssh failed or nginx inactive" >&2
fiConnectTimeout=5 是 TCP 连接超时,不是命令执行超时。网络不通时如果不设这个值,默认会等很久(通常几十秒到几分钟,取决于系统的 TCP 重传配置)。
二、第一次连接:known_hosts 在干什么
第一次连一台新机器时,客户端会打印:
text
The authenticity of host '192.168.10.129 (192.168.10.129)' can't be established.
ED25519 key fingerprint is SHA256:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.
Are you sure you want to continue connecting (yes/no/[fingerprint])?这是 SSH 客户端告诉你:我这辈子没见过这台机器,这是它报上来的公钥指纹,你帮我看一下对不对。
输入 yes 后,这个指纹存入 ~/.ssh/known_hosts。以后每次连接,SSH 会拿 known_hosts 里存的公钥跟服务器当前报上来的公钥对比——一致就继续,不一致就报警:
text
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!翻译过来就是:之前你连的那台机器,现在换了一个身份,要么是机器重装了,要么是真的有人在中间冒充。
这个机制就是防止中间人攻击的。假设有人在网络中间架了一台机器,拦截你的 SSH 连接、假装是目标服务器,如果没有主机指纹校验,你敲的密码和命令都会落进中间人的手里。known_hosts 用"第一次看到的指纹作基准"来检测这种冒充。
当然,生产环境里主机密钥变化更多是因为重装系统、云主机重建、IP 复用。确认变化是合法的之后,删掉旧记录即可:
bash
ssh-keygen -R 192.168.10.129提前把服务器指纹写进 known_hosts,可以避免第一次连接时的交互确认:
bash
ssh-keyscan -t ed25519 192.168.10.129 >> ~/.ssh/known_hosts但这只是拿到对方报出来的公钥——指纹是不是真的,ssh-keyscan 不负责判断。严谨的做法是从装机流程、控制台元数据或 CMDB 拿到指纹后再写入。自动化脚本里直接用 ssh-keyscan 做信任写入,实际上绕过了主机验证。
StrictHostKeyChecking=no 让 SSH 跳过主机指纹验证,连上去再说。这在临时测试环境里省事,但在脚本里长期用会让主机指纹校验形同虚设。真要让自动化脚本安全运行,正确做法是把指纹管理清楚,而不是关掉检查。
三、密钥登录是怎么工作的
密码登录的问题是:长度有限、可能被暴力尝试、多台机器上要记不同密码。密钥用数学关系替代记密码——私钥自己拿着,公钥放到服务器上。
连接时,服务端用公钥加密一个随机挑战值发给客户端,客户端用私钥解密并返回正确的应答。整个过程中私钥不离开本地,挑战值每次不同,所以即使通信被截获也无法重放。
生成一对 Ed25519 密钥:
bash
mkdir -p ~/.ssh
chmod 700 ~/.ssh
ssh-keygen \
-t ed25519 \
-a 100 \
-C "ops@workstation-01" \
-f ~/.ssh/id_ed25519_ops各参数的作用:
| 参数 | 做什么 |
|---|---|
-t ed25519 | 使用 Ed25519 算法。比 RSA 短、安全性高、签名快,现在主流 Linux 发行版都支持 |
-a 100 | 私钥口令的 KDF 轮次,增大暴力破解口令的代价。只对设置了 passphrase 的私钥有意义 |
-C | 加在公钥末尾的注释,跟多把密钥时知道哪把属于谁 |
-f | 指定输出文件,避免覆盖默认的 id_ed25519 |
生成后的文件:
text
-rw------- 1 ops ops 411 May 21 10:00 /home/ops/.ssh/id_ed25519_ops
-rw-r--r-- 1 ops ops 100 May 21 10:00 /home/ops/.ssh/id_ed25519_ops.pub私钥是 600(仅自己可读写),公钥是 644(别人可读不可写但没问题,公钥本来就可以公开)。
把公钥放到目标服务器上:
bash
ssh-copy-id -i ~/.ssh/id_ed25519_ops.pub root@192.168.10.129ssh-copy-id 做的就是一件事:把本地公钥追加到远程的 ~/.ssh/authorized_keys 文件末尾。如果目标机器没有这个命令,手动操作也一样:
bash
cat ~/.ssh/id_ed25519_ops.pub | ssh root@192.168.10.129 '
umask 077
mkdir -p ~/.ssh
cat >> ~/.ssh/authorized_keys
'umask 077 保证新建的 .ssh 目录和 authorized_keys 文件只有当前用户可以读写——服务端对权限很严格,目录或文件的权限太宽的话,sshd 会直接拒绝使用它们。
用指定私钥连接:
bash
ssh -i ~/.ssh/id_ed25519_ops root@192.168.10.129如果私钥设置了 passphrase,每次输口令比较麻烦。ssh-agent 可以缓存解锁后的私钥,在当前会话里后续连接不需要再输:
bash
eval "$(ssh-agent -s)" # 启动 agent,加载环境变量
ssh-add ~/.ssh/id_ed25519_ops # 输入一次口令,agent 记住解锁后的私钥
ssh-add -l # 看 agent 里加载了哪些 keysshd 对权限的要求有多严
曾经碰到一个情况:公钥是对的,私钥也是对的,密码也确认没问题,但就是通不过密钥认证。最后在 /var/log/secure 里看到一行 Authentication refused: bad ownership or modes for directory——~/.ssh 的权限设成了 775,sshd 看到同组可写就直接拒绝了。
sshd 对文件权限的要求:
| 路径 | 权限 | 为什么 |
|---|---|---|
~/.ssh | 700 | 不能让其他人或同组进入这个目录 |
~/.ssh/authorized_keys | 600 | 不能让别人往里面加公钥 |
| 私钥文件 | 600 | 私钥泄露等于身份泄露 |
| 家目录 | 不能对组或其他人可写 | 否则别人可以改你的 .ssh 目录 |
背后的道理很简单:如果目录对同组可写,组内其他人就可以重命名你的 .ssh 目录,然后建一个新的、放上自己的公钥。sshd 拒绝这种配置是为了防止权限漏洞被利用。
四、~/.ssh/config
机器一多,每次都敲完整的 ssh -p 2222 -i ~/.ssh/prod_key user@10.x.x.x 又长又容易打错。~/.ssh/config 把这些参数固化下来,之后直接 ssh 别名:
Host test-rocky
HostName 192.168.10.129
User root
Port 22
IdentityFile ~/.ssh/id_ed25519_ops
IdentitiesOnly yes
ServerAliveInterval 30
ServerAliveCountMax 3配置项的作用:
| 字段 | 作用 |
|---|---|
Host | 本地用的别名,支持通配符(* 匹配任意) |
HostName | 真实地址,IP 或域名 |
User | 登录用户名 |
Port | SSH 端口 |
IdentityFile | 使用哪把私钥 |
IdentitiesOnly yes | 只尝试配置里指定的私钥,不试 ssh-agent 里其他的 |
ServerAliveInterval | 客户端每隔 N 秒发一次心跳,防止空闲连接被中间设备断开 |
ServerAliveCountMax | 连续几次收不到心跳响应就断开 |
IdentitiesOnly yes 值得单独说一下。ssh-agent 里加载的私钥多的时候,客户端可能逐把试过去,试到某一把触发服务端的 Too many authentication failures 就断了。指定 IdentitiesOnly yes 后只试配置里写的那一把,排查认证问题会清晰很多。
按环境拆配置,用通配符匹配:
Host prod-*
User ops
IdentityFile ~/.ssh/id_ed25519_prod
IdentitiesOnly yes
Host test-*
User root
IdentityFile ~/.ssh/id_ed25519_test
IdentitiesOnly yes这样 prod-web-01 和 prod-db-02 都会走生产环境的 key,test-* 走测试环境的 key。按环境隔离私钥的习惯,至少能让测试 key 的泄露不影响生产。
配置文件的权限也建议收紧:
bash
chmod 600 ~/.ssh/config毕竟里面写了用户名、跳板机地址、私钥路径这些信息。
想看某个别名最终展开后的所有配置项——包括配置文件、默认值和命令行参数合并后的结果:
bash
ssh -G test-rocky | less这条命令排查配置问题时很实用。比如你以为用了某把 key,实际生效的是另一把,-G 能直接看到 identityfile 字段的最终值。
五、ProxyJump:通过跳板机连接内网
生产服务器经常没有公网 IP,暴露在公网的只有一台跳板机(堡垒机)。连接流程是:先 SSH 到跳板机,再从跳板机 SSH 到目标机器。
OpenSSH 7.3 之后用 ProxyJump:
bash
ssh -J ops@203.0.113.10 root@10.10.1.21写进 ~/.ssh/config 更直观:
Host jump
HostName 203.0.113.10
User ops
IdentityFile ~/.ssh/id_ed25519_jump
IdentitiesOnly yes
Host app-01
HostName 10.10.1.21
User root
IdentityFile ~/.ssh/id_ed25519_inner
IdentitiesOnly yes
ProxyJump jump之后直接 ssh app-01,SSH 会自动先连跳板机再转发到目标。
多条跳板的情况用逗号分隔:
bash
ssh -J user@jump1,user@jump2 target链路是:本地 -> jump1 -> jump2 -> target。
老版本的 OpenSSH 可能没有 ProxyJump,只能用 ProxyCommand 实现相同效果:
Host app-01-old
HostName 10.10.1.21
User root
ProxyCommand ssh jump -W %h:%p-W %h:%p 表示"把标准输入输出转发到这个主机和端口",%h 和 %p 会被替换成 HostName 和 Port 的值。ProxyJump 本质上就是 ProxyCommand ssh -W 的简化写法。
注意 ProxyJump 只是解决了"怎么连过去"的链路问题。堡垒机还要管"谁能连、用什么身份、做什么操作"这些事,那是另一个层面的事了。
六、SSH 端口转发
端口转发把本地和远程的 TCP 端口通过 SSH 隧道连起来。常见的用场:访问只有内网 IP 的数据库、临时看内网的管理后台、或者让外网同事临时看本机开发环境的服务。
本地转发(-L):本机访问远端内网资源
bash
ssh -N \
-L 15432:127.0.0.1:5432 \
-o ExitOnForwardFailure=yes \
ops@db-gateway这条命令的效果是:本机的 15432 端口被映射到了 db-gateway 这台机器能访问到的 127.0.0.1:5432。本机访问 localhost:15432,就等于访问 db-gateway 本机的 PostgreSQL。
如果数据库不在 db-gateway 上,而是在它能连通的内网另一台机器上:
bash
ssh -N \
-L 15432:10.10.2.15:5432 \
ops@db-gateway这里的一个关键点是:10.10.2.15 是从 db-gateway 这台机器的视角来解析的,不是从本机。一开始用的时候容易把方向搞反,以为填的是本机能看到的地址,实际上应该填跳板机能看到的地址。-L 的参数格式就是 本地端口:远端目标:远端端口,这里的"远端目标"是中间机视角下的地址。
参数拆解:
| 参数 | 作用 |
|---|---|
-N | 不执行远程命令,只建隧道 |
-L 15432:10.10.2.15:5432 | 本机 15432 -> 走 db-gateway 隧道 -> 10.10.2.15 的 5432 |
-o ExitOnForwardFailure=yes | 端口绑定失败时直接退出,而不是静默继续运行 |
ExitOnForwardFailure=yes 在脚本里很重要。如果不设置,端口绑定失败(比如被其他进程占用了)但 SSH 连接本身成功了,转发进程会在后台静默运行,后面排查时会发现端口通了但不是预期的服务。
远程转发(-R):让远端访问本机服务
方向反一下——把本机或本机能访问到的服务暴露到远端:
bash
ssh -N \
-R 18080:127.0.0.1:8080 \
ops@public-gateway执行后,在 public-gateway 上访问 127.0.0.1:18080,流量回到本机的 8080。
一个具体场景:内网开发机上跑了一个 Web 服务,临时给外网同事看。用远程转发把一台有公网 IP 的 VPS 上的端口映射到本地开发的端口,同事访问 VPS 的地址就能看到开发机上的服务。
远程转发有个默认限制:目标机器上的监听只绑在 127.0.0.1,也就是说只有目标机器自己可以访问这个被映射的端口。如果希望目标机器所在的网络里其他机器也能访问这个映射端口,要在 sshd 配置里打开 GatewayPorts yes(或者 GatewayPorts clientspecified)。
动态转发(-D):SOCKS 代理
在本地起一个 SOCKS 代理端口:
bash
ssh -N -D 127.0.0.1:1080 ops@gateway配置浏览器或应用使用 SOCKS5 代理 127.0.0.1:1080 后,这些应用的网络请求都会通过 gateway 那台机器转发出去。等于把 gateway 当成上网出口。
测试代理是否生效:
bash
curl --socks5-hostname 127.0.0.1:1080 https://example.com--socks5-hostname 和 --socks5 的区别在于域名解析发生在哪一端。--socks5 是本机解析域名、代理只负责转发 IP 包;--socks5-hostname 是域名解析也交给代理端处理。内网里有些域名在公网 DNS 解析不了,要用 --socks5-hostname 把解析也送到远端。
隧道放后台
加 -f 让 SSH 认证成功后进入后台:
bash
ssh -f -N \
-L 15432:127.0.0.1:5432 \
-o ExitOnForwardFailure=yes \
ops@db-gateway-f 必须在命令行上有 -N 或者指定了远程命令时才能用,因为它要先完成认证,然后把自己放到后台。
七、连接复用:ControlMaster
短时间内多次连接同一台机器时,每次都要重新走 TCP 握手和密钥认证。ControlMaster 用第一条连接的通道来承载后续连接,省掉握手开销:
Host *
ControlMaster auto
ControlPath ~/.ssh/controlmasters/%r@%h:%p
ControlPersist 10m先建目录:
bash
mkdir -p ~/.ssh/controlmasters配置说明:
| 配置 | 作用 |
|---|---|
ControlMaster auto | 自动尝试复用已有连接,没有就新建 |
ControlPath | 控制 socket 的存放路径。%r=用户名,%h=目标主机,%p=端口 |
ControlPersist 10m | 主连接在最后一个会话结束后继续保持 10 分钟 |
管理已有的复用连接:
bash
ssh -O check user@host # 看复用连接的状态
ssh -O exit user@host # 主动关掉主连接(连同所有复用的会话)八、连接排错
围绕连接失败的问题,可以按从外到内的顺序查。
客户端侧:看清到底发生了什么
-v 输出握手、认证每一步的日志:
bash
ssh -vvv root@192.168.10.129日志里重点关注这几类信息:
| 日志关键字 | 大概含义 |
|---|---|
Offering public key | 客户端正在尝试某把公钥 |
Authentications that can continue | 服务端允许哪些认证方式 |
Permission denied | 认证失败,可能是用户、key、权限 |
Connection timed out | 网络不通,查路由、防火墙、安全组 |
Connection refused | 网络可达但端口没有监听或被主动拒 |
排查"到底是 key 的问题还是密码兜底成功了"时,可以强制只走公钥认证:
bash
ssh \
-o PreferredAuthentications=publickey \
-o PasswordAuthentication=no \
-i ~/.ssh/id_ed25519_ops \
root@192.168.10.129这样就能确认:刚才登录成功是因为 key 生效了,还是因为密码兜底成功了(而 key 其实没配好)。
网络侧:端口是否可达
bash
nc -vz 192.168.10.129 22如果没有 nc,Bash 内置的 /dev/tcp 也能临时测:
bash
timeout 3 bash -c '</dev/tcp/192.168.10.129/22'
echo "$?" # 0 表示 TCP 建连成功,非 0 表示失败或超时服务端侧:sshd 日志
服务端日志通常能直接看到失败原因,比在客户端反复试更直截了当。
RHEL/Rocky 系:
bash
tail -f /var/log/secure | grep sshdDebian/Ubuntu 系:
bash
tail -f /var/log/auth.logsystemd 环境:
bash
journalctl -u sshd -f服务端权限检查
如果日志里出现了 bad ownership or modes,检查这几项:
bash
ls -ld ~ # 家目录不能对组或其他人可写
ls -ld ~/.ssh # 应该是 700
ls -l ~/.ssh/authorized_keys # 应该是 600SELinux 环境里权限对了但上下文不对也会失败:
bash
restorecon -Rv ~/.ssh修改 sshd 配置时的安全习惯
改完 /etc/ssh/sshd_config 后需要让服务重新加载配置:
bash
systemctl reload sshdreload 不会断开已有的连接,但修改想生效的新连接会走新配置。操作时的习惯是:保留一个已登录的窗口不要关,另开一个终端测新配置能否登录,确认没问题再关旧窗口。这样即使配置有误导致后续连接被拒,已有的会话还在,可以回滚。