我相信不少人遇到过这个场面:手里攥着服务器密码,认认真真敲完ssh-copy-id user@server,输入密码,提示成功,结果下一次ssh user@server还是照样问你密码;或者更糟,ssh-copy-id本身就直接甩你一句Permission denied (publickey,password)。再过一会儿,连 VSCode Remote-SSH 都跟着报Bad owner or permissions on C:\Users\thinkpad/.ssh/config。这堆问题的根源往往不在“密码”或“公钥内容”,而在于整个 SSH 认证链路里某个环节的权限、配置或平台差异。这篇文章就围绕ssh-copy-id的前前后后,把高频报错按信息逐条拆开,讲清楚每一步的排查方法和底层逻辑,顺便带上ssh -vvv这类真正管用的诊断手段。适合刚配置密钥免密的同学,也适合在 Windows 下折腾 OpenSSH、VSCode 远程开发时被权限问题反复摩擦的人。
1. ssh-copy-id 到底帮你干了什么:理解命令的四个动作
1.1 一条命令背后的隐藏行为
ssh-copy-id user@host看起来只是“把公钥拷过去”,但实际它替你做了四件事:第一,在本机~/.ssh/下找默认公钥(通常优先id_rsa.pub,新版 OpenSSH 也会找id_ed25519.pub);第二,用密码或已有密钥登录远程主机;第三,在远程主机的用户家目录下创建~/.ssh目录;第四,把公钥内容追加到~/.ssh/authorized_keys文件末尾。
这四步任何一步出问题,后面的免密就不可能成立。很多人的误区是:我手动用scp id_rsa.pub user@host:~/.ssh/authorized_keys不是一样吗?不一样。scp是直接覆盖,如果你之前已经加过别的公钥,一覆盖就全没了;而ssh-copy-id用的是追加,这也是它能被反复安全执行的原因。理解这四步之后,报错就有了解释方向:命令找不到,是本机工具缺失;登录失败,是认证环节;目录创建或文件追加失败,是远程权限;权限不对,是后续sshd校验阶段直接拒绝。
1.2 权限要求不是玄学:目录和文件的真实权限标准
为什么公钥明明写进了authorized_keys,ssh还是要密码?绝大多数原因是权限不达标。OpenSSH 的sshd在启用StrictModes yes(默认开启)时,会严格检查三个地方的权限:
- 远程用户家目录
~:属主必须是登录用户,且不能对 group 或其他用户可写。比如/home/user权限是777,那 sshd 直接拒绝使用该用户的 authorized_keys。 - 远程
~/.ssh目录:权限应该是700,也就是只有属主可读写执行。 - 远程
~/.ssh/authorized_keys文件:权限应该是600,属主可读写,其他人什么都别碰。
ssh-copy-id命令默认会做chmod 700 ~/.ssh和chmod 600 ~/.ssh/authorized_keys,但如果你之前手动创建过.ssh目录,或者用scp复制过文件,权限可能已经被改歪了,再执行一次ssh-copy-id未必会帮你修正已有文件的权限。这时候我一般直接手动确认:
ssh user@host "ls -ld ~ ~/.ssh ~/.ssh/authorized_keys" ssh user@host "chmod 700 ~/.ssh && chmod 600 ~/.ssh/authorized_keys"需要注意的是,家目录权限如果太松,比如/home/user是755是没问题的,但如果是777就必须改回755或更严格。这个检查对 CentOS、Ubuntu、麒麟等 Linux 发行版基本通用。
1.3 为什么“复制公钥文件”不行,必须“追加”
我见过不少人这样操作:先用ssh-keygen生成密钥,然后用cat ~/.ssh/id_rsa.pub >> ~/.ssh/authorized_keys手动追加,这个方向是对的。但另一些人用scp把本机公钥直接覆盖到远端authorized_keys,如果这台服务器只有你这一个用户,那还能用;一旦服务器里存了其他机器的公钥,覆盖之后那台机器立刻失效。ssh-copy-id的追加语义就是要避免这种问题。
还有一个小细节:ssh-copy-id支持指定公钥文件,如果你本机有多个密钥,默认可能选错。这时候用-i显式指定,后面排查也更有针对性:
ssh-copy-id -i ~/.ssh/id_ed25519.pub user@host2. 高频报错按图索骥:从错误信息到根因
2.1 ssh-copy-id: command not found
这个报错在 macOS 和 Windows 上最常见。macOS 虽然自带ssh客户端,但很长一段时间默认不装ssh-copy-id,需要自己用 Homebrew 安装:
brew install ssh-copy-idWindows 10/11 自带的 OpenSSH 客户端同样不带ssh-copy-id。如果你的系统里装了 Git for Windows 或 WSL,里面可能有这个命令,但原生 PowerShell 和 cmd 里没有。这种情况最简单的替代方案是手动复制公钥:
type $env:USERPROFILE\.ssh\id_rsa.pub | ssh user@host "mkdir -p ~/.ssh && chmod 700 ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"这里用mkdir -p保证.ssh目录存在且不覆盖已有内容,chmod顺手把权限修正,最后cat >>追加而不是覆盖。手动流程熟记之后,比依赖ssh-copy-id更通用,尤其是遇到老设备或嵌入式系统时很管用。
2.2 Permission denied (publickey,password):从三种路径排查
这个报错表示服务器拒绝了你提供的所有认证方式,既没有接受公钥,也没有接受密码。逐层排查,通常落在三处:
第一,本机密钥文件权限有问题。OpenSSH 对私钥文件(id_rsa、id_ed25519)要求权限为600,如果你不小心chmod 644了,客户端会直接忽略这个私钥,日志里会出现bad permissions字样。修复:
chmod 600 ~/.ssh/id_rsa ~/.ssh/id_ed25519第二,远程sshd配置禁止了密码认证。检查/etc/ssh/sshd_config里的PasswordAuthentication是否为yes,同时看PermitRootLogin。云厂商默认镜像经常把 root 的密码登录关掉,如果你在本地用 root 执行ssh-copy-id,就会看到这个报错。解决是用一个有 sudo 权限的普通用户登录,再把密钥加到 root 或自己的authorized_keys里。
第三,公钥没被正确追加到authorized_keys,或者追加之后权限不对。这个场景我在后面第 5 章专门用案例展开。
2.3 Connection timed out / Connection refused:网络层和服务层分开查
Connection timed out和Connection refused经常被混为一谈,实际含义完全不同。timed out表示包发出去了,对方没回应,常见原因:目标 IP 不通、防火墙丢弃了 22 端口、跨网段路由不对、云安全组没放行。refused表示对方网络可达但端口上没人监听,可能 sshd 没启动、监听在其他端口、或者被 fail2ban 临时封禁。
我一般按这个顺序排查:
ping host nc -vz host 22 telnet host 22 systemctl status sshd ss -tlnp | grep sshnc -vz host 22只要显示succeeded,说明端口通了,问题大概率在认证层;如果端口不通,先别急着折腾公钥,把网络和安全组搞定再说。VSCode Remote-SSH 报Connection timed out时,同样先用这几条命令判断,而不是反复重装插件。
2.4 Host key verification failed 与 REMOTE HOST IDENTIFICATION HAS CHANGED
Host key verification failed意味着远程主机的 host key 和你本地known_hosts里记录的不一致。最常见的触发场景是服务器重装系统、更换镜像、或者容器重建后 IP 复用了。这时候不要直接删掉整个known_hosts,只要删除冲突的那一条就行:
ssh-keygen -R 192.168.1.100 ssh-keygen -R server.example.com如果你想在脚本或首次连接时自动接受新的 host key,OpenSSH 7.6 以后可以用:
ssh -o StrictHostKeyChecking=accept-new user@hostssh-copy-id同样支持透传-o参数:
ssh-copy-id -o StrictHostKeyChecking=accept-new user@host但注意,accept-new只接受“新”的 host key,如果known_hosts里已有冲突记录,它照样会失败,所以脚本里要么先ssh-keygen -R,要么接受StrictHostKeyChecking=no的风险。生产环境不建议长期使用no,这个选项只适合一次性初始化场景。
2.5 Bad owner or permissions on C:\Users\thinkpad/.ssh/config
这个报错是 Windows 下 OpenSSH 的经典问题,VSCode Remote-SSH 用户应该都见过。Win10/11 自带 OpenSSH 对config文件权限要求非常严格,默认情况下用户主目录和.ssh目录可能带着从父目录继承来的、属于多个用户或组的 ACL 权限,OpenSSH 觉得不安全就直接拒绝。
处理方式:打开文件属性 -> 安全 -> 高级,先点“禁用继承”,把从上层继承的权限全部清掉,然后只保留当前用户SYSTEM和Administrators的完全控制权。如果想用命令行,PowerShell 里执行:
icacls "$env:USERPROFILE\.ssh\config" /inheritance:r /grant:r "$env:USERNAME:F" icacls "$env:USERPROFILE\.ssh\known_hosts" /inheritance:r /grant:r "$env:USERNAME:F"顺手把整个.ssh目录也处理一遍,避免id_rsa、id_rsa.pub后续再报权限问题。改完之后重新打开 VSCode,执行Remote-SSH: Kill VS Code Server on Host再重连,能解决大部分 Windows 上的 SSH 配置异常。顺带一提,plink 用户(PuTTY 的命令行组件)如果出现类似登录后无法执行 sudo 的问题,通常不是权限而是没有分配 tty,加-t参数即可。
3. 带上 ssh -vvv 做一次“案发现场”复查
3.1 -v、-vv、-vvv 分别能看到什么
很多人排错 SSH 全凭猜,其实 OpenSSH 自己就带了一个非常完整的诊断模式,根据-v的数量决定日志详细程度。ssh -v输出连接过程的关键节点,包括正在尝试的认证方法、读取了哪个密钥文件;ssh -vv会增加更细的协商细节;ssh -vvv则进入“显微镜”模式,把密钥交换、报文类型、远端返回的状态全打印出来。
实际操作中我建议先用-v看大方向,再对可疑环节用-vvv追细节。不要在平时登录也加-vvv,那会刷屏刷到你找不到重点。
ssh -vvv user@host日志里值得关注的节点比报错信息本身更准确,因为它会告诉你问题发生在“读取本地密钥”还是“远端验证”阶段。
3.2 一次成功认证的日志长什么样
如果一切正常,ssh -vvv的尾部会看到类似这样的流程:
debug1: Next authentication method: publickey debug1: Offering public key: /home/user/.ssh/id_ed25519 ED25519 SHA256:xxxx debug3: send packet: type 50 debug2: we sent a publickey packet, wait for reply debug1: Server accepts key: /home/user/.ssh/id_ed25519 debug3: send packet: type 21 debug1: Authentication succeeded (publickey).关键在Server accepts key这行。它表示远程 sshd 已经在你提供的公钥里找到了匹配项,并且完成了签名校验。只要出现这一行,说明authorized_keys的内容没问题,前面折腾的权限大概率也正确。如果卡在Offering public key之后迟迟没有回应,或者直接返回password认证,说明远端根本不认这把公钥。
3.3 失败日志的关键词判读表
下面这张表是我排障时实际用到的速查表,按日志关键词定位下一步动作:
| 日志关键词 | 含义 | 下一步操作 |
|---|---|---|
Permission denied (publickey) | 远端不接受提供的公钥 | 检查 authorized_keys 内容、.ssh 权限、sshd_config |
bad permissions | 本地私钥或 config 权限不对 | chmod 600 id_rsa,Windows 修 ACL |
Connection refused | 22 端口无服务监听 | 启动 sshd,确认端口 |
Operation timed out | 网络层不可达 | 查防火墙、路由、安全组 |
no matching key exchange method | 客户端与服务器算法套件不匹配 | 指定 KexAlgorithms 或升级 OpenSSH |
Host key verification failed | known_hosts 记录冲突 | ssh-keygen -R 删除旧记录 |
Server accepts key | 公钥认证本身已成功 | 继续排查后续,如 tty、sudo 权限 |
这表的逻辑是:先把报错归类到网络层、服务层、认证层或权限层,再决定动哪里。很多人在网络层都没通的情况下反复生成密钥,纯属浪费时间。
4. Windows 平台下 ssh-copy-id 的完整替代方案
4.1 Windows 现状:自带 OpenSSH,但默认没有 ssh-copy-id
Windows 10 1809 之后的系统可以通过“设置 -> 应用 -> 可选功能”安装 OpenSSH 客户端,安装后ssh、scp、ssh-keygen都可用,但唯独没有ssh-copy-id。所以在 Windows 的原生终端里执行ssh-copy-id,第一反应应该是“命令不存在”,而不是想歪了。如果你装了 Git Bash,里面可能会顺带提供一个ssh-copy-id脚本,但不同版本的 Git for Windows 行为不太一样,有的能找到,有的没有,不建议把它当作稳定依赖。
解决思路也很简单:把ssh-copy-id背后的逻辑手动执行一遍。这个方案绕开了命令是否存在的问题,也更容易处理像 plink、跳板机这类特殊环境。
4.2 手动复制公钥的标准命令
在 PowerShell 里,手动复制公钥的完整命令是:
type $env:USERPROFILE\.ssh\id_rsa.pub | ssh user@host "mkdir -p ~/.ssh && chmod 700 ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"如果远程系统比较老,不支持chmod(比如部分嵌入式设备或老交换机),可以把后面的chmod去掉,只保留mkdir -p和cat >>。需要注意,type是 PowerShell 里读取文件内容的命令,CMD 里也可以用,功能等同于 Linux 的cat。如果你的默认公钥是id_ed25519.pub,记得改文件路径。
这里有个经验:如果远端是 Windows 的 OpenSSH Server,那路径和权限逻辑又不一样了,authorized_keys通常在C:\Users\用户名\.ssh\authorized_keys,追加命令里的chmod可能不生效,需要改用icacls给当前用户授予权限。
4.3 Windows 下 .ssh 目录权限的修复
Windows 的 OpenSSH 对权限的判断沿用 Unix 的严格思路,但实现上完全依赖 Windows ACL。最常见的就是config文件继承了C:\Users\thinkpad目录的权限,导致当前用户之外的其他账号也有读取权,OpenSSH 直接拒绝。
通过图形界面修复的路径是:右键config-> 属性 -> 安全 -> 高级 -> 禁用继承(把继承的权限转换为显式权限或直接删除)-> 删除多余条目 -> 只保留当前用户和 SYSTEM 的完全控制。操作完以后,known_hosts如果有类似问题,同样处理。
如果嫌界面操作啰嗦,PowerShell 里用icacls一条命令搞定:
icacls "$env:USERPROFILE\.ssh\config" /inheritance:r /grant:r "$env:USERNAME:F"细心的朋友会发现,$env:USERNAME在 PowerShell 里是当前用户名,但如果你把这段放进了双引号里,PowerShell 会先展开变量再传给icacls,所以路径是正常的。不要在 CMD 里直接跑%USERNAME%的版本,除非你想踩转义坑。
4.4 VSCode Remote-SSH 与 config 文件权限
VSCode Remote-SSH 本质就是对~/.ssh/config的解析加一个ssh客户端进程。它报Bad owner or permissions,根因就是上一节说的 Windows ACL 问题。修完权限之后还需要在 VSCode 里执行Remote-SSH: Kill VS Code Server on Host,因为旧的 SSH Server 进程还可能带着出错时的状态,不杀掉重连可能继续报错。
顺便说一句,VSCode 里配置多主机可以用config的别名和IdentityFile,但 IdentityFile 路径在 Windows 上最好是绝对路径,比如C:\Users\thinkpad\.ssh\id_ed25519,不要写成~/.ssh/id_ed25519这种相对格式,某些情况下解析会出问题。填绝对路径配合上一节的权限修复,VSCode 连服务器基本不会再卡在“权限”这关。
5. 三个真实排错案例:从报错到免密登录
5.1 案例一:公钥明明加了,还是提示输入密码
有位朋友的 Ubuntu 服务器,按网上教程把公钥内容贴进了authorized_keys,但每次ssh还是问密码,最后翻/var/log/auth.log才发现一行关键日志:
Authentication refused: bad ownership or modes for directory /home/user原因是他之前手动把整个家目录chmod -R 777过,.ssh目录和authorized_keys也跟着变成了777。SSH 的StrictModes检测到这种开放权限,会直接拒绝使用这个文件里的任何公钥,哪怕内容完全正确。修复命令很简单:
chown -R user:user /home/user/.ssh chmod 700 /home/user/.ssh chmod 600 /home/user/.ssh/authorized_keys chmod 755 /home/user这里强调一个细节:authorized_keys文件的所有者也必须是登录用户,如果你用 root 帮忙把文件chown成了 root,普通用户登录时 sshd 一样会拒绝。遇到这个报错时,用ls -la确认.ssh下文件的所有者。
5.2 案例二:root 登录被禁用,ssh-copy-id 直接报 Permission denied
另一个典型场景是云厂商提供的 Ubuntu 镜像,默认只允许普通用户登录,root 的PermitRootLogin是prohibit-password,也就是禁止 root 密码登录,但允许密钥登录。你直接用ssh-copy-id root@server,如果 root 没有密码,系统会直接拒绝密码认证,报Permission denied (publickey,password)。
正确做法是先用有 sudo 权限的普通用户登录,比如 ubuntu 或 admin:
ssh-copy-id ubuntu@server ssh ubuntu@server sudo -i然后编辑/etc/ssh/sshd_config,把PermitRootLogin改回prohibit-password而不是yes,这样既允许密钥登录又保留密码登录的部分限制。改完之后:
systemctl reload ssh不要改成yes就把 root 密码登录打开,安全上完全没有必要。如果你确实需要在脚本里以 root 身份执行远程命令,建议用普通用户加sudo的组合,而不是强行开放 root 登录。
5.3 案例三:非 22 端口、多台服务器批量执行
ssh-copy-id对非标准端口的使用有个容易踩的坑:-p只能放在用户和主机之前,不能跟在命令末尾,否则会被当成远程命令的一部分。正确写法:
ssh-copy-id -p 2222 -i ~/.ssh/id_ed25519.pub user@host批量给多台服务器推送公钥时,写一个简单的 for 循环最常见:
for host in 192.168.1.101 192.168.1.102 192.168.1.103; do ssh-copy-id -p 2222 -i ~/.ssh/id_ed25519.pub user@"$host" done执行每台机器时还是会要求输入一次密码,除非你已经配置了免密。如果非要全自动,可以用sshpass -p '密码' ssh-copy-id ...,但明文密码会出现在 shell history 和进程列表里,生产环境慎用。我个人的建议是:如果机器数量不多,循环里手动输密码最稳妥;如果机器数量很大,走配置管理工具(ansible 之类)集中下发公钥,而不是在裸 shell 脚本里硬编码密码。
批量场景下,私钥如果设置了 passphrase,每连一台新机器都会让你输入一次私钥口令。这时候先启动ssh-agent并添加密钥:
eval "$(ssh-agent -s)" ssh-add ~/.ssh/id_ed25519之后ssh和ssh-copy-id会自动从 agent 取密钥进行签名,不需要反复输入口令。这个方法对 VSCode Remote-SSH 同样有效,Windows 下只需要在 PowerShell 里先跑ssh-agent服务并ssh-add。
6. 密钥认证打通之后的维护和调优
6.1 多主机多密钥的目录规划
密钥多了以后,不建议把所有私钥都扔在~/.ssh下然后用默认文件名,那样ssh客户端会挨个尝试,浪费时间也可能触发服务器端的失败计数。更规范的做法是在~/.ssh/config里为不同主机定义别名和指定密钥:
Host aliyun HostName 203.0.113.10 User ubuntu Port 22 IdentityFile ~/.ssh/id_ed25519_aliyun Host office HostName 10.0.0.5 User admin Port 2222 IdentityFile ~/.ssh/id_ed25519_office配置完成后直接ssh aliyun就能登录。这个 config 文件的权限同样重要,在 Linux 下执行chmod 600 ~/.ssh/config,在 Windows 下按第 4 章的 ACL 方法处理。多主机场景里最忌讳的是把几十台服务器密码都记在文本文件里,用 key 加 config 的方式既方便又安全。
6.2 known_hosts 的维护
known_hosts是一个会随着时间不断膨胀的文件,服务器重装、IP 复用都会导致 host key 变化。平时我在重装系统后都会执行ssh-keygen -R删除对应主机的旧记录,而不是把整个known_hosts删掉。多用户环境下,可以开启HashKnownHosts yes,让 known_hosts 里的主机名和 IP 以哈希形式保存,避免服务器地址泄露给拿到文件的人。
6.3 ssh 断开与长任务处理:tmux 和断点续训
密钥免密只是第一步,真正远程跑训练任务、数据迁移、部署脚本的人,最烦的是 SSH 突然断开后任务跟着挂掉。热搜里的“断点续训”本质不是 SSH 的问题,而是任务进程没有脱离 SSH 会话。解决方案是在远程主机上用tmux或screen把任务挂在后台:
ssh user@server tmux new -s train python train.py断线重连后用tmux attach -t train回到原会话。这是我最推荐的方式,比nohup ... &直观得多,因为你能随时看到输出,还能丢给别的同事接管。再配合客户端侧的心跳保活,把~/.ssh/config里加上:
Host * ServerAliveInterval 60 ServerAliveCountDown 3每 60 秒发一个 keepalive 包,连续 3 个没回应才断线,能大幅减少“办公室网络抖动一次,远程训练就断掉”的尴尬。
6.4 sshd 配置的预防性建议
走到免密成功后,很多人就再也不看sshd_config了。我建议在初始化服务器时就做几件预防性的事:确认PubkeyAuthentication yes是开启的;PasswordAuthentication按安全基线决定,不用的场景直接关掉;MaxAuthTries设置一个较小的值,比如 3,防止有人暴力试探;LoginGraceTime不要给太长,默认 120 秒可以接受。
修改sshd_config之后有一个安全操作习惯:先开一个额外的 SSH 会话保持不动,然后执行systemctl reload ssh,再开新窗口验证,验证成功后再关老会话。这样即使配置写错了,你也不至于把自己锁在机器外面。我吃过一次亏,改完PermitRootLogin直接重启 sshd,结果新配置语法错误,老连接也断了,最后只能靠云厂商的控制台 VNC 进去救。从那以后,备份配置、保留逃生会话、reload而不是restart,这三步成了我改 sshd 的固定流程。
另外,老设备和老系统的兼容问题也值得一提。有些华为交换机或老款 ARM 设备只支持旧版本的 SSH 算法,新客户端连上去会报no matching key exchange method。这时候可以在命令里临时指定算法定位问题,但最终还是要升级系统里的 OpenSSH,而不是长期用弱算法凑合。安全性和兼容性的平衡,永远是先保证前者。