今天给本机的 UTM 虚拟机配好了 avahi,在 ~/.ssh/config 里写了个别名指向 vm.local,结果一连就报错:

ssh: connect to host vm.local port 22: Undefined error: 0

用 IP 能连,ping 也通,dscacheutil 也能把名字解析出来,就是 ssh 不行。错误码是 0,等于什么都没说。

TL;DR

在 ~/.ssh/config 里给这台主机加一行 ConnectTimeout 10 就能连上。

原因是 macOS 自带的 ssh 在默认参数下会把连接交给 Network.framework,而 Network.framework 会检查本地网络权限。我的 tmux server 是在一个旧的 Ghostty 进程里启动的,Ghostty 自动更新后旧进程退出了,系统还把 tmux 算在它头上,于是 tmux 里的局域网连接都被拦了。设了 ConnectTimeout 后 ssh 会走传统的 socket,绕过了这个检查。

不在 tmux 里的新 Ghostty 窗口是正常的。重启 tmux server 应该能彻底修好,不过我还没试。

环境

  • macOS 27.0.1,系统自带的 /usr/bin/ssh,OpenSSH 10.3p1。
  • Ghostty 加 tmux,tmux server 已经跑了三个星期。
  • 目标是 UTM 的虚拟机,Shared 网络,Mac 这边的网卡是 bridge101。

这次是我和 Claude Code 一起查的。报错是我遇到的,验证也是我做的,翻 Apple 的源码和抓系统日志主要是它做的。

ConnectTimeout 为什么有用

最开始测的时候我顺手加了 -o ConnectTimeout=5,怎么连都成功,去掉就失败。对比 ssh -v 的输出,区别在这一行:

# 能连
debug1: Connecting to vm.local [192.168.64.2] port 22.
# 不能连
debug1: Connecting to vm.local port 22.

失败的时候,方括号里的 IP 没了,说明 ssh 根本没自己去解析主机名。

Apple 的 OpenSSH 是开源的,在 apple-oss-distributions/OpenSSH(在新标签页打开)。我看的是 OpenSSH-354.0.3,和系统里的版本不一定完全一样,不过行为对得上。ssh.c 里有一段 Apple 加的补丁:

#ifdef __APPLE_NW_CONNECTION__
	/*
	 * Skip hostname resolution since it will be handled by
	 * tcp_connection inside ssh_connect_direct().
	 * We do not skip in the presence of multiple attempts,
	 * a timeout requested, or a privileged port.
	 * In those cases the tcp_connection is not used.
	 */
	if (addrs == NULL && options.proxy_command == NULL
	    && (options.connection_attempts != 1 ||
		options.connection_timeout > 0)) {

也就是说,只要没有 ProxyCommand、ConnectionAttempts 是默认的 1、也没设 ConnectTimeout,ssh 就跳过解析,在 sshconnect.c 里用 nw_connection_create() 把主机名整个交给 Network.framework,注释里说是为了 Happy Eyeballs 和按需连接 VPN。设了其中任何一个,就回到 OpenSSH 原来的 getaddrinfo() 加 connect()。

连接失败时,补丁是这样报错的:

const int connection_error = event_error ? nw_error_get_error_code(event_error) : 0;
...
error("ssh: connect to host %s port %u: %s",
      host, port, strerror(connection_error));

Network.framework 没给错误对象,错误码就是 0,strerror(0) 正好是「Undefined error: 0」。

谁拦的

用 log stream 盯着 ssh 进程再连一次:

/usr/bin/log stream --style compact --level debug --predicate 'process == "ssh"'

能看到名字解析成功,拿到了 IPv4 和 IPv6 两个地址,然后两条路径都是:

waiting path (unsatisfied (Local network prohibited), interface: bridge101, ipv4)

最后「all children failed」。本地网络权限不让连,又没给错误码,于是就成了上面那个报错。

zsh 里 log 是个内建命令,要写 /usr/bin/log,不然会报「too many arguments」。

那权限算到了谁头上?换成看 com.apple.networkextension:

/usr/bin/log stream --style compact --level debug --predicate 'subsystem == "com.apple.networkextension"'

关键的几行:

Got local network blocked notification: pid: 2135, uuid: C87D5573-..., bundle_id: (null)
LocalNetwork: found bundle id com.mitchellh.ghostty by UUID C87D5573-...
Local network allowed by preference for Ghostty (com.mitchellh.ghostty)

系统认出这是 Ghostty,也查到了 Ghostty 是允许访问本地网络的,但连接还是被拦了。问题在 PID 2135,现在跑着的 Ghostty 并不是这个号。

翻了一下日志和进程:

  • 2135 是自动更新之前的那个 Ghostty。
  • tmux server 是 10 月 8 日启动的,比这次更新早得多,之后一直没退出过。
  • 10 月 28 日 Ghostty 通过 Sparkle 自动更新并重启了,2135 退出,换成了新的进程。
  • 之后我每次打开 Ghostty,都是 tmux new-session -A 连回那个老 server。

所以系统一直把 tmux 和它下面的所有进程算在一个已经不存在的 Ghostty 名下。判定结果是「允许」,但放行没有落到这些进程上,流量就一直被拦着。这一段是根据日志推断的,不用 root 读不到进程的 responsible PID,没法直接证实。

对照试验:新开一个 Ghostty 窗口,不进 tmux,跑同样的命令,能连上。

为什么用 IP 能连

用 IP 的时候,-v 里同样有方括号,走的是传统的 socket。至少在我这里,传统 socket 不受这项检查影响,所以用 IP 或者设了超时都能连。反过来也说明,只是 ssh 能连不代表 tmux 里的其他程序也能连,用 Network.framework 的程序照样会被拦。

解决方法

  1. 在 ~/.ssh/config 里给对应的主机加上 ConnectTimeout,这是我现在用的办法:

    Host vm
    	HostName vm.local
    	ConnectTimeout 10

    这只是绕开了检查,哪天 Apple 把传统 socket 也管起来,就不灵了。

  2. 退出整个 tmux server(tmux kill-server,所有 session 都会关掉),在新的 Ghostty 里重新启动 tmux。按上面的分析应该能好,但我还没试。而且 Ghostty 每次自动更新都可能再来一次。

网上也有人遇到,Ghostty #13438(在新标签页打开) 里 iTerm2、Warp、VS Code 都有人报同样的错,有人发现重开 Ghostty 后 attach 回原来的 tmux 就会坏,和我的情况一样。帖子里还有关掉 SIP 删 networkextension 配置的办法,不太推荐。

遗留问题

  • 重启 tmux server 能不能修好,等我哪天舍得关掉所有 session 再试。
  • 为什么判定了「允许」还是拦,只能等 Apple 修。

参考资料