Appearance
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 是真实地址、User 是登录用户名、Port 是 SSH 端口、IdentityFile 指定用哪把私钥、ServerAliveInterval 是客户端每隔几秒发一次心跳(防止空闲连接被中间设备断开)、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 表示不执行远程命令,只建隧道;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(或者 clientspecified)。
动态转发 -D
在本地起一个 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/controlmastersControlMaster 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 表示失败或超时服务端日志
服务端日志通常能直接看到失败原因,比在客户端反复试更直截了当。RHEL/Rocky 系看 /var/log/secure,Debian/Ubuntu 系看 /var/log/auth.log,systemd 环境用 journalctl -u sshd -f:
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改配置的安全习惯
改完 /etc/ssh/sshd_config 后要让服务重新加载配置:
bash
systemctl reload sshdreload 不会断开已有的连接,但修改想生效的新连接会走新配置。操作时的习惯是:保留一个已登录的窗口不要关,另开一个终端测新配置能不能登录,确认没问题再关旧窗口。这样即使配置有误导致后续连接被拒,已有的会话还在,可以回滚——这条习惯能避免无数次"把自己关在外面"的事故。