这个标题我盯着看了好一会儿。当时我的个人仓库和公司 GitLab 连续报了几次 Permission denied (publickey),第一反应就是去重新生成密钥,结果折腾半天发现根本不是密钥失效,而是服务器端 authorized_keys 被重置了。后来帮同事排查类似问题,才发现“SSH 密钥可能已经过期”这句话,几乎涵盖了好几种完全不同的故障场景,判断错了方向,后面再怎么重装工具、重配客户端都是白费。
写这篇文章,就是想把这些场景摊开来一一盘清楚。不管你是开发、运维,还是平时只拿 SSH 连个服务器敲命令,只要你遇到过一次“明明密钥没删,却连不上”的诡异问题,这篇内容应该能帮你省下不少排查时间。
1. 背景与问题拆解
1.1 “过期”到底是什么意思
先说结论:SSH 密钥本身确实有理论上的过期概念,但我们平时遇到的所谓“密钥过期”,百分之九十九是下面三件事之一。
第一类,是服务器端的 authorized_keys 里已经没有你的公钥了。可能是管理员清理过账号,可能是重建用户目录时把 /home/xxx/.ssh 一并删掉,也可能是你在某个代码平台(GitLab、Gerrit)重新生成过密钥,但平台的配置没更新。这类问题的典型报错就是 Permission denied (publickey)。
第二类,是客户端存放的服务端主机指纹和实际不一致,OpenSSH 会直接拒绝连接,报错里通常有 REMOTE HOST IDENTIFICATION HAS CHANGED。这种情况很容易让人误以为“密钥过期”,但实际上是你本机的 known_hosts 文件记录过期了,需要更新的是客户端记录,不是服务端密钥。
第三类,是密码或 passphrase 失效感。有些服务器配置了账号密码过期策略,或者你给私钥设置了 passphrase 但记混了。SSH 连不上的表现也是认证失败,但报错细节和公钥问题并不一样。把这三类区分清楚,排查就有了方向。
1.2 我遇到过的三类典型连接失败
我把自己踩过的坑和帮别人处理的案例归了一下类,大概对应下面的报错形态。
第一种是 Permission denied (publickey)。这种最常见,OpenSSH 在认证阶段把能试的公钥都试了一遍,服务端全部拒绝。注意,拒绝不一定是说你公钥不对,也可能是服务端 sshd 配置不允许公钥登录,比如 PubkeyAuthentication no,或者你试图用 root 登录而服务端设了 PermitRootLogin prohibit-password 但密码认证被关闭。反正这个报错出现时,先确认“公钥有没有被服务端接受”,再确认“配置层面是否允许公钥登录”。
第二种是 WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!。这个通常发生在服务器重装系统、IP 被重新分配、或者你连接的主机名对应到了别的机器时。OpenSSH 为了避免中间人攻击,只要 host key 变了就会直接停下。还记得第一次遇到这个提醒时,我以为是密钥过期,赶紧重新生成了私钥,结果当然没用,正确做法是手动删除旧指纹,让客户端重新记录。
第三种是 Too many authentication failures。如果你在 ~/.ssh/config 里给很多主机配了不同密钥,或者 ssh-agent 里缓存了一堆私钥,OpenSSH 默认对每个密钥都尝试,超过 MaxAuthTries 上限就直接中断,很容易让人觉得“我的所有密钥都不对了”。这类问题最常见的解决办法是显式指定 -i 指定单个密钥,或者用 IdentitiesOnly yes 限制候选身份。
这三种情况,我都经历过“从头到尾换新密钥”的白费功夫。所以真要动手处理前,先花两分钟把报错完整读一遍,比什么技巧都重要。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 密钥为什么会失效:从产生到使用的全链路排查
2.1 密钥文件本身:算法、格式、权限
先看本地私钥文件是否还能被 OpenSSH 正确读取。老一点的 RSA 1024 或 DSA 密钥,在新版 OpenSSH 里会被直接拒绝。OpenSSH 8.8 之后默认禁止 RSA-SHA1 签名算法,部分旧工具连老版本 RSA 密钥也会报 no matching key exchange method。如果你的密钥有年头了,建议直接换成 ed25519。
格式问题也很隐蔽。公钥应该是单行文本,不能有换行符,也不能有多余空格。有时候从 Windows 复制公钥到网页文本框,会被自动加上一个换行,粘贴到 authorized_keys 后服务端解析失败,看起来就像“密钥失效”。我的习惯是把公钥内容先用 cat 打印,再手动选择和复制,避免复制终端输出时带上前后干扰字符。
权限问题在 Windows 上最坑。很多人在 Windows 上生成密钥,然后把整份 .ssh 目录拷贝到 Linux,这时私钥权限通常是 644,也就是组内其他用户可读。OpenSSH 对私钥权限很敏感,一旦权限过宽,它会直接忽略这把私钥而不是弹出提示,表现就是“怎么连接都报 Permission denied”。解决办法很固定:chmod 700 ~/.ssh,chmod 600 ~/.ssh/id_ed25519。
2.2 服务器端限制:authorized_keys 的权限与内容
服务端的 ~/.ssh/authorized_keys 权限同样重要。这个文件如果被其它用户可写,sshd 出于安全考虑也会拒绝使用它。标准要求是 authorized_keys 权限为 600,.ssh 目录权限为 700。我见过有人图省事直接在家目录用了 chmod -R 777,结果所有公钥登录全部失败。
另外一个容易忽略的点是用户目录本身权限不能太宽松。sshd 要求家目录不能被 group 可写,/home/user 的权限如果是 775 或 777,即使是 root 帮用户改的,sshd 也会判定不安全。最稳的组合是:家目录 755,.ssh 目录 700,authorized_keys 文件 600。
还有 sshd_config 里的几个参数。PubkeyAuthentication yes 是公钥登录的总开关;AuthorizedKeysFile .ssh/authorized_keys 指定公钥文件位置;如果用的是非默认位置或者想要多个公钥路径,这里也要对应配置。不要忘记改完 sshd_config 要 systemctl reload sshd 或重启 sshd,否则配置不会生效。
如果这些都没问题,再看一下账号本身是否正常。有些企业服务器会通过 PAM 或 /etc/shadow 设置账号过期时间,账号一旦过期,即使公钥验证通过,SSH 连接也可能会在会话建立前被拒绝。我之前遇到过一头雾水的情况:密钥登录瞬间提示 Your password has expired,然后连接就断了。这其实是账号密码过期策略在作怪,跟密钥文件本身无关。
2.3 客户端干扰:known_hosts、agent、多密钥
客户端侧最容易被忽略的就是 known_hosts。连接时 OpenSSH 会拿服务器返回的 host key 跟 known_hosts 里记录的对比,不一致就报 REMOTE HOST IDENTIFICATION HAS CHANGED。很多人在服务器重装后忘记清理这条记录,就一直连不上。按惯例,确认目标服务器确实没问题后,可以用 ssh-keygen -R 主机名 删除旧指纹,再重新连接。如果想一劳永逸,也可以把不受信任的指纹检查关掉,但不推荐,你永远不知道哪天会有人在你和目标机器之间搞小动作。
ssh-agent 缓存同样会干扰排查。当你执行 ssh-add -l 能看到一堆私钥路径时,OpenSSH 会挨个尝试。假设你更新了服务器公钥,但 agent 里还是旧私钥,或者多个私钥用同一个 comment 导致难以分清,认证就会不断失败。这时候最快的排除法是临时绕过 agent:ssh -i /path/to/actual/private key user@host -o IdentitiesOnly=yes。
~/.ssh/config 里也容易出问题。比如给某台主机配置了 IdentityFile 指向一个不存在的文件,OpenSSH 会静默跳过;又比如 Host 匹配规则写得太宽,把多个主机都匹配到同一个配置,导致连接到某一个时总是用错私钥。我的建议是每条 Host 配置尽量精确,并用 IdentitiesOnly yes 限定只尝试指定的密钥。
3. 手把手重新配置与更新 SSH 密钥
3.1 生成新密钥对:算法选择与参数
前面说了,老算法容易被新版本拒绝,所以我在 2024 年后基本统一改用 ed25519。生成命令很简单:
bash复制ssh-keygen -t ed25519 -a 100 -C "dengyu-desktop-2024" -f ~/.ssh/id_ed25519_work
参数解释一下:-t ed25519 指定算法,-a 100 是 KDF(密钥派生函数)迭代次数,数值越高,私钥本地暴力破解的难度越大,也不会影响连接速度。-C 是备注,通常会填“用途+主机+日期”,方便后续维护。-f 指定私钥保存路径,公钥会默认生成在同一个路径加 .pub 后缀。
如果你对接的老服务器不支持 ed25519,也可以退一步用 RSA:
bash复制ssh-keygen -t rsa -b 4096 -a 200 -C "company-aliyun-2024" -f ~/.ssh/id_rsa_company
这里的 -b 4096 指密钥长度,实际安全性远高于 2048,但对服务端配置要求也高一点。新环境我都是优先 ed25519,只有连接到老版本 OpenSSH 时再考虑 RSA。
生成时会询问是否设置 passphrase。我建议设置,但用 ssh-agent 配合使用就能只输一次。如果你完全不设 passphrase,私钥泄露后别人就能直接用,风险很大;如果设了一个复杂 passphrase,每次连接都输又很烦。比较好的做法是设置一个中等强度的 passphrase,然后把私钥加载进 ssh-agent,登录一次后就不用重复输入。
3.2 公钥部署到服务器
有条件的直接用 ssh-copy-id:
bash复制ssh-copy-id -i ~/.ssh/id_ed25519_work.pub user@server
它会自动把公钥追加到服务器的 ~/.ssh/authorized_keys,并把目录和文件权限调好。不过这个方法要求你当前还有密码或已有可用密钥登录服务器,属于“初始部署”类的工具。
没有条件时只能手动。我的操作习惯是先把公钥内容放到服务器上,再逐项确认权限:
bash复制mkdir -p ~/.ssh
echo "ssh-ed25519 AAAA... user@host" >> ~/.ssh/authorized_keys
chmod 700 ~/.ssh
chmod 600 ~/.ssh/authorized_keys
这里有两个细节。一个是我强烈建议用 echo ... >> ... 追加而不是 > 覆盖,否则很容易把原来的其它公钥全部清掉,到时候一场“团队事故”就变成个人事故了。另一个是 authorized_keys 里每行一个公钥,行首和行尾都不能有多余空格,粘贴时尤其注意。
批量部署时我更推荐写一个简单循环脚本,避免手动操作遗漏:
bash复制for host in server01 server02 server03; do
ssh-copy-id -i ~/.ssh/id_ed25519_work.pub deploy@$host
done
如果服务器量级上百台,就不建议 ssh-copy-id 了,用 Ansible 的 authorized_key 模块更省事:
yaml复制- name: Add SSH public key
ansible.posix.authorized_key:
user: deploy
state: present
key: "{{ lookup('file', '/home/dengyu/.ssh/id_ed25519_work.pub') }}"
3.3 客户端侧配置:把私钥交给 ssh-agent
公钥部署完之后,本地要确保私钥能被正确找到并使用。最简单的方式是每次连接时用 -i 指定,但连接次数多了很繁琐。更推荐在 ~/.ssh/config 里做多主机配置:
code复制Host work-git
HostName gitlab.example.com
User git
Port 22
IdentityFile ~/.ssh/id_ed25519_work
IdentitiesOnly yes
Host prod-web
HostName 10.0.0.88
User deploy
Port 2222
IdentityFile ~/.ssh/id_ed25519_prod
IdentitiesOnly yes
注意这里的 IdentitiesOnly yes 非常关键。不加这个参数时,OpenSSH 会把 config 里指定的密钥和 agent 里的所有密钥都拿去试,认证尝试次数多了就容易触发 Too many authentication failures。加了之后基本只尝试你指定的私钥,连接速度和成功率都高很多。
如果你设置了 passphrase,可以把它加载进 agent:
bash复制eval "$(ssh-agent -s)"
ssh-add ~/.ssh/id_ed25519_work
Windows 上建议在 PowerShell 里执行:
powershell复制Start-Service ssh-agent
ssh-add $env:USERPROFILE\.ssh\id_ed25519_work
Windows 自带的 OpenSSH 客户端在较新版本里已经可用,大部分场景不需要额外装工具。但如果你需要在 Windows 下管理多个密钥或者频繁调试,我见过不少人用 Bitvise SSH Client,功能更图形化,不过日常命令行连接我用原生的 OpenSSH 就够。
3.4 验证登录与日志定位
配置完成后不要直接跑业务,先做一次带调试信息的连接:
bash复制ssh -v -i ~/.ssh/id_ed25519_work user@server
看输出里有几个关键点:
- 是否提示
Offering public key: ...,这代表客户端有主动提供私钥。 - 服务端是否回复
Authentications that can continue: publickey,说明公钥认证本身被允许。 - 有没有
Authentication refused: bad ownership or modes,这是在提示权限问题。 - 连接成功后是否出现
Entering interactive session。
这几个输出各代表一类问题。如果客户端压根没有 Offering public key,那就是本地没把私钥送到服务端;如果发送了但被服务端拒绝,再去查服务端日志。服务端排查日志最方便:
bash复制sudo grep sshd /var/log/auth.log | tail -50
Debian/Ubuntu 是 /var/log/auth.log,CentOS/RHEL 是 /var/log/secure。这里能看到 sshd 拒绝的具体原因,比如 Invalid user、User root not allowed because not listed in AllowUsers、publickey 认证失败等。日志不会骗人,比一个个猜效率高太多。
4. 真实场景复盘:从报错到恢复的排查流程
4.1 Linux 服务器连不上(对应热词“ubuntu ssh 无法连接”)
遇到 Ubuntu 服务器无法通过 SSH 连接,我一般按四步走,而且顺序很重要。
先确认网络和应用层。ping 通不代表 SSH 通,要 telnet IP 22 或 nc -vz IP 22 看端口是否可达。如果端口不通,大概率是防火墙或 sshd 没启动。Ubuntu 上常见的是 ufw 规则没放行 22 端口,执行 sudo ufw allow 22 之后才能访问。
再确认 sshd 进程。systemctl status ssh 或 sudo systemctl status sshd,状态不是 active (running) 就检查启动日志。有时候 sshd 因为配置错误根本起不来,随便怎么连都是拒绝。我遇到过一次 sshd_config 写了不存在的 Subsystem sftp 路径,sshd 直接退出,排查了老半天才发现是配置文件语法检查没做。改完配置不要直接 restart,先执行 sudo sshd -t 做语法检查。
然后是认证日志。登录上的时候先看 /var/log/auth.log。常见表现是 Failed password for invalid user 或 Connection closed by authenticating user,前者说明对方在扫你的密码,后者说明认证流程已经走了一部分但最终被掐断。根据日志里的具体报错再去判断是公钥问题还是账号权限问题。
最后才是重置密钥。如果前面三步都没发现问题,再重新生成密钥并部署。但说实话,大部分“ubuntu ssh 无法连接”都不是密钥本身出问题,而是防火墙或 sshd 没启动,导致我们误以为是“密钥过期”。顺序错了一步,就容易陷入反复生成密钥的死循环。
4.2 VSCode 连接 SSH 远程服务器失败
VSCode Remote-SSH 插件的底层就是调用本机 SSH 客户端,所以它报错和命令行报错本质一样,但它有个坏习惯:把详细日志藏着掖着。你看到的是一个弹窗“Could not establish connection to ...”,底层原因要到输出面板里刷新。
我建议按下面的顺序处理。
先在命令行测试原始 SSH 能否连接。如果命令行都不通,VSCode 怎么配都没用,回到上一节排查。如果命令行通,再看 VSCode 的配置。点击左下角远程标志,选 Connect to Host...,里面会列出 ~/.ssh/config 里的 Host。如果列表里没有你的主机,就是 config 文件没识别到。这里注意 VSCode 默认读的是 %USERPROFILE%\.ssh\config(Windows)或 ~/.ssh/config(Linux/macOS),路径弄错等于白写配置。
很多时候报错是因为 VSCode 服务端在远程机器上下载不下来。远程机器上会执行一个脚本,然后从 update.code.visualstudio.com 下载服务端压缩包。如果你的远程机器处于内网或者有流量限制,这个下载很容易失败。表现就是连接窗口卡很久,然后报 Failed to install the VS Code Server。这时候可以在远程机器上手动设置 server-download 相关镜像,或者干脆在本地把 server 压缩包传上去。但更通用的做法是先解决远程机器的网络访问问题,确保它能访问外部更新源。
还要注意 VSCode Remote-SSH 默认会同时尝试多个身份文件,如果你本机 ~/.ssh 目录下有过期的旧公钥,它也会一并发送,导致认证次数过多。最直接的解决方式是在 config 里加 IdentitiesOnly yes 并精确指定 IdentityFile。
4.3 GitLab/Gerrit 等代码平台配置 SSH 密钥
代码平台和普通服务器有点区别:版本服务器通常不给你一个交互式 shell,而是把你当成 git 或 gerrit 用户,通过你提交的公钥来认证你的身份。一旦公钥配置不对,你就会看到类似 git@gitlab.example.com: Permission denied (publickey)。
对于 GitLab,关键是确认你在平台个人设置里添加的公钥是否完整、是否过期。如果平台提示密钥已被删除或撤销,就需要重新生成。有时候你在本地重新生成密钥后,忘记更新平台配置,就会持续报 Permission denied。
Gerrit 的默认端口是 29418,所以连接命令通常写成:
bash复制ssh -p 29418 username@gerrit.example.com
如果你在配置里加了 Host gerrit,就可以简写为:
bash复制ssh gerrit
Gerrit 验证成功后会返回一个欢迎信息,比如 Welcome to Gerrit Code Review 或者一句话说明你是注册用户。如果你的提示是 Permission denied (publickey, keyboard-interactive),则说明公钥没被识别。Gerrit 的网站后台有管理 SSH keys 的入口,最好把新公钥加进去后再试。
还要注意一个细节:很多人在 GitLab 或 Gerrit 上同时配置多个公钥,平台按“最近一次登录使用的账号”来判断你是谁。如果你临时换了一台电脑,用新生成的密钥提交代码,平台会问你是否要创建一个新账号,而不是直接关联到旧账号。结果就是新代码仓库 clone 不下来,看起来又像“密钥过期”。实际上是你没有把新公钥绑定到已有账号,而是误触发了新建账号流程。
4.4 批量登录与自动化场景的密钥管理
批量和自动化场景下最怕的不是密钥过期,而是密钥更新太频繁导致脚本失联。很多同事维护一批服务器,每换一次密钥就在一堆服务器上手工改 authorized_keys,改完发现漏了一台,第二天脚本就报错。这种做法不可取。
我的做法是先把密钥统一到一个专门的部署账号或统一身份体系。如果服务器数量在几十台左右,可以用一个密码迁移脚本,按 IP 列表逐个部署公钥。下面是日常用的脚本骨架:
bash复制#!/bin/bash
USER=deploy
KEY_FILE=~/.ssh/id_ed25519_ops.pub
for host in $(cat hosts.txt); do
sshpass -p 'CHANGE_ME' ssh-copy-id -i "$KEY_FILE" -o StrictHostKeyChecking=no "$USER@$host"
done
注意:sshpass 只在临时迁移时用,跑完就改密码。平时运维批量执行应该基于密钥本身,不要依赖密码。
如果在更大规模的环境中,建议用 Ansible 统一管理。在 playbook 里把授权公钥作为变量维护,统一更新。Ansible 的运维通道也建议单独用一把专用密钥,App 连接用的密钥是另一把,两把密钥分开轮换,互不干扰。批量登录工具我见过有人直接用 Paramiko 写 Python 脚本,也有人用 TERM 终端模拟器做交互,但本质上都依赖 SSH 协议本身。真正要保证的是:所有机器上的 authorized_keys 都是通过同一套配置管理工具生成的,而不是零零散散手动追加。
5. 密钥生命周期管理与安全建议
5.1 主动的密钥轮换策略
别等到“连不上”才想着更新密钥,而是要制定一个固定的轮换周期。个人电脑半年换一次,生产服务器和代码平台建议一年至少换一次。换的时候要按顺序来,尽量不中断在跑的任务。
我给团队的建议是三个月查看一次所有服务器 authorized_keys 的清单,六个月换一批运维密钥。换密钥前先把新公钥部署上去,确认能连上之后,再从本地和 agent 里移除旧私钥。千万别反过来,先在本地删旧密钥,结果发现服务器 authorized_keys 因为某些原因没有更新成功,那时候就连不上了。
对于离职或转岗人员,要第一时间从所有服务器和代码平台里删除其公钥,这个操作最好自动化。GitLab/Gerrit 后台可以设置用户状态,但服务器侧就没有那么智能了,所以需要定期审计脚本扫描所有 authorized_keys 里的 comment,和人员清单比对,发现可疑项就报警。
5.2 账号与密钥整体加固
密钥本身只是第一层防护,更安全的做法是给 SSH 加第二层验证。最常见的方案是 sshd_config 里设置 AuthenticationMethods publickey,keyboard-interactive:pam,要求先完成公钥验证,再通过动态口令验证。这样就算私钥泄露,攻击者没有动态令牌也进不来。
如果管理成百上千台机器,纯手工维护公钥是不现实的。可以考虑基于证书的 SSH 认证:每台服务器信任同一个 CA 证书,用户的公钥由 CA 签名后生成短期证书,证书过期后自动失效。这样用户更换电脑时只要重新申请一次证书,不需要跑到每台服务器上去改 authorized_keys。这个方案实施成本高一些,但长期维护要比手工加公钥省心得多。
同时要给服务器加上登录审计。journalctl -u sshd 或 /var/log/auth.log 里的登录记录要定时收集。看到短时间内大量 Failed password 或 Connection closed by authenticating user,就要警惕有人在扫你的服务器。正常的密钥登录是不需要反复重试密码的,如果日志里大量出现同一 IP 的认证失败记录,优先在防火墙层面把该 IP 封掉。
5.3 个人密钥的习惯养成
最后说点个人习惯层面的东西。我现在生成密钥时,一定会做三件事:一是给私钥设置 passphrase;二是在公钥 comment 里写明是自己哪台机器、什么用途;三是把公钥内容单独备份到一个私有的加密仓库(比如 keepass 或 bitwarden)里,省得机器重装后找不到。
还有一点,永远不要图方便把自己常用的私钥复制到服务器上。私钥应该只存在于你本地受信任的机器里,服务器端只需要公钥。如果你把私钥复制到服务器,一旦服务器被入侵,攻击者拿到的就是你的私钥,它能尝试连接你所有配置过公钥的主机和代码平台,后果比想象中严重。
我是吃过大亏才养成这个习惯的。之前在一台跳板机上放了自己的私钥,结果跳板机被扫出漏洞,攻击者直接把私钥拖走,紧接着我所有的 GitLab 仓库和几台线上服务器全部收到异地登录告警。那次之后,所有机器上的私钥一律清除,跳板机只保留一把临时签发的证书。
综合来看,SSH 密钥的“过期”绝大多数时候不是真的过期,而是某个环节的文件、配置或记录和实际情况不匹配。排查时先看日志,再逐段验证客户端和服务端状态,最后才考虑重新生成。平时则要把密钥当账号密码一样管理:定期轮换、最小权限、加双因素、集中审计。我的体会是,把下面这个命令当成肌肉记忆去执行,能在关键时刻救你一命:
bash复制ssh -vvv user@host
它会输出客户端和服务端交换的每一条调试信息。任何“莫名其妙”的密钥失效,在 -vvv 的输出里都会露出马脚。下次再遇到连接失败,不要立刻删掉重来,先把这个输出存下来,逐行读一遍,你很快就能找到真正的根源。
