公司内部的 IM 工具基于 WebRTC 做一套语音通话功能,近期后端在做 WebRTC 相关服务的迁移,在测试环境发现迁移后,有如下问题:
- 如果一台 Android 手机连公司内网,另一台 Android 手机连公司内网但开美国 VPN 的情况下,两台手机无法打通语音通话,点击接通后始终处于连接状态,无法接通。
- 如果是 iOS 设备或者 Desktop,则能打通。
- 如果是 iOS 设备连内网开美国 VPN,Android 设备直接连内网,可以打通。
自然这个问题的调查就交给了 Android 组。
初步结论
我对 WebRTC 并不熟悉,因此让组里接触过语音通话功能的同事进行调查,初步结论如下。
通话双方 Android 的 STUN binding request 一直没有响应,导致无法连接。
且发现 Android 设备无法 ping 通 STUN 服务器。
怀疑是挂了美国 VPN 之后,导致 Android 手机连不上 STUN 服务器,导致 ICE 建连失败。
暂时没有明确结论和解决方案。
后来发现 ping 不通 STUN 服务器,其实是服务器故意不响应 ping,如果 telnet 到对应的端口,是畅通的。
由于同事在这个事情上已经花了将近两天的时间调查,仍然没有头绪,且还要做需求,需求有明确的时间节点,不能抽太多时间调查;其他同事也没有相关的经验,都是各有任务在身,这个事情如果不解决,则迁移工作受影响,所以我放下手头的工作,专心查这个问题。
前期调查的结果,在我看来还是有很多疑点的,既然现在由我继续调查工作,首先就是提出我的疑问。
对前期调查的疑问
- 初步调查提出怀疑是挂了美国 VPN 之后,导致 Android 手机连不上 STUN 服务器导致通话无法连通,那为什么会出现通话双方的 STUN binding request 均没有响应?理论上,对方开不开 VPN,都不会影响我这边与外网服务器的连通性。
- iOS 和 Desktop 两端,任意一端开 VPN,另一端不开,能打通,那它们到底是怎么打通的?
- 使用的 VPN 软件,是不是开了全局模式。
我对 WebRTC 了解的不多,但我还是知道在双人通话的情况下,通话双方可以是 P2P 直连通话,也可以是经过服务器中转通话。前期调查时并没有调查 iOS 和 Desktop 是怎么连上的,我很想知道为什么。
为什么非 Android 能打通
我自己在家办公,且没有还原现场网络环境的设备和条件,幸好以前的同事做了一个通话记录平台,可以聚合展示某个通话记录中客户端、服务器的日志,根据同事提供的通话时间点,我查看了几个打通了的通话记录,其中随便挑了一个截图:
这里虽然日志有点奇怪,但还是看得出来,通话双方是采用 P2P 直连的方式通话的,如果你看不懂也没关系,后面我会解释,回过头就能明白为什么能看出这里是 P2P 直连了。
这就是一个很关键的信息,几乎所有 iOS、Desktop 打通的语音通话,都是直连模式,所以我的猜测就来了。
猜测:测试环境语音通话无法中转
既然能打通的通话都是直连,而 Android 又出现打不通的情况,那极有可能是因为 Android 设备因为开了 VPN 导致没法使用直连,需要中转,但测试环境,迁移后的服务器无法中转,导致一直无法连通。
要验证这个猜测很简单,在测试环境用一台使用蜂窝网络的 iOS 设备给连接内网 Desktop 发起语音通话,一般来讲蜂窝网络是没法完成 NAT 穿透的,所以必须中转,试验结果是和 Android 表现出一样的问题,一直提示连接中。所以问题的关键并不是 Android 无法打通,而是中转服务不正常,只要是需要走中转的,都无法连通。
所以其实问题的关键是为什么无法中转。本次服务器迁移,恰好迁移的就是 TURN 服务器,TURN 的职责就是做转发。好像这个结论说了跟没说一样,但至少证明我可以在家重现这个问题了。
为什么 Android 开 VPN 无法直连
iOS、Desktop 开了 VPN 也能直连,而 Android 不行,我感觉是因为这个 VPN 软件的实现在各个平台不同,在 Android 上可能是类似全局代理的模式,而在其他端,至少是不代理局域网流量的。实际下载了测试使用的同款 VPN 软件,发现在我家里根本就连不上,可能是它比较有名气,都被封了。但从软件的界面上看,这个软件确实没有提供类似是否全局代理的选项
猜测:Android 无法直连是因为 VPN 代理了局域网流量
好在现在我在家里也能连测试环境复现问题,所以要想验证这个猜测,也非常简单。我手头是一台 iPhone 和一台 Android。
两台设备都连接 WiFi,直接进行语音通话,能打通,通过语音后台的记录,确认两台设备使用 P2P 直连通话。证明这两台设备在这个局域网是可以直连的。
Android 设备连 WiFi,不开 VPN,iPhone 使用蜂窝网络,进行语音通话,此次无法打通,一直显示连接中,此时两台设备无法直连,没有打通,证明后台转发服务没有正常工作。
Android 设备连 WiFi,开启 VPN,使用绕过 LAN 模式,不代理对局域网的流量,iPhone 连 WiFi,进行语音通话,依旧能打通,通过语音后台的记录,确认开启 VPN 但不代理 LAN 地址的情况下,两台设备使用 P2P 直连通话。
Android 设备连 WiFi,开启 VPN,使用全局模式,iPhone 连 WiFi,进行语音通话,此次无法打通,一直显示连接中,证明全局代理的情况下,的确会造成设备无法直连,且因为后台转发服务没有正常工作,导致无法打通。
至此可以确定,问题场景中 Android 设备,是因为 VPN 软件的实现导致其无法与局域网其他设备通信,导致语音通话时无法直连。
为什么 STUN binding request 没有响应
我在自己的手机上复现问题之后,也观察到 logcat 里打出了很多 STUN binding request,但没有 STUN binding response,正常情况下,至少有一个 STUN binding response。
同时也发现,出问题的时候,客户端其实是收集到了自己的一系列 ICE candidate 了的,同时也拿到了对方的多个 ICE candidate,应该是 ICE 过程失败了。
猜测:STUN 服务器连通性有问题
我第一眼看到 STUN binding request 没有响应这个现象的时候,下意识判断 STUN 服务器的连通性有问题,然而这个猜测是错的。是因为我对 WebRTC 不熟悉,才得出这个错误结论。这里说一下了解过一些信息后,我对 WebRTC 的理解。
WebRTC:STUN、TURN、ICE
收集 ICE candidate
两台手机如果想通过 WebRTC 通话,必须要建立连接,建立连接用的技术叫 ICE(Interactive Connectivity Establishment)。
但由于通话双方彼此都不知道对方在哪,因此必须要能先交换一部分信息,信息交换通过信令服务器(Signaling server)进行。在我们的 IM 里,客户端会使用 WebSocket 与信令服务器建立连接,这样就可以交换信息了。
交换的信息中除了各自的媒体信息以外,对建连最重要的是 ICE candidate,ICE candidate 的收集光靠 WebRTC 客户端是不可能做到的,需要一台在公网的服务器辅助,这台服务器就叫 STUN(Session Traversal Utilities for NAT)服务器。
要想得到 STUN 的帮助,必须要先发一个 UDP 请求给 STUN,这个请求一般被称为 STUN binding request,之后客户端会和 STUN 有多次请求,客户端会拿到自己想要的 candidate 信息。除了 STUN,还有 TURN(Traversal Using Relays around NAT)服务器,WebRTC 客户端会向 TURN 申请一个地址,用于转发数据;可以把这个地址理解为一个快递转运公司,如果其他端想办法将快递发到这个地址,则 TURN 会将快递转发给对应的 WebRTC 客户端。
UDP ICE candidate 包括如下几种类型:
- Host candidate(简称 host):也就是设备的本地IP地址,一般我们看到的设备IP地址就是这个。
- Server-Reflexive candidate(简称 srflx):设备访问 STUN时,经过 NAT 设备后,STUN 看到的访问设备的地址,一般代表NAT设备上给其分配的公网地址。如果设备直接在公网上,那么就和 Host candidate 一样。
- Relayed candidate(简称 relay):设备向 TURN 服务器申请的 relay 地址,如果不能成功进行点对点连接,则另一个设备可以将数据发给这个 relay 地址,relay 服务器会将数据转发给本设备。当然此时,可能对端也会有它的 relay 地址。实际 relay 服务器可以就是 TURN 自己。
- Peer-Reflexive candidate(简称 prflx):在 ICE 建连的检查连通性过程中,本机可能接收到了对端的请求,本设备看到的对端的地址可能不在上面的候选地址中,于是叫它 Peer-Reflexive Candidate。
TCP 还有三种 ICE candidate,这里不赘述。
Offer & Answer
通话双方都会收集自己的 ICE candidate,然后彼此互换信息,交换 ICE candidate 的阶段,通话双方还没有建立有效的连接,所以依旧要通过信令服务器进行交换,这个步骤中,一定有一端 A 主动发出一个 Offer,另一端 B 收到 Offer 后,就知道了 A 的 ICE candidate,同时将 Answer 发给对方,A 收到 B 的 Answer,就知道了 B 的 ICE candidate。
信令服务器可以决定谁发出 Offer,比如由信令服务器给某一端下发一个 require_offer 指令,这一端收到之后发出 Offer,随后信令服务器将其交给另一端,再给另一端下发一个 require_answer 指令,另一端收到后发出 Answer,信令服务器再将 Answer 转交给发出 Offer 的那一端。
这样双方都有自己和对方的 ICE candidate 列表,列表中 candidate 一一配对,就会有各种 candidate pair。
比如 ( local host,remote host),一般如果两台机器在同一个局域网,或者直接都在公网上,且可以访问局域网内的其他设备,则这个 candidate pair 就能直接连通。
又比如 (local server-reflexive,remote server-reflexive),如果双方处在各自的 NAT 设备后面,但这俩 NAT 设备对 NAT 穿透非常友好,则可以通过这个 pair 连通。
连通性检查
由于实际网络情况复杂,很多 candidate pair 在理论上就是不可能连通的,此外就算理论上可能连通,实际也可能不通,所以要对 candidate pair 的连通性做检查,检查完毕后可以提名其中的质量最好的 pair 双方使用。
这里检查 ICE candidate pair 连通性的动作就是发送 STUN binding request,A 针对每一个 ICE candidate pair 都发出 STUN binding request,如果单向能连通,则 B 会收到这个 request,B 会发出一个对应通道上的 STUN binding response,这个连通性检查通话双方都会做,即 B 也会发 request,等待 A 的 response。为了和之前与 STUN 交互的 STUN binding request 区分,我们可以把检查连通的称为 peer-to-peer STUN binding request。
检查连通性阶段,每个 WebRTC client 在收到 STUN binding request 后,都会发出 STUN binding response。这里的 STUN binding request & response 本质上是用来检测 candidate pair 连通性的,只不过复用了收集本端 ICE candidate 阶段中使用的 STUN binding request 和 STUN binding response 的值,不要把这里的 STUN binding request 和收集 ICE candidate 时与 STUN 交互的 STUN binding request 混为一谈。
最终双方会挑选质量最好的 candidate pair 进行数据传输,我们的通话记录后台里看到的 ICE Candidate Change 其实就是通话双方选择的 candidate pair,因此只要其中没有出现 relay 的字样,就说明没有通过服务器中转,而是直接 P2P 直连。
比如下图就是一个典型的使用 relay 的记录。
时序问题
虽然我上面把这些步骤分成了好几个阶段,但实际上它们并不是严格的在时间上分离的。
比如双方的 ICE candidate 收集完毕的时间可能不同,当要发出 Offer 那一端 A 收集 ICE candidate 完毕,就可以发出 Offer,等待 Answer;另一端 B 收到 Offer 后,假如自己的 ICE candidate 已经收集完毕,就可以发出 Answer。
同时由于 B 端此时已经拿到了双方的 ICE candidate,它就会开始检测各种 candidate pair 的连通性,但 A 有可能还没有收到 Answer,所以 A 还没有进入检查连通性的阶段,B 发出的所有 peer-to-peer STUN binding request 都不会有任何响应,即有可能超时,B 会不断的进行检查,并不会因为超时而停止检查;同时 B 也会响应其收到的 peer-to-peer STUN binding request,只不过此时没有任何端给它发而已。
当 A 收到 Answer,则 A 也会进入检查连通性的阶段,此时 A 一方面也会发 peer-to-peer STUN binding request,另一方面也会响应收到的 peer-to-peer STUN binding request。
谁应该 response
根据上面的描述,以及实际运行的情况,日志里打出的 STUN binding request 指的是 peer-to-peer STUN binding request,这些 request 其实是由 remote ICE candidate 来响应的;remote ICE candidate 中还有一个对端的 relay 地址,所以 STUN binding request 是由对端或对端的 relay 来响应的,其中自己的 TURN 可能需要做中转,所以 peer-to-peer STUN binding request 有可能发给对端、自己的 TURN 或对方的 relay,但具体响应还是对端来响应。
收集 ICE candidate 阶段的 STUN binding request 由 STUN 响应,检查连段性阶段的 peer-to-peer STUN binding request 由发出方的对端响应,不能混为一谈。
实际查看日志,通话双方都收集了自己的 ICE candidate,也收到了对端的 ICE candidate,candidate 中都有 server-reflexive 和 relayed 地址,因此 STUN 和 TURN 的连通性一定是正常的。
那为什么拿不到 STUN binding response 呢?
直接发给对端的 binding request,对方显然是收不到的,因为挂了 VPN,这个 VPN 是全局代理的,导致无法和局域网内的设备通信,因此才需要 relay。所以问题的关键是为什么 relay 服务器没有成功转发 peer-to-peer STUN binding request。
遗憾的是,WebRTC 中 STUN binding request 失败是非常普遍的事情,而且很多时候建连过程也不是简单的请求一次就完事,我们的 IM 中并没有打印 STUN binding request 失败的细节。在仔细看了所有日志之后,我确信日志中没有我想要的答案。
relay 地址为公网 IP 的情况
直观上,relay 地址应该是一个公网地址,比如可以是本端的 TURN 地址 + TURN 分配端口,当然 TURN 也可以分配另一台服务器的地址 + 端口作为 relay。
如果对端的 relay 地址为公网 IP + 端口,则检查连通性 / 数据传输阶段直接向其发送数据即可。不过我们这里有些不同。
为什么 relay 服务器不响应
在调查期间,我发现其实 relay 的地址和 TURN 服务器地址并不是一致的,甚至都不是一个公网地址,比如下面是正式环境的截图:
正式环境明显能看到,relay 是内网地址,而 TURN 需要在公网上提供服务,所以不可能是这个地址。
可以通过这个工具 https://webrtc.github.io/samples/src/content/peerconnection/trickle-ice/ 来测试 STUN / TURN 服务器返回的结果。我实际测试了正式环境的 TURN,发现 relay 确实是给的内网地址。
relay 地址是否有问题
按一般对 TURN 的理解,这个 relay 地址 IP 至少应该是一个公网地址,实际抓包查看,发现这个地址还真是故意给的内网地址。
由于 Android 不方便抓包,我这里是用 Wireshark 抓的 Desktop 的包:
向 TURN 申请 relay 的请求:
TURN 返回的响应:
可以看到响应中 XOR-RELAYED-ADDRESS 中确实就是给的一个内网地址。
正式环境的 relay 地址因为也是内网地址,所以肯定是不会有响应的,抓包也证实了这一点。
发送了大量的 Binding Request,却没有任何回应,这是很正常的,毕竟我这个局域网内根本不可能有公司部署的 relay 服务器,如果客户端直接向 relay 地址发请求,得不到任何响应,那正式环境 relay 是如何工作的呢?
Private IP relay 如何工作
正式环境 relay 地址是一个 Private IP,所以语音数据并不是发给 relay 地址,还是发给了自己的 TURN 服务器。
发数据之前,会先 CreatePermission,将对端的 relay address 填入 XOR-PEER-ADDRESS。
CreatePermission 成功后,因为这个 relay 地址一定出现在了某个 candidate pair 中,接下来对这个 candidate pair 做连通性检查,即发出 peer-to-peer STUN binding request。
如果收到响应,再给 TURN 发 Channel-Bind Request,对端的 relay address 再次作为 XOR-PEER-ADRESS 出现,同时带有一个客户端指定的 ChannelNumber。
拿到 Channel-Bind Succues Response 之后,就可以给 TURN server 发送 ChannelData,ChannelData 中带上同样的 ChannelNumber,TURN 看到这个 ChannelNumber 就知道应该转发给谁。
所以这个 relay address 在客户端看来,只是一个类似对端的 relay 虚拟地址一样的东西,并不是一个可以直接访问的的地址,告诉 TURN 我要给这个虚拟地址发数据,TURN 就会把数据转发给对端。
(截图中 relay 地址有变化,是因为来自不同的通话记录,但都是内网地址)
所以追查 relay 为什么没响应是没意义的,因为公司 IM 里用的这个 TURN,relay 模式下,数据不是发给 relay 地址的,依旧是发给 TURN。
测试环境为什么 relay 无法连通
上面知道了正式环境下 relay 是怎么工作的,接下来就看测试环境为什么它不工作。抓包之后发现其实是 CreatePermission 这一步失败了。
WebRTC:CreatePermission 与 Indication & ChannelData
TURN 可以用来转发数据,如果有数据想通过 TURN 发给对端,可以要求 TURN 转发。
TURN 并不是只能用于转发数据到 relay 地址,还可以转发数据到 srflx 地址。
由于 TURN 转发数据时就像一个中转服务器,企业 IT 管理部门可能会担心其被用于绕过防火墙,所以 TURN 在转发数据前,需要客户端先 CreatePermission。
CreatePermission 成功之后,TURN 就可以转发客户端的数据了,客户端要求其转发数据有两种方式。
Send Indication & Data Indication
Send Indication 就是指明我要发给的对端地址,可能是 server-reflexive 或 relayed 地址,可以携带数据,对端发送的 Send Indication 如果被转发给本设备,则是 Data Indication 。
ChannelData
Send Indication 和 Data Indication 有额外的地址开销,对于 VoIP 这类应用场景,影响比较大,可以发送 Channel bind request 要求 TURN 给某个地址绑定一个 ChannelNumber ,之后本设备发的 ChannelData 中只要带有 ChannelNumber,TURN 就知道要转发给谁。这样可以省去额外的地址开销。
为什么 CreatePermission 失败
由于我不是 TURN 的开发,所以我也不知道我们的 TURN 是什么写的。但我想,后台的 TURN 八成是借鉴了 WebRTC 的 sample,所以我在 WebRTC 的源码里翻了一下,顺着 Forbidden 就找到 CreatePermission 的代码:
void TurnServerAllocation::HandleCreatePermissionRequest(
const TurnMessage* msg) {
// Check mandatory attributes.
const StunAddressAttribute* peer_attr =
msg->GetAddress(STUN_ATTR_XOR_PEER_ADDRESS);
if (!peer_attr) {
SendBadRequestResponse(msg);
return;
}
if (server_->reject_private_addresses_ &&
rtc::IPIsPrivate(peer_attr->GetAddress().ipaddr())) {
// 这里导致 CreatePermission 失败
SendErrorResponse(msg, STUN_ERROR_FORBIDDEN, STUN_ERROR_REASON_FORBIDDEN);
return;
}
// Add this permission.
AddPermission(peer_attr->GetAddress().ipaddr());
RTC_LOG(LS_INFO) << ToString() << ": Created permission, peer="
<< peer_attr->GetAddress().ToSensitiveString();
// Send a success response.
TurnMessage response;
InitResponse(msg, &response);
SendResponse(&response);
}
这里会从 CreatePermission 请求中拿到 XOR-PEER-ADDRESS 对应的地址,根据 TURN 的 reject_private_addresses 配置,决定要不要拒绝私有地址。私有地址的判断代码如下:
static bool IPIsPrivateNetworkV4(const IPAddress& ip) {
uint32_t ip_in_host_order = ip.v4AddressAsHostOrderInteger();
return ((ip_in_host_order >> 24) == 10) ||
((ip_in_host_order >> 20) == ((172 << 4) | 1)) ||
((ip_in_host_order >> 16) == ((192 << 8) | 168));
私有地址其实就是一般用于局域网内分配的地址,按这个代码的话,正式环境分配的 relayed address 10.127.5.4 是私有 IPv4 地址,测试环境分配的被拒绝的 relayed address 10.71.19.38 也是私有 IPv4 地址,所以正式和测试的 TURN ,应该就是reject_private_addresses
参数不同。
搜了一下 sample 的代码,这个属性默认是 false,还得故意调用set_reject_private_addresses
才能改成 true,但我感觉大概率就是这个问题了。
最终结论
联系 SRE 检查,果然启动服务的时候有一个拒绝私有 IP 的选项,在测试环境配置成了 ture,改为 false 之后,Android 开 VPN 就打不通语音通话的问题消失了。
花了一天多的时间解决了这个问题,调查这个问题,很多 WebRTC 知识都是一边看日志一边查资料,也正是因为我对 WebRTC 不了解,所以才在查这个问题的时候走了很多弯路,浪费了很多时间。另外就是整个过程基本是无源码情况下调查,一般对于不熟悉的东西,我不会通过看源码的方式查问题,看源码是绝招,但也是没办法的办法。
疑难问题调查其实就是对知识储备和经验的考察,如果是一个比较了解 WebRTC 的人,应该在二十分钟内就能查出这个问题的根本原因。
后续问题
在这个问题调查结束几周后,又发现了类似的问题,这次是 CreatePermission 成功,但客户端日志显示全部检测连通性的 STUN binding request 失败,所以没有 Channel bind request。
起初这个问题是其他人,包括做语音通话的人查的,结论是运营商屏蔽了 UDP,于是建议我们使用 TCP 和 STUN / TURN 通信,我得知后第一反应是质疑这个结论,因为 ICE 阶段到 CreatePermission,都是 UDP 通信,都没有问题,运营商为什么非要去屏蔽 CreatePermission 之后的 STUN binding request 这系列 UDP 请求?
当对端 relay 地址为私有 IP 时,本端首先向自己的 TURN 请求 CreatePermission,然后要求 TURN 转发 STUN binding request 检查连通性,此时需要对端响应,如果 STUN binding request 没有响应,则不会执行 Channel bind request。
如果这个 candidate pair 双方都用 relay,则对端应该向本端 relay 发出 STUN binding response,本端才能收到响应。
这个问题的不同点在于,TURN 是没问题的,关键在其中一个通话方拿到了对端的 ICE candidate,拿到之后就断了网,导致这端与信令服务器断连,其 ICE candidate 没有发给信令服务器,导致对端没有收到本端的 ICE candidate。
此时对端还在等待 ICE candidate,本端已经开始 ICE 建连,但又没网,所以根本没法建连成功。
这个问题我能推翻错误的结论,是因为我认定检查连通性的 STUN binding response 应该由对端响应,而非 TURN 响应,检查了对端的日志发现它根本没有到检查连通性的阶段,更别提响应 STUN binding request,一直在等待对端 ICE candidate。有了这个关键发现,追查下去,才发现是其中一端断了网,断网的原因与 iOS 系统有关,但至少和运营商屏蔽 UDP 没什么关系。
作者:Wan Xiao
版权声明:本文内容转自互联网,本文观点仅代表作者本人。本站仅提供信息存储空间服务,所有权归原作者所有。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至1393616908@qq.com 举报,一经查实,本站将立刻删除。