公司前段时间做了个功能,本地客户端会启动一个 webview 窗口,并通过它和本机浏览器上的一个页面建立 WebRTC 通信,然后互相交换数据。本地的 webview 会借助客户端的通信能力,和浏览器上的网页各自接收服务器的消息,满足条件后就可以借助服务器交换 icecandidate 和 sdp,随后建立连接。
但是我们在使用过程中发现了问题,Mac 机器的 WebRTC 无法成功建立。因为公司的机器上安装了 Zscaler,它是个网络流量监控和代理的工具,所以很快怀疑到它身上,测试一下发现把它退了后就能成功连接了。另外 windows 系统并没有受到它影响,退不退 Zscaler 都可以连接。

调查

chrome 浏览器提供了一个 chrome://webrtc-internals 调试工具,可以让我们观察 WebRTC 建立过程中的数据交换。
webrtc internal success

webrtc internal failed

借助它我们可以直观地看到连接失败的地方,比如上图中 ice connections state 就从 connecting 最终变成 disconnected。

此外也可以查看交换的 sdp 信息,ice 的用户名密码等参数。

那接下去自然是调查为什么失败。因为两端都在本地主机上,并且我们代码里也没有加入打洞用的 STUN/TURN 服务器,所以就不可能涉及到 NAT 方面的限制。而且从面板上我们也可以看到双方 candidate 里提供的都只有 host 类型。但是就算双方都是本地,交换端口等流程还是会走的。

接下去用 wireshark 抓包,查看 stun 包的收发状态。下图是在出问题的 Mac 机器上过滤出的 stun 包,我们看到本机 ip 10.100.X.X 的数据全部发往 100.64.0.1 这个 IP 了,它是个用于运营商级 NAT 中用户和服务商通信的保留 ip。100.64.0.1 没有对 stun 包作任何响应,p2p 连接自然也就中断了。

webrtc internal failed

而正常情况应该是本机 ip 发往本机,然后两个端口分别收到对方的请求,并各自向对方回复带有 XOR-MAPPED-ADDRESS 的包。(这里码掉的都是相同的本机 ip)

webrtc internal failed

用 ifconfig 遍历网卡发现本地有个 utun 网卡,它的地址正是 100.64.0.1。退出 Zscaler 或者关闭它的 Internet Security 功能后,stun 包就会全部发往本机 ip 了,可见这些都是它搞的鬼。

webrtc internal failed

重新看 WebRTC 中 icecandidate 的交互过程,WebRTC api 里提供的是一个形如 {GUID}.local 的域名。而关闭 Zscaler 前后这个域名的解析结果也从 100.64.0.1 变成了本机 ip,看来被干扰的是域名的解析过程。

mDNS

这个 .local 域名是用在 mDNS 上的。

mDNS(Multicast DNS,多播DNS)协议可以让局域网内设备自动进行域名解析。在没有中央 DNS 服务器的情况下,允许设备自我注册和查询主机名、IP 地址等信息。mDNS 使用与标准 DNS 相同的报文格式。

早期 WebRTC api 中提供的 candidate 地址是本地 ip,而这样会将 ip 暴露给 js 代码,所有网页都可以有意无意地收集我们的本机 ip。

为了解决这个安全问题后来引进了新的方式。当 WebRTC api 需要提供一个私有 ip 时,它就会改用 mDNS 地址代替,即随机生成一个 *.local 域名。在随后的 SDP 和 ICE 协商中都用这个域名。

使用 mDNS 域名后整个过程中 js 代码、信令服务器、STUN/TURN 服务器都只会拿到 mDNS 地址。而解析 mDNS 域名又是在本地网络上进行的,这就避免将 ip 暴露给它们了。

解析 mDNS 域名

常规的域名解析是向设定好的 DNS 服务器 53 端口发送 udp 报文,而 mDNS 是向本地网络的多播地址 224.0.0.251:5353(ipv6 是 [FF02::FB]:5353)发送 udp 请求。一旦系统 API 发现要解析 .local 域名时,它就会用 udp:5353 作为源端口发送多播查询,同时在 udp:5353 上等待响应结果。(如果 API 并不完全支持 mDNS 标准,那它会用一个 5353 外的随机高位端口发送查询。)

查询数据包发送给多播地址后会被分发给本地网络所有支持多播的网卡的 5353 端口上,如果有进程在该网卡的 5353 端口监听,那这个进程就会收到数据包。

当有进程对查询作出响应后,会通过该网卡地址的 5353 端口往 224.0.0.251:5353 发送响应。这样还有个好处就是能让所有解析器都能更新它们的缓存,避免大量的多播查询在内网里来回传输。

通过抓包发现出问题的时候多个网卡都不约而同地回复该域名需要解析到 100.64.0.1。

webrtc internal failed

一些猜测

虽然能知道是 Zscaler 搞的鬼,但是没法进一步找到它在哪里做了手脚。是我们 IT 错误的配置了某些规则,还是说 Zscaler 的代码里写死了某个特殊逻辑把 local 域名解析到了这个地址。

我也对比了路由表,发现退出 Zscaler 前后并没有什么变化,查看监听 5353 端口的进程也没有发现 Zscaler 的身影,只有 chrome 自己而已。

另外 Windows 版本的 Zscaler 使用的 Tunnel Versionv2.0 – TLS,但出问题的 Mac 上使用了 v1.0,因为这个解析跟 tunnel 相关,所以怀疑两个版本间行为是有变化的。
我同 IT 一起排查时发现 admin 面板中可以针对 2.0 的 tunnel 加上 DNS 查询的白名单,这样也许能排除对 local 域名的干扰,但 Mac client 目前没法升级到支持 2.0 的更高版本,所以暂时没法验证。

目前的解决方案

其实看之前的分析,还是有个方法可以用来绕过的。只要让其中一方发送 ice candidate 时直接提供本机 ip,这样对端就能直接向正确的地址通信,绕过 mDNS 解析这一步。

但是原本引入 local 域名就是为了解决本机 ip 暴露给 js 代码的,光靠它自己肯定无法绕过。所以浏览器一端我们做不了什么了。但是我们还有对端的 webview 可以跟客户端相互通信,所以借助客户端能拿到本机 ip,然后再添加到 candidate 里发给浏览器的网页端。虽然还是“暴露”了 ip,但在这个场景上用于自家产品对接,勉强能接受吧。

PS:目前 firefox 还可以在 about:config 里修改 media.peerconnection.ice.obfuscate_host_addresses 为 false 来提供 ip,但是 chrome 已经没有关闭的选项了。

参考

https://datatracker.ietf.org/doc/html/rfc6762
https://groups.google.com/g/discuss-webrtc/c/4Yggl6ZzqZk/m/nV24XkXXAQAJ