Skip to content

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
fi

ConnectTimeout=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.129

ssh-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 里加载了哪些 key

sshd 对权限的要求有多严

曾经碰到一个情况:公钥是对的,私钥也是对的,密码也确认没问题,但就是通不过密钥认证。最后在 /var/log/secure 里看到一行 Authentication refused: bad ownership or modes for directory——~/.ssh 的权限设成了 775,sshd 看到同组可写就直接拒绝了。

sshd 对文件权限的要求:

路径权限为什么
~/.ssh700不能让其他人或同组进入这个目录
~/.ssh/authorized_keys600不能让别人往里面加公钥
私钥文件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登录用户名
PortSSH 端口
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-01prod-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 会被替换成 HostNamePort 的值。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 sshd

Debian/Ubuntu 系:

bash
tail -f /var/log/auth.log

systemd 环境:

bash
journalctl -u sshd -f

服务端权限检查

如果日志里出现了 bad ownership or modes,检查这几项:

bash
ls -ld ~                     # 家目录不能对组或其他人可写
ls -ld ~/.ssh                # 应该是 700
ls -l ~/.ssh/authorized_keys # 应该是 600

SELinux 环境里权限对了但上下文不对也会失败:

bash
restorecon -Rv ~/.ssh

修改 sshd 配置时的安全习惯

改完 /etc/ssh/sshd_config 后需要让服务重新加载配置:

bash
systemctl reload sshd

reload 不会断开已有的连接,但修改想生效的新连接会走新配置。操作时的习惯是:保留一个已登录的窗口不要关,另开一个终端测新配置能否登录,确认没问题再关旧窗口。这样即使配置有误导致后续连接被拒,已有的会话还在,可以回滚。