Appearance
01|SSH 客户端与密钥
本地虚拟机里敲命令,机器就在面前。但真实的服务器在机房或云上,摸不到键盘。要把命令发过去、把结果拿回来,靠的是 SSH。
第一次连一台服务器,输完密码却发现一行红字警告"主机指纹变了"——连还是不连?密钥配好了却登录失败,提示"permission denied"——哪里错了?这些都和 SSH 的验证机制有关。本篇讲 SSH 客户端怎么连、怎么认证、怎么排错。
一、连接的两层验证
每次 SSH 连接经历两重验证:
| 层 | 验证什么 | 证据位置 | 类比 |
|---|---|---|---|
| 服务器身份 | 这个 IP 是不是我以为的那台机器 | ~/.ssh/known_hosts | "这栋楼是我要去的那栋" |
| 用户身份 | 我有没有权限登录 | 私钥或密码 | "我有这栋楼里某个房间的钥匙" |
楼对了但没钥匙进不去;有钥匙但找错了楼更危险——你以为连的是自己的服务器,实际上是攻击者架的钓鱼机。
最基本用法
bash
ssh root@192.168.10.129 # 交互式登录
ssh root@192.168.10.129 'hostname -I' # 执行单条命令脚本中判断连接与命令
脚本里跑 SSH 时需要区分"连接不通"和"远端命令失败":
bash
# 设 ConnectTimeout=5,否则网络不可达时默认等几十秒
if ssh -o ConnectTimeout=5 root@192.168.10.129 'systemctl is-active --quiet nginx'; then
echo "nginx active"
else
echo "SSH failed or nginx inactive" >&2
fiConnectTimeout=5 是 TCP 连接阶段的超时,不是命令执行超时。脚本里不设超时,一台连不上的机器就能卡住整个批量任务。
二、known_hosts 与主机指纹验证
第一次连一台新机器:
bash
ssh root@192.168.10.129屏幕不是直接要密码,而是先问一句:
The authenticity of host '192.168.10.129' can't be established.
ED25519 key fingerprint is SHA256:xxxx...
Are you sure you want to continue connecting (yes/no/[fingerprint])?这是 SSH 在问:你确定这台机器是你要连的那台吗?它报出一串指纹(机器公钥的摘要),让你确认。输入 yes,这个指纹存进 ~/.ssh/known_hosts,下次再连就不问了——直接比对指纹,对得上才放行。
为什么要这一步?防止连错机器。比如你以为连的是自己的服务器,实际 DNS 被劫持、IP 复用,连到了攻击者架的钓鱼机上。指纹对不上,SSH 会拦下来。
主机密钥变了怎么办
某天再连这台机器,突然冒出一堆红字:
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@意思是:这台机器的指纹,和 known_hosts 里记的对不上了。两种可能:合法的(重装系统、重建云机、换了 IP 复用),或不合法的(真被劫持了)。
确认是合法变化后(比如刚重装过),删掉旧记录,让它重新认证:
bash
ssh-keygen -R 192.168.10.129下次再连,又回到第一次连接那个"确认"的流程。
自动化里别图省事关掉校验
写脚本批量连机器时,那个"yes/no"提示很烦人。有人图省事加 -o StrictHostKeyChecking=no 跳过校验——这等于把指纹校验关了,钓鱼机也能连上。临时测试可以,生产脚本长期这么用很危险。
可靠做法是提前把指纹写进 known_hosts,而不是现场信任。装机流程里把每台机器的指纹记下来,脚本用已知的指纹。ssh-keyscan 能拿到对方报的公钥,但注意它不验证真伪——对方报什么就存什么,所以得在可信网络里跑,不能从不可信来源拿指纹。
bash
ssh-keyscan -t ed25519 192.168.10.129 >> ~/.ssh/known_hosts三、密钥认证
密码登录有个麻烦:每次输密码,脚本里没法自动化;密码弱了容易被爆破;人多时密码谁都知道、改了要通知所有人。密钥认证解决这些——用一对密钥代替密码,私钥留本机、公钥放服务器,登录时自动验证,不用输密码。
生成密钥对
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 更短、更快、安全 |
-a 100 | 私钥口令的迭代轮次,防暴力破解 |
-C | 公钥注释,多把密钥时便于识别 |
-f | 输出路径,避免覆盖默认密钥 |
生成时会问要不要给私钥设口令(passphrase)。设了的话,每次用密钥得输一次口令——更安全,私钥文件泄露了没口令也用不了。嫌麻烦可以用 ssh-agent 免重复输入(后面讲)。
生成后两个文件,权限要紧:
bash
~/.ssh/id_ed25519_ops # 600(私钥,仅自己可读写,泄露=密码泄露)
~/.ssh/id_ed25519_ops.pub # 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-agent:免输 passphrase
前面给私钥设了口令,每次登录都得输一次。连十几台机器输十几遍,烦。ssh-agent 解决这个——启动一次 agent,把私钥加载进去,输一次口令,之后这个会话里所有 ssh 自动用 agent 里的密钥,不再问口令。
bash
eval "$(ssh-agent -s)" # 启动 agent
ssh-add ~/.ssh/id_ed25519_ops # 输一次口令,后续免密
ssh-add -l # 查看 agent 中已加载的密钥eval "$(...)" 是因为 ssh-agent 启动时要设几个环境变量,它输出的不是密钥而是 export SSH_AUTH_SOCK=... 之类的语句,eval 执行这些语句让当前 shell 能找到 agent。
sshd 权限要求
密钥明明配对了,却提示"Permission denied"——多半是权限问题。sshd 对 .ssh 相关目录和文件的权限检查很严格,过宽就拒绝认证(防止别人偷偷改了你的 authorized_keys):
bash
~/.ssh # 700(不能同组/其他可写)
~/.ssh/authorized_keys # 600
私钥文件 # 600
家目录 # 不能对组/其他人可写家目录这行容易被忽略——家目录对组可写,sshd 也不认。密钥对了但认证失败,先查权限和 SELinux:
bash
ls -ld ~ ~/.ssh ~/.ssh/authorized_keys # 看权限对不对
restorecon -Rv ~/.ssh # SELinux 上下文恢复(RHEL 系)四、~/.ssh/config
连一台机器要敲 ssh -i ~/.ssh/id_ed25519_ops -p 2222 ops@192.168.10.129——长、难记、易错。机器多了更烦。
ssh config 把这些参数存起来,给每台机器起个别名:
text
# ~/.ssh/config
Host test-rocky
HostName 192.168.10.129
User root
Port 22
IdentityFile ~/.ssh/id_ed25519_ops
IdentitiesOnly yes
ServerAliveInterval 30
ServerAliveCountMax 3之后直接 ssh test-rocky,所有参数自动带上。
几个常用字段:
| 字段 | 含义 |
|---|---|
Host | 本地别名(支持通配符) |
HostName | 真实地址 |
IdentityFile | 指定私钥 |
IdentitiesOnly yes | 只试配置中的私钥,不逐个试 agent 中的 |
ServerAliveInterval | 客户端心跳间隔,防空闲连接被中间设备断开 |
按环境隔离私钥
生产、测试用不同密钥,至少让测试环境密钥泄露不影响生产。用通配符批量配:
text
Host prod-*
User ops
IdentityFile ~/.ssh/id_ed25519_prod
IdentitiesOnly yes
Host test-*
User root
IdentityFile ~/.ssh/id_ed25519_test
IdentitiesOnly yesHost prod-* 匹配所有以 prod- 开头的别名,ssh prod-web-01 自动用生产密钥。
排查配置的最终值
配了一堆,不知道实际生效的是哪个。-G 显示合并所有配置后的最终值:
bash
ssh -G test-rocky | grep identityfile排查"为什么用了错的 key"时特别实用——能看到 SSH 实际拿的是哪把私钥。
五、ProxyJump 跳板访问
生产服务器经常没有公网 IP,只有一台跳板机暴露在公网。要连内部机器,得先到跳板,再从跳板连进去。
bash
# OpenSSH 7.3+ 一条命令搞定
ssh -J ops@203.0.113.10 root@10.10.1.21config 写法
text
Host jump
HostName 203.0.113.10
User ops
IdentityFile ~/.ssh/id_ed25519_jump
Host app-01
HostName 10.10.1.21
User root
ProxyJump jump之后直接 ssh app-01。
多跳板
bash
ssh -J user@jump1,user@jump2 target链路:本地 → jump1 → jump2 → target。
六、SSH 端口转发
一台数据库在内网 10.10.2.15:5432,本机直连:
bash
psql -h 10.10.2.15 -p 5432 -U deploy连不上——超时,因为本机不在那个内网里。但有一台跳板机 db-gateway,它在内网里,能连到那台数据库。
SSH 端口转发解决这个:让本机的某个端口,通过 SSH 隧道连到跳板机,再由跳板机去访问内网资源。本机连本地端口,等于在连内网。
本地转发 -L:访问远端能访问的资源
先连最简单的情况——数据库就在跳板机本机(127.0.0.1:5432):
bash
ssh -N -L 15432:127.0.0.1:5432 ops@db-gateway这条命令连上后不退(-N 表示不执行远端命令,只建隧道),本机 15432 端口被占住。另开一个终端:
bash
psql -h 127.0.0.1 -p 15432 -U deploy连上了。本机 15432 的流量,经 SSH 隧道到 db-gateway,再由 db-gateway 访问它自己的 5432。
这里有个关键认知容易踩错:127.0.0.1:5432 这个地址,是从 db-gateway 机器的视角解析的,不是从本机。所以"访问谁"要站在跳板机那边想。
数据库不在跳板机本机、而在内网另一台 10.10.2.15 时,改转发目标:
bash
ssh -N -L 15432:10.10.2.15:5432 ops@db-gatewaydb-gateway 能访问 10.10.2.15,所以这个能通。如果把 10.10.2.15 写成本机视角的地址就错了——本机访问不到 10.10.2.15,得让跳板机去访问。
加上一个安全选项:
bash
ssh -N -L 15432:10.10.2.15:5432 -o ExitOnForwardFailure=yes ops@db-gatewayExitOnForwardFailure=yes 在端口绑定失败时让 SSH 直接退出。否则绑定失败的 SSH 会在后台静默跑着,你以为端口通了,其实没绑上。
远程转发 -R:把本机的端口暴露到远端
反过来——本机起了个开发服务(8080),想让外部同事临时看一眼,但本机没公网 IP。借一台公网 VPS:
bash
ssh -N -R 18080:127.0.0.1:8080 ops@public-gateway效果:在 public-gateway 上访问 127.0.0.1:18080,流量经隧道回到本机 8080。同事连 VPS 的 18080 就看到了本机的开发服务。
动态转发 -D:SOCKS 代理
前面两种是转发一个固定端口。如果想"通过跳板机访问内网任意地址",用动态转发,相当于建了个 SOCKS 代理:
bash
ssh -N -D 127.0.0.1:1080 ops@gateway本机 1080 成了 SOCKS5 代理,浏览器或 curl 配上它,请求都经 gateway 转发:
bash
curl --socks5-hostname 127.0.0.1:1080 https://内网域名--socks5-hostname 把域名解析也交给代理端——内网域名在公网 DNS 解析不了,让 gateway 那边解析才有用。
后台运行转发
前面的命令连上后不退,占着一个终端。加 -f 让它认证成功后进后台:
bash
ssh -f -N -L 15432:10.10.2.15:5432 -o ExitOnForwardFailure=yes ops@db-gateway-f 进后台后,终端还能干别的。但要注意:这种后台转发生命周期不好管,关掉要 ps 找进程 kill,或配 ControlMaster(下一节)统一管。
七、ControlMaster:连接复用
对同一台机器,一会儿 ssh 执行命令、一会儿 scp 传文件、一会儿又 ssh 一下——每次都重新握手、认证一遍。机器多、操作频繁时,这开销积累起来明显。
ControlMaster 让多次连同一台机器时复用第一条连接——握手认证只做一次,后续连接共用这条通道。在 ssh config 里配:
text
Host *
ControlMaster auto
ControlPath ~/.ssh/controlmasters/%r@%h:%p
ControlPersist 10mbash
mkdir -p ~/.ssh/controlmastersControlMaster auto 自动复用、ControlPath 指定 socket 文件位置、ControlPersist 10m 让连接在最后一次使用后还保持 10 分钟(这段时间内再连直接复用,不用重新认证)。
管理复用连接:
bash
ssh -O check user@host # 看连接状态
ssh -O exit user@host # 主动关闭主连接八、连接排错
ssh 连不上,可能卡在好几处:网络不通、端口没开、认证失败、权限不对。光看一句"Permission denied"分不清是哪一类。SSH 自己有详细日志,让它说出来。
客户端日志
加 -v(一个 v 信息少,三个最详细):
bash
ssh -vvv root@192.168.10.129输出里几个关键字,对应不同问题:
| 日志内容 | 含义 |
|---|---|
Connection timed out | 网络不通,到不了那台机器 |
Connection refused | 网络通了,但端口没监听或被防火墙拒 |
Offering public key | 正在尝试某把公钥 |
Authentications that can continue | 服务端允许的认证方式 |
Permission denied | 认证失败(key 不对、用户不对、权限不对) |
timed out 和 refused 是网络层问题,Permission denied 是认证层问题,分开排查。
只测试密钥认证
bash
ssh \
-o PreferredAuthentications=publickey \
-o PasswordAuthentication=no \
-i ~/.ssh/id_ed25519_ops \
root@192.168.10.129确认登录成功是密钥真正生效,而非密码兜底。
服务端日志
bash
journalctl -u sshd -f # systemd 环境
tail -f /var/log/secure # RHEL 系
tail -f /var/log/auth.log # Debian 系改配置的安全习惯
bash
# reload 不中断现有连接
systemctl reload sshd保留一个已登录的窗口不要关,另开终端验证新配置能登录后再关旧窗口——这条习惯能避免无数次"把自己关在外面"的事故。