发布于
点对点传输从外面看很像魔法:两个浏览器、一个短码,文件就直接过去了,没有上传这一步。底下其实是一次分好几个阶段的协商,每个阶段都有自己的失败方式。搞清楚这些阶段,才能把「用不了」变成「双方都只收集到 relay 候选,而又没有配置 TURN 服务器」—— 后者是可以动手解决的问题。
信令不属于 WebRTC
规范有意没有规定两个对等端最初如何找到彼此。WebRTC 定义了需要交换的内容 —— 一份会话描述和一组网络候选 —— 但没有定义交换用的通道。你可以用 WebSocket、HTTP 接口、二维码、邮件,甚至让人在电话里把字符串念出来。这些都行得通,因为载荷本身就是一段文本。
正因为如此,每一个点对点工具都需要某种带外步骤。本站的房间码模式把这次交换放在同源接口上中转,接口的行为就是一个小信箱:房间保存在 SSR 进程自己的内存里,最多两位成员,单条载荷上限 96 KB,空闲 10 分钟过期 —— 连接期间保持,断开或超时后释放。它只存协商文本,别的什么都不存:文件从不经过它。
手动码模式连这一步也去掉了。会话描述被压缩成一个邀请码,由你自己带到对面,应答码再原路带回来,全程不向服务端发起任何请求。它更麻烦,但当你不希望协商文本接触任何服务器(包括本站)时,它就是正确的选择。
offer 与 answer
发起方生成一份会话描述,也就是 offer,对方回一份 answer。两者都是 SDP 文档 —— 一种从更早的信令协议继承下来的、以行为单位的文本格式。对于只有数据通道的传输,这份文档很短,而其中四行几乎承载了全部含义。
m= 行声明媒体段;文件传输用的是承载 SCTP 关联的 application 段,而不是音频或视频。a=ice-ufrag 与 a=ice-pwd 是一对短时凭据,双方用它来互相认证 ICE 连通性检查,防止无关主机往这次交换里注入检查包。a=fingerprint 行是对端 DTLS 证书的 SHA-256 摘要,a=setup 行说明哪一方担任 DTLS 客户端。
fingerprint 这一行值得特别注意,因为整个传输的安全性就落在它身上。数据通道用浏览器自己生成的证书做 DTLS 加密,而把这张证书和「你以为在跟他通话的那个人」绑定起来的,只有你通过信令收到的这个指纹。如果攻击者能改写信令载荷,他就能替换成自己的指纹并坐到中间。这才是邀请码真正重要的原因:它不是保护房间的口令,它是密钥交换的完整性。
v=0
o=- 4611731400430051336 2 IN IP4 127.0.0.1
s=-
t=0 0
a=group:BUNDLE 0
m=application 9 UDP/DTLS/SCTP webrtc-datachannel
c=IN IP4 0.0.0.0
a=ice-ufrag:F7gI
a=ice-pwd:x9cml6YzichV2QXlhiMu8g
a=fingerprint:sha-256 4A:AD:B9:B1:3F:82:18:3B:54:02:12:DF:3E:5D:49:6B:
19:E5:7C:AB:3A:0B:3D:2C:1E:0F:7A:66:55:44:33:22
a=setup:actpass
a=mid:0
a=sctp-port:5000
a=max-message-size:262144
# c=IN IP4 0.0.0.0 是正常的:真实地址以 candidate 行的形式出现,
# 要么在候选收集完成后一并放进 offer,要么采用 trickle 逐条发送。ICE 候选,以及每种类型说明了什么
在准备 offer 的同时,浏览器会收集候选:所有它可能被对方触达的地址和端口。每个候选都带一个类型,而类型是整个流程里诊断价值最高的字段。连接失败时,读一遍候选列表通常马上就知道问题出在哪。
候选会在两端之间两两配对,按优先级顺序用 STUN 连通性检查逐对验证,这套流程由 ICE(RFC 8445)定义。优先级的算法是 (2^24 × 类型偏好) + (256 × 本地偏好) + (256 - 组件号),按推荐的类型偏好算出来:host 候选是 2130706431,server-reflexive 是 1694498815,relay 是 16777215。这个排序也解释了为什么同一个 Wi-Fi 下两台笔记本之间的传输几乎瞬间建立、且完全不碰中继:host 配对在竞速里毫无悬念地胜出。
- 只有 host 候选、没有 srflx,说明 STUN 没有回应 —— 检查是否配置了 STUN 服务器,以及 UDP 是否被拦截。
- 一端只有 relay 候选,在限制严格的网络里是正常的;只有 relay 候选而又没有 TURN 服务器,这条连接就根本建立不起来。
| 类型 | 地址从哪来 | 类型偏好 | 意味着什么 |
|---|---|---|---|
| host | 设备自身的某个本地网络接口 | 126 | 同一张网络内的直连。浏览器会把它发布成一个随机的 .local mDNS 名称,页面脚本看不到私网地址 |
| prflx(对端自反) | 在连通性检查过程中发现的、双方都没预料到的映射 | 110 | NAT 给对端分配的映射和给 STUN 服务器的不一样 —— 这是地址相关映射的迹象 |
| srflx(服务器自反) | 由 STUN 服务器告知:NAT 分配的公网地址与端口 | 100 | 跨互联网直连的常规路径 |
| relay(中继) | 在 TURN 服务器上分配,所有字节都经它转发 | 0 | 只要 TURN 可达就一定能用,代价是带宽、额外延迟,以及得有人为此付费 |
NAT 的行为决定了有没有直连路径
STUN 的作用,是告诉一端「你从外面看起来是什么地址」。这个地址第三方能不能用,完全取决于 NAT 如何分配映射,而 RFC 4787 给出了描述这件事的术语。采用端点无关映射的 NAT,对同一个内部套接字总是复用同一个外部端口,不管流量发往哪里 —— 也就是说 STUN 报告的地址,对端可以直接用。家用路由器大多属于这一类,这也是打洞通常能成功的原因。
采用地址与端口相关映射的 NAT(常被叫作对称型),会为每一个不同的目标分配一个新的外部端口。于是 STUN 服务器观察到的那个映射对对端毫无用处,因为对端是另一个目标,会被分到另一个端口。当只有一端如此时,连接往往还有救:另一端可预测的映射给了检查包一个可瞄准的目标,由此发现的结果就表现为 peer-reflexive 候选。而当两端都如此时,根本不存在可以打洞的地址 —— 这时中继不是兜底方案,它是唯一方案。
STUN、TURN,以及本站为什么不运行中继
这两种服务器的成本结构差别极大,所以业界对它们的态度一直不同。STUN 服务器只需要用「我看到的地址」回应一个小请求,然后就把你忘掉;它不承载任何用户流量,运行成本几乎为零。TURN 服务器要代对端分配端口,并双向转发整个会话的每一个字节,也就是说它要为经过它的每一次传输支付全额带宽。TURN 还必须做认证,因为开放的中继就是滥用磁铁。
本站不运行也不托管 TURN 中继,点对点工具也不预置任何默认中继。它内置一份公共 STUN 地址目录供你选择,可以手动增删、逐个检测,也可以填入你自己的 STUN 或 TURN 服务器及凭据。在完全没有配置 ICE 服务时,工具仍会尝试局域网直连,这对同一个 Wi-Fi 下的两台机器已经够用。凡是需要跨网络的场景,服务器由你自己提供。
数据通道内部
候选配对选定之后,两端在这条路径上完成 DTLS 握手,并用 SDP 里的指纹校验证书。之上的一切都以封装在 DTLS 中的 SCTP 运行,这就是所谓的数据通道。加密不是可选项,也关不掉:WebRTC 里没有明文模式。
数据通道默认可靠且有序,这正是传文件需要的:字节恰好到达一次、按顺序到达,重传由协议栈替你处理。对延迟敏感的数据可以用 maxRetransmits 或 maxPacketLifeTime 换掉这份保证,但传输文件时默认值就是对的。真正不能忽视的是消息大小。实现之间会协商一个最大消息尺寸 —— 上面的例子里是 262144 字节 —— 把整个文件当成一条消息发出去必然失败。要分块,并且去读协商出来的上限,而不是想当然。
另一个实际问题是背压。在紧凑循环里不停调用 send,会以远快于网络排空的速度填满内存队列,大文件在传完之前就会把内存吃光。要盯住 bufferedAmount,设置 bufferedAmountLowThreshold,并在缓冲量降下来之前暂停读取。传输开始很快、随后卡住或者把标签页搞崩,几乎总是这个原因,而不是网络问题。
- 分块大小不要超过协商出的 max-message-size;64 KB 是广泛安全的块大小,16 KB 是最保守的选择。
- 每次都在同一个字节偏移处中断,那是分块或缓冲的 bug,不是 NAT 问题。
要点回顾
- WebRTC 不定义信令,所以每个点对点工具都需要带外步骤 —— 本站用的是 SSR 进程内存里的房间信箱,只存协商文本,空闲 10 分钟过期,文件从不经过它。
- SDP 里的指纹把 DTLS 证书和对端绑定在一起,所以保护邀请码的完整性,才是防止中间人的关键。
- 连接失败时先看候选类型:没有 server-reflexive 说明 STUN 没回应;只有 relay 候选却没有 TURN 服务器,连接就不可能建立。
- 能否直连取决于 NAT 的映射行为;两端都是地址与端口相关映射时,中继是唯一出路,而本站不提供中继。
- 分块要小于协商出的最大消息尺寸,遵守 bufferedAmount 的背压,并对两份文件算哈希确认内容一致。