今天回来,要帮同学远程运维一下之前的服务器(他忘记续费了),忽然发现运维脚本执行后没反应(往常1分钟一台跑完的).

问题现象

在 macOS 上 SSH 登录一台 Ubuntu 服务器,连接永久卡死。ssh -v 的调试输出停在:

1
2
debug1: Authentications that can continue: publickey
debug1: Next authentication method: publickey

之后再无任何输出,进程既不报错也不超时,就这样一直挂着。

更诡异的是:

  • ssh-add -l 同样卡死
  • 重启电脑后问题依旧
  • 换用系统自带的 /usr/bin/ssh(9.x)和 Homebrew 的 OpenSSH 10.2 都卡

排查思路:逐层缩小范围

第一层:定位卡死的环节

-v 日志停在 Next authentication method: publickey,下一步本应是 Offering public key ...。在这两步之间,ssh 客户端会做一件事:向 ssh-agent 查询可用密钥。agent 不响应,客户端就无限等待。

初步怀疑 agent。检查环境变量:

1
2
$ echo $SSH_AUTH_SOCK
/var/run/com.apple.launchd.b6Kz3tJtrK/Listeners

这是 macOS 系统 launchd 管理的标准 agent socket,路径本身正常。

第二层:绕过 agent 做对照实验

关键的分流测试——让 ssh 完全不碰 agent:

1
ssh -o IdentityAgent=none -o IdentitiesOnly=yes -i ~/.ssh/id_rsa root@<server>

立即成功登录。 这一步把问题 100% 锁定在 ssh-agent 环节:服务器没问题、网络没问题、密钥没问题,就是 agent 死了。

小插曲:先用 id_ed25519 测试时返回 Permission denied (publickey)——虽然被拒,但”立即返回”本身就是有效信息(说明绕过 agent 后流程通畅),换成服务器上实际授权的 id_rsa 即成功。

第三层:审问 launchd

macOS 上系统 ssh-agent 是 socket 激活式的:launchd 监听 socket,有连接进来才拉起 /usr/bin/ssh-agent -l。查看服务状态:

1
2
3
4
5
6
$ launchctl print gui/$(id -u)/com.openssh.ssh-agent
...
state = spawn scheduled
runs = 61
last exit code = 255
...

铁证:runs = 61,last exit code = 255 launchd 一直在拉起 agent,但它每次启动后立即以 255 退出,已经反复崩了 61 次。客户端卡死的机制也清晰了:

  1. ssh / ssh-add 连接 socket
  2. launchd 收下连接,拉起 agent
  3. agent 秒退,连接悬空
  4. 客户端永远等不到响应
  5. launchd 有 10 秒的重启节流,于是日志里每 10 秒一次”Successfully spawned → service inactive”的循环

用统一日志确认了这个循环:

1
log show --last 2m --predicate 'process == "ssh-agent" OR eventMessage CONTAINS "ssh-agent"' --info --debug

每次生命周期只有 5 毫秒:spawn → 查询用户信息(getpwuid)→ 退出。没有留下任何错误日志——launchd 模式下 stderr 被丢弃了。

第四层:让它当面崩给我看

既然 launchd 模式下看不到报错,那就手动跑,把 stderr 抓出来:

1
2
3
4
5
6
$ /usr/bin/ssh-agent; echo "exit: $?"
agent_cleanup_stale: stat "/Users/cat/.ssh/agent/.": Permission denied
agent_cleanup_stale: stat "/Users/cat/.ssh/agent/..": Permission denied
unix_listener_tmp: bind "/Users/cat/.ssh/agent/s.xxxx.agent.xxxx": Permission denied
main: Couldn't prepare agent socket
exit: 255

真相大白。 新版 ssh-agent 会在 ~/.ssh/agent/ 目录下创建 socket,而这个目录的权限坏了,导致 bind 失败,fatal() 退出 255。

根因:一个缺失的 x 位

看权限:

1
2
$ ls -ld ~/.ssh/agent
drw-------@ 3 cat staff 96 4月 4 10:37 /Users/cat/.ssh/agent

属主没问题,坏在权限位:drw-------——目录缺了 execute(x)位

对目录而言,x 是”进入/穿越”权限。没有它:

  • 可以 ls 列出文件名(靠 r 位)
  • 但无法 stat 目录内任何文件
  • 无法在目录内创建新文件(socket 的 bind 也就必然失败)

这种坏法的典型成因:某次对 ~/.ssh 递归执行了 chmod 600600 对私钥文件是正确的,但对目录是致命的——目录必须有 x 才能正常使用。

这也完美解释了所有诡异现象:

现象 解释
重启无效 权限持久化在磁盘上,与进程/会话无关
两个版本的 ssh 客户端都卡 问题在 agent 端,客户端无辜
agent 静默死亡 launchd 丢弃了 stderr,错误信息无处可看
socket 存在且可连接 socket 由 launchd 持有,与 agent 是否活着无关

修复:一条命令

1
chmod 700 ~/.ssh/agent

验证:

1
2
3
$ ssh-add -l          # 立即返回,agent 复活
$ ssh-add ~/.ssh/id_rsa ~/.ssh/id_ed25519
$ ssh root@<server> # 秒进

经验总结

  1. “卡死”和”报错”是两种线索。 卡死意味着某一方在无限等待,顺着协议流程找”下一步本应发生什么”,就能定位是在等谁。

  2. 对照实验是最快的手术刀。 -o IdentityAgent=none 一条命令就把嫌疑范围从”客户端/网络/服务器/agent”缩到只剩 agent。

  3. socket 激活服务的报错会被吞掉。 launchd 拉起的进程 stderr 默认丢弃,launchctl print 里的 runslast exit code 是最重要的健康指标;拿不到日志时,手动在终端复现启动是终极手段。

  4. chmod 600 不要递归用在目录上。 ~/.ssh 的标准权限:目录 700,私钥 600,公钥/config/known_hosts 644600。修权限时用 chmod 700 目录 + chmod 600 私钥 分开处理,或者:

    1
    2
    find ~/.ssh -type d -exec chmod 700 {} \;
    find ~/.ssh -type f -name 'id_*' ! -name '*.pub' -exec chmod 600 {} \;
  5. exit code 255 在 OpenSSH 语境里就是 fatal() 看到它,去找初始化阶段的失败点(socket、权限、配置解析),而不是运行时逻辑。

一个丢失的 x 位,让 ssh-agent 无声地崩溃了 61 次,拖着两个版本的 ssh 客户端一起陪葬,还扛过了一次重启。定位它花了四层排查,修复它只要九个字符。