Skip to content

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
fi

ConnectTimeout=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 ed25519Ed25519 算法,比 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.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-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 yes

Host 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.21

config 写法

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-gateway

db-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-gateway

ExitOnForwardFailure=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 10m
bash
mkdir -p ~/.ssh/controlmasters

ControlMaster 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 outrefused 是网络层问题,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

保留一个已登录的窗口不要关,另开终端验证新配置能登录后再关旧窗口——这条习惯能避免无数次"把自己关在外面"的事故。