免密登录失败时,ssh 只会给一句 Permission denied (publickey)。这行字把整条链路压成了一个结果,而链路上有五个环节各自都能产出同一句话:客户端没提供对的密钥、服务端没找到 authorized_keys、权限检查没通过、认证方式没协商上、密钥格式不被接受。判定只能靠 -vvv 的逐步输出。
1. 完整链路:五步
| 步骤 | 发生位置 | 关键日志 | 失败表现 |
|---|---|---|---|
| 1 协商算法 | 双方 | peer server KEXINIT |
算法不匹配,直接断开 |
| 2 选密钥 | 客户端 | Offering public key |
一个都没提供 |
| 3 试探认证 | 双方 | Server accepts key |
提供了但被拒 |
| 4 权限检查 | 服务端 | Authentication refused: bad ownership or modes |
StrictModes 拒绝 |
| 5 认证完成 | 双方 | Authentication succeeded |
停在 Authentications that can continue |
第 2 步由客户端决定,第 3、4 步由服务端决定。所以判断方向的第一条规则是:-vvv 里有没有 Offering public key——没有就是客户端的问题,有就是服务端的问题。
2. -vvv 里只看六行
$ ssh -vvv -p 22 root@TARGET 2>&1 | grep -E 'Offering|accepts|Authentications that can continue|Server host key|Trying private key|refused'
| 行 | 含义 | 下一步 |
|---|---|---|
Offering public key: ... ED25519 SHA256:xx |
客户端选中并提供了这把 | 记下指纹,与 authorized_keys 比对 |
Server accepts key: ... |
服务端认为这把可用 | 接着看是否走到 Authentication succeeded |
Authentications that can continue: publickey |
服务端允许的方式 | 说明公钥认证没被禁 |
Authentications that can continue: password |
只允许密码 | 服务端关了 publickey |
Authentication refused: bad ownership or modes for file ... |
权限检查失败 | 按第 3 节修权限 |
no such identity: /path |
客户端指定的私钥不存在 | 检查 -i 或 config |
"提供了但被拒"与"根本没提供"是两条完全不同的排查路线,靠的就是这几行区分。只 -v 看不到 Offering public key 的指纹,必须 -vvv。
3. 权限:服务端的硬性检查
StrictModes(默认 yes)要求家目录、.ssh、authorized_keys 的属主与权限都在允许范围内。
| 对象 | 要求 | 不满足时 |
|---|---|---|
| 家目录 | 属主是本人,group/other 不可写 | bad ownership or modes |
~/.ssh |
700,属主本人 |
拒绝读取 |
~/.ssh/authorized_keys |
600,属主本人 |
拒绝读取 |
| 私钥 | 600 |
客户端 UNPROTECTED PRIVATE KEY FILE 警告并拒绝使用 |
$ chmod 700 ~/.ssh && chmod 600 ~/.ssh/authorized_keys ~/.ssh/id_ed25519
$ chown -R "$USER":"$USER" ~ ~/.ssh
$ ls -ld ~ ~/.ssh ~/.ssh/authorized_keys
注意家目录权限是 755 或 750 都没问题,只要 group/other 不带写位。775 的家目录就足以让 StrictModes 拒绝——这是"root 能登、普通用户登不上"的常见原因。
SELinux 与 AppArmor 是第二层:SELinux 下文件必须带 ssh_home_t 上下文,从别处拷来的 authorized_keys 可能带错标签。
$ restorecon -Rv ~/.ssh
$ ls -Z ~/.ssh/authorized_keys
属主错在"用 root 建了目录再改属主"的场景里很常见:chown 只改到 .ssh 而漏了里面的文件,或反之。
4. 密钥格式:三种静默失败
| 问题 | 表现 | 处理 |
|---|---|---|
新格式私钥(BEGIN OPENSSH PRIVATE KEY)给旧客户端 |
旧 ssh 不认,报 invalid format |
用 ssh-keygen -p -m PEM 转回 PEM |
| 公钥末尾缺换行 | authorized_keys 后一条被拼进来 |
echo >> file 补齐 |
| 复制时断行/加了注释 | 指纹不匹配,Offering 有但被拒 |
用 ssh-copy-id 或校验指纹 |
$ ssh-keygen -l -f ~/.ssh/id_ed25519.pub # 本地公钥指纹
$ awk '{print $2}' ~/.ssh/authorized_keys | while read k; do echo "$k" | ssh-keygen -lf -; done
两侧指纹逐条比对,比肉眼读 base64 可靠得多。authorized_keys 里一行只能有一条公钥;把两条粘在一行(中间没换行)时,整行被当成一条无效记录,两把钥匙同时失效——这是最难发现的一类。
5. 多密钥与 ~/.ssh/config
客户端默认会依次尝试 ~/.ssh/id_rsa、id_ecdsa、id_ed25519 等。机器上有多个同名或用途不同的密钥时,容易"用了错的那把"。
Host blog
HostName TARGET
Port 22
User root
IdentityFile ~/.ssh/id_ed25519_blog
IdentitiesOnly yes
Host * !blog
ServerAliveInterval 30
ServerAliveCountMax 3
| 指令 | 作用 | 不加会怎样 |
|---|---|---|
IdentityFile |
指定用哪把私钥 | 按默认顺序盲试 |
IdentitiesOnly yes |
只提供显式指定的密钥 | agent 里的密钥也会被提供给服务端,可能触发失败计数 |
ServerAliveInterval |
发心跳保活 | 中间设备静默断连,表现为卡住 |
AddKeysToAgent |
首次使用后加入 agent | 每次都要输口令 |
配置匹配是首个匹配胜出,通配符按出现顺序生效,不是"最具体的胜出"。Host * !blog 里的 ! 是排除,写在 Host 行里才有效。把 Host * 放在文件最上面会把后面的具体配置全部盖掉。
6. 服务端配置
$ sudo sshd -t && sudo sshd -T | grep -Ei 'pubkey|authorizedkeys|allowusers|password|strictmodes'
| 指令 | 默认 | 排查点 |
|---|---|---|
PubkeyAuthentication |
yes |
关了则只剩密码认证 |
AuthorizedKeysFile |
.ssh/authorized_keys |
改成绝对路径后不认家目录 |
AllowUsers / DenyUsers |
空 | 名单里没这个用户,直接拒 |
PasswordAuthentication |
视发行版 | 关掉后密钥失效就彻底登不上 |
StrictModes |
yes |
权限检查的开关,不建议关 |
AllowUsers 与 DenyUsers 同时存在时,DenyUsers 优先。AllowUsers 支持 user@host 形式,写错主机段会让匹配全部失效。
sshd -T 打印的是实际生效的配置,比读 /etc/ssh/sshd_config 可靠:Include 引入的 sshd_config.d/*.conf 常常覆盖你在主文件里的修改,而且顺序靠前。
$ sudo sshd -t # 语法检查
$ sudo systemctl reload ssh # 只重载,不断开现有连接
改配置前先保持一个已登录的会话,再用另一个窗口测试。systemctl reload 比 restart 安全,因为 reload 不会踢掉已有会话——这一点在改坏了认证配置时能救回服务器。
7. 跳板与端口转发
$ ssh -J root@BASTION root@TARGET
$ ssh -L 3306:127.0.0.1:3306 blog # 本地转发:访问本机 3306 即到目标
$ ssh -N -f -L 8080:127.0.0.1:8080 blog
| 形式 | 方向 | 典型用途 |
|---|---|---|
-J / ProxyJump |
多跳登录 | 经跳板机到内网 |
-L |
本地 → 远端 | 访问内网数据库、调试端口 |
-R |
远端 → 本地 | 把本机服务暴露给远端 |
-D |
SOCKS 代理 | 浏览器走隧道 |
-L 的转发目标是从远端主机的视角解析的,所以 127.0.0.1:3306 指跳板/目标机自己的回环。写成对端内网 IP 时,解析发生在远端。-N 表示不执行远程命令,-f 表示后台,两者常一起用。
跳板场景的经典故障是 ProxyJump 通了但目标机拒绝:跳板机能连,不代表跳板机到目标机的密钥也在 authorized_keys 里。要分别在两段上单独测。
8. 超时与保活
| 参数 | 端 | 作用 |
|---|---|---|
ConnectTimeout |
客户端 | 建连超时,默认走系统 |
ServerAliveInterval / CountMax |
客户端 | 空闲时发探测,超次数断开 |
ClientAliveInterval / CountMax |
服务端 | 服务端主动探测并清理僵死会话 |
TCPKeepAlive |
双方 | TCP 层保活,默认开 |
"连上后几十秒没输出就断"通常是中间 NAT/防火墙清理了空闲连接,而两端都没开应用层保活。ServerAliveInterval 30 是最省事的一行配置。
9. 排错
| 现象 | 原因 | 处理 |
|---|---|---|
Permission denied (publickey),-vvv 无 Offering |
客户端没找到私钥 | 检查 -i、IdentityFile、文件是否存在 |
有 Offering 但 Server accepts key 缺席 |
服务端没匹配到公钥 | 比对指纹,确认 AuthorizedKeysFile 路径 |
bad ownership or modes |
家目录或 .ssh 权限过宽 |
chmod 700/600,确认属主 |
| 一个用户能登、另一个不能 | 家目录权限或 AllowUsers |
分别看两个用户的家目录模式 |
改完 sshd_config 后所有连接失败 |
语法错误或配置被 sshd_config.d 覆盖 |
先 sshd -t/sshd -T,用 reload 不用 restart |
authorized_keys 里两把钥匙都失效 |
两条粘成一行 | 每行一条,末尾补换行 |
旧客户端报 invalid format |
私钥是 OpenSSH 新格式 | ssh-keygen -p -m PEM 转换 |
| 用了 agent 里的错密钥 | 未设 IdentitiesOnly |
显式 IdentityFile + IdentitiesOnly yes |
10. 边界
| 场景 | 更合适的做法 | 理由 |
|---|---|---|
| 多台机器的密钥分发 | 配置管理工具下发 authorized_keys |
手工 ssh-copy-id 不可审计 |
| 短期访问授权 | 短期证书(SSH CA) | 静态公钥无法自动过期 |
| 需要审计每条命令 | ForceCommand + 会话录制 |
原生日志只有登录记录 |
| 高安全要求环境 | 硬件密钥 + 禁密码 + 限制源 IP | 单因子公钥仍可被复制 |
| 大量并发连接 | 连接复用 ControlMaster |
每次握手开销可观 |
11. 自测
-vvv输出里没有Offering public key,问题在客户端还是服务端?- 家目录是
775,StrictModes yes下会发生什么? authorized_keys里两条公钥粘在同一行,为什么两把都失效?Host *写在配置文件最上面会有什么后果?sshd -T与直接读sshd_config的差别在哪?- 改认证配置时,为什么优先
reload而不是restart?