做 WebRTC 第一个点对点 Demo 的时候,我印象最深的不是 ICE 打洞有多难,而是 ICE 已经显示 connected,DTLS 却一直卡在握手状态。日志里翻来覆去只有一行 waiting for peer's ClientHello,然后等几秒超时,重启握手,再超时。后来我把 WebRTC DTLS 相关的坑整个趟了一遍,才发现这类问题里只有一小部分是真正的"协议实现"问题,绝大多数是 API 用错、角色配反、SDP 字段没对上。
这篇文章把我从第一次跑通 P2P 到稳定上线的过程中遇到的高频 BUG 和排查思路完整整理了出来,按"先懂链路、再看现象、用一套可复用的方法定位、最后回到代码层修正"的顺序来写。适合刚把 ICE 跑通、正被 DTLS 握手折磨的读者,也适合做网关、媒体服务器集成的人参考。内容全部基于我在实际项目中踩过、修过的坑,部分配置和工具用法以 OpenSSL 的常见实践为准。
1. 先把 DTLS 在 WebRTC 链路里的位置说清楚:角色、SDP 与握手顺序
WebRTC 的媒体链路可以简单地分成三段:ICE 负责解决"怎么把包从 A 传到 B",DTLS 负责解决"这段通道够不够安全、SRTP 的加密密钥从哪来",SRTP 负责实际音视频包的加解密。ICE 通了之后,DTLS 才开始握手。它不是把 TCP 上的 TLS 拿来直接用,而是把 TLS 重新实现到 UDP 上,这套东西叫 Datagram TLS,也就是 DTLS。
DTLS 和 TLS 的差异,是理解后面所有 BUG 的基础,下面三条记住就能少踩一半坑:
- TLS 跑在 TCP 上,字节流有序可靠,TLS 状态机不需要考虑丢包。DTLS 跑在 UDP 上,每个握手消息都可能丢、可能乱序,所以 DTLS 在 Record 层做分片和重组,在握手层自己做重传。
- 因为 UDP 没有"连接"的概念,DTLS 用 cookie 机制(HelloVerifyRequest)防放大攻击。很多"ClientHello 发出去了对方不回应"的现象,其实是 server 在等你的第二个带 cookie 的 ClientHello。
- TLS 有明确的 client/server 角色,但在 WebRTC 里,DTLS 的 client/server 不是你代码里谁先调用 API 决定的,而是由 SDP 里的 a=setup 属性以及 ICE 角色共同决定。
这里多说一句 a=setup。offer 里通常写 actpass,answer 里写 active 或 passive。active 的一方是 DTLS client,负责先发 ClientHello;passive 的一方是 DTLS server,等 ClientHello 来了再应答。最常见的搭配是:offerer 写 actpass,answerer 回 passive,结果是 offerer 作为 ICE controlling 兼 DTLS client 主动发起握手。如果两边都把自己配成 passive,就会双双等对方先开口,握手永远起不来;如果两边都是 active,两个 ClientHello 撞在一起,也是一堆诡异现象。
所以"谁先调用 SSL_connect"不是拍脑袋决定的,它应该直接从 SDP 解析结果里来,再结合 ICE 角色做一个最终确定。我见过不少新手把角色硬编码成 server,结果只跟自己的服务端测通了,一到浏览器就握手超时。原因很简单:浏览器作为 answerer 通常回的是 passive,它默认你是 active,结果你俩都在等对方。
1.1 SSL 上下文也要分客户端和服务端,别一个 CTX 打天下
OpenSSL 的 DTLS 有 DTLS_client_method() 和 DTLS_server_method(),还有不做限定的 DTLS_method()。实践里有个容易忽略的点:用客户端方法创建的上下文去跑服务端逻辑,通常不会有明显报错,但行为偶尔很怪,尤其是版本协商和 cookie 处理。反过来,服务端方法创建的上下文拿来当客户端用,握手也可能以很奇怪的方式失败。
我的习惯是分别建 client CTX 和 server CTX,里面除了 method 不同,其余证书、SRTP 扩展、cipher 配置完全一致,角色从 SDP 的 a=setup 判定后取对应 CTX 创建 SSL 对象。不要图省事共用一个 CTX 创建两个 SSL 来回调 SSL_connect / SSL_accept,虽然大多数情况能跑,但一旦遇到重协商场景就有概率出幺蛾子。用独立 CTX 之后,每次握手失败重来也更好重置,不用纠结一个 SSL 对象能不能二次握手。
1.2 握手完成的判断标准:别拿 SSL_is_init_finished 当唯一依据
很多人在握手这一步只判断 SSL_is_init_finished 是否返回真,然后就冲进去初始化 SRTP,结果媒体还是不通。因为 DTLS-SRTP 的握手成功,至少包含三层含义:双方交换完 Finished;协商出共同的 SRTP protection profile;导出并正确分配了密钥材料(通常是 60 字节,后面细说)。SSL_is_init_finished 只覆盖第一层,后面两层都依赖你主动去检查。
正确顺序是:握手返回成功、SSL_is_init_finished 为真之后,先取 SSL_get_selected_srtp_profile,确认 profile 不为空,再去做密钥导出和指纹校验。指纹校验其实应该在握手过程中通过 verify 机制取到对端证书,但真正的比对动作放在握手完成之后更稳妥,因为这个时候你能拿到完整的对端证书内容。
2. 上手期命中率最高的五个 BUG:现象、根因和一句话结论
下面这五个是我在开发周期里反复见到的坑,也是和同行交流时出现频率最高的几类。每个我都会先说现象,再说根因,最后给一句可以直接记住的结论。
2.1 ClientHello 有去无回:角色反了还是包没到该去的地方
现象:一端日志里一直打印 send ClientHello,另一端却始终不回应。先别怀疑网络,先看角色:把两端 SDP 的 a=setup 打出来,如果两端都是 passive,就必然这样。如果是 active 发了 ClientHello 但对面不回应,再看对端是否只处理已经完成 ICE 选定的 candidate pair。注意 DTLS 握手包要跟着 ICE 选中的 nominated pair 走,而不是随便挑一个 pair 发。还有一种情况:服务端的 UDP socket 和 ICE agent 使用的 socket 不是同一个端口,握手包到了 agent 手里,却因为不认识 DTLS record 头被当成未知包丢弃了。
角色确认没问题、pair 也对了仍然超时,大概率是 cookie 流程出了问题。DTLS server 收到第一个 ClientHello,会回一个带 cookie 的 HelloVerifyRequest;client 收到后要用同样的 cookie 重新发第二个 ClientHello。如果代码用的是普通 UDP recv/send 循环,但你在处理 HelloVerifyRequest 时把它当普通握手消息丢弃,那么 server 永远等不到带 cookie 的 ClientHello,表现为"server 端日志显示收到了包,但一直接收不到握手"。拿到 OpenSSL 的话,把收到的每个 UDP 包都喂给 read BIO、让 OpenSSL 自己处理 cookie 就行,不需要应用层干预。
一句话结论:角色和 candidate pair 优先于一切网络怀疑,cookie 流程别拦腰砍。
2.2 指纹不匹配:你以为你校验了,其实校验的是自己
DTLS-SRTP 的认证不是靠 CA 链,而是双方在 SDP 里交换自签证书的 SHA-256 指纹,拿到对端证书后重新计算并比对。这个逻辑本身不难,但有两个高频错法:一是 X509_digest 用的是本地证书而不是 SSL_get1_peer_certificate 拿到的对端证书;二是把 PEM 文本直接拿去做哈希,而没有用证书的 DER 编码。
指纹比对还要注意归一化。Wireshark 和浏览器里看到的指纹是带冒号的大写十六进制,SDP 里也是冒号分隔、通常大写。代码里要么统一转成小写并去掉冒号再比较,要么统一按原格式比较,大小写不一致会导致明明同一张证书却比对失败。我建议在调试日志里直接打印你算出来的指纹,和 SDP 里的指纹放一起肉眼比对一遍,往往几秒钟就能发现问题。
另一个容易踩的点是 verify 回调。WebRTC 场景下证书是自签的,正常 CA 校验必然失败,所以 verify callback 里通常直接 return 1,真正的指纹比对放到握手完成之后。如果你把指纹校验写进 verify callback 又提前 return 0,就会看到奇怪的手握失败。顺序应该是:先让 OpenSSL 完成握手,导出对端证书,算指纹,和 SDP 比对,不一致就主动断开。
一句话结论:算的是对端证书,不是本地证书;比对前先归一化;CA 校验和指纹校验分开。
2.3 分片和 MTU:随便生成一张 RSA 证书就够你喝一壶
TLS 里证书大一点无所谓,TCP 帮你切片。DTLS 不一样,握手消息超过路径 MTU 时,要么在 DTLS Record 层分片,要么交给 IP 层分片。IP 层 UDP 分片在公网上经常被中间设备直接丢弃,一次握手可能要拖很久,甚至直接失败。
WebRTC 里最常见的诱因就是用了 RSA 2048 证书。ServerHello 加上 Certificate 和 ServerKeyExchange,一包很容易超过 1200 字节,一旦超过路径 MTU,问题就来了。解决办法很朴素:证书用 ECDSA P-256 自签,整段握手包能控制在 1 个 MTU 内,既省握手时间也减少分片坑。生成方式也很简单,先用 ecparam 生成 prime256v1 的私钥,再用 req 生成自签证书,网上查一下就有整套命令。
如果确实要兼容大证书或者证书链,记得在代码里给 DTLS 设置合理的 MTU 上限,同时一定在弱网环境测试握手成功率。只在本地回环上把握手跑通没有意义,回环 MTU 很大,分片问题根本不会暴露。
一句话结论:WebRTC 的 DTLS 握手尽量用 ECDSA P-256 证书,控制握手包尺寸。
2.4 use_srtp 没生效:握手成功但导不出 SRTP 密钥
很多人的代码在"握手成功"之后就开心地直接跑媒体,然后发现黑屏。其实 DTLS 握手成功只是第一步,WebRTC 需要的是 DTLS-SRTP 扩展协商,也就是在握手的同时把 SRTP 的 protection profile 谈好。OpenSSL 里要在握手前调用 SSL_CTX_set_tlsext_use_srtp 把 profile 注册进去,常见的是 SRTP_AES128_CM_SHA1_80 和 SRTP_AES128_CM_SHA1_32。如果忘了注册这个扩展,握手本身照样能完成,但 SSL_get_selected_srtp_profile 返回空,后面导出密钥必然失败——表现就是握手日志一切正常,媒体就是不加密也不通。
另一个问题是 profile 不一致。一端只注册 SHA1_80,另一端只注册 SHA1_32,协商时选不出共同项,很可能在扩展协商阶段直接发 alert。生产环境里统一用 SRTP_AES128_CM_SHA1_80 一般没什么问题,浏览器对它的兼容性最好。
一句话结论:握手之前一定要调用 SSL_CTX_set_tlsext_use_srtp,profile 两端保持一致。
2.5 "wrong version number":上下文不对,一切白搭
这个报错出现的位置比较阴,可能在握手初始化时就报,也可能在收到第一个包之后才报。最常见的根因是用了 TLS 的 method 或上下文来处理 UDP 包:SSL_CTX_new(TLS_method()) 然后往里面喂 UDP 数据,OpenSSL 把 DTLS 的 record header 读出来,发现版本号对不上,直接报 wrong version number。
排查时检查三件事:协议上下文是不是 DTLS_method / DTLS_client_method / DTLS_server_method;最小和最大版本有没有被设成 TLS 的版本;封装的传输层是不是把 TCP 的那套逻辑无意间搬到了 UDP 上。最后这个听起来低级,但真的有人因为复用了 TCP 封装层,导致 DTLS 握手包被包了一层 TCP 头,对端收到后完全解析不了。
另外,某些环境下对端可能只实现了 DTLS 1.0,如果你把 min proto version 强制到 1.2,会看到客户端主动丢弃对端版本。调试阶段可以先用 min=DTLS1_0_VERSION 放开验证,确认能互通之后再收紧,这样可以更快区分是版本问题还是算法问题。
一句话结论:wrong version number 先查协议上下文,再查版本范围,最后查传输封装。
3. 一套能复用的 DTLS 排查链路:抓包解密、OpenSSL 日志、回调打点
跟你说句实在话,DTLS 的 BUG 靠肉眼读日志是很难定位的,因为很多问题发生在"应用层觉得发成功了、对端也收到了"这种信息不对等的灰色地带。我后来形成了固定的一套排查链路,按这个顺序走,大多数问题能在十来分钟内定位。
3.1 抓包的第一件事不是解密,而是看方向
遇到握手问题第一件事永远是抓包。用 Wireshark 抓 UDP 包,过滤端口,先看有没有 ClientHello、它的源端口和目标端口是不是预期的 candidate pair。通常一眼就能看出角色配置错没错:如果两端都只发 ClientHello 不回应,就是双 active;如果只有 STUN 包没有 DTLS 握手包,那是链接层根本没把媒体流引到 DTLS 这边。
抓包还能一锤定音地解决"到底有没有收到对端包"的争论。A 说发了,B 说没收到,抓包一看就知道是 A 发到了别的 IP/端口,还是 B 的 socket 没绑定到那个地址。这个环节省下的时间,远比抓包本身多。
先确认方向和路径没问题,再谈解密。
3.2 keylog 解密和 msg_callback:把 OpenSSL 变成透明盒子
Wireshark 能解密 DTLS,前提是你给它密钥。OpenSSL 1.1.1 以后可以用 SSL_CTX_set_keylog_callback 注册回调,把 client_random 和 master_secret 写进文件,然后在 Wireshark 的 TLS 设置里指定这个文件,再对 UDP 端口做 Decode As DTLS。这样握手包和媒体包都能解出来,看着握手一步步走完,再去对照自己代码的日志,很容易定位卡在哪一条消息。
Wireshark 里定位故障的思路是看握手停在哪一步:如果停在 ClientHello 之后没有 ServerHello,多半是 server 端没收到或者 cookie 流程有问题;如果停在 Certificate 之后,可能是证书解析或指纹问题;如果停在 Finished 前后,可能是 SRTP profile 协商失败。每一条都有明确的排查方向,比猜日志靠谱得多。
OpenSSL 还可以设置消息回调(SSL_CTX_set_msg_callback),在每个握手消息收发时打印消息类型、长度、是否是重传。有了它,你不需要靠猜来判断"OpenSSL 是不是真的把 ClientHello 发出去了"。如果出现同一消息反复出现,说明重传逻辑在跑;如果没有重传,说明定时器没走或驱动循环没在跑。
3.3 自己的代码埋点只需要三个位置
我自己的排查实践是在三个位置打日志,工具链再强也得有应用层的信息才能对上号:
- UDP 收包入口:把包分成 STUN / DTLS / RTP 三类分别统计,能快速发现 DTLS 包是否真的到达了应用层;
- SSL_do_handshake 返回值:区分 SSL_ERROR_WANT_READ / SSL_ERROR_WANT_WRITE / SSL_ERROR_SSL,每次状态变化都记录,重转、重传时也要记录;
- SDP 解析结果:把 a=setup、a=fingerprint、ice-ufrag 打出来,方便跟对端交换对比。
这三个点配合抓包,90% 的 DTLS 问题能在十分钟内定位到"包没到"、"状态机没推"还是"消息内容错了"。剩下的 10%,基本就是我在下一节要讲的代码层的坑,那些坑埋得比较深,但一旦理解了原理,比排查瞬时问题更好防。
4. 代码层最容易埋雷的三个位置:BIO、重传循环、密钥导出
如果说前面几节讲的是"怎么查问题",那么这一节讲的就是"问题往往藏在哪里"。这三块是 DTLS 集成代码里最容易被写错、又最不容易被发现的地方。
4.1 memory BIO 的方向和生命周期:低级错却最高频
用 memory BIO 时最容易搞混的是方向:对端发来的数据要 BIO_write 到 read BIO 里,从 write BIO 里 BIO_read 取出要发出去的数据。手滑写反,最轻的表现是握手完全卡住,最迷惑的表现是能听到回声——因为你把对方的数据又原样发回了对方。这个错一眼看日志根本看不出,因为两边都在老老实实地收发。
释放也是一件有坑的事。SSL_set_bio(ssl, rbio, wbio) 之后,这两个 BIO 通常就归 SSL 管了,SSL_free 会一起释放。如果你又单独 BIO_free 了一次,就是 double free;反过来,如果你希望自己管理生命周期,就必须在 SSL 释放前把 BIO 的引用关系摘干净,不然还是悬空引用。实战里我建议干脆别自己管,让 SSL 拥有 BIO,代码少写好几行,还不会漏。
另外一个隐藏问题:DTLS 没有连接关闭的概念,读 BIO 永远不应该返回 EOF。别去处理 0 长度读取然后当成对端关闭,UDP 协议层没有这个语义,处理了反而会把状态机搞乱。
4.2 非阻塞循环与重传定时:memory BIO 下最容易卡死的点
很多人第一次把 DTLS 跑在自研 UDP 循环里,会惊讶于一个现象:丢一个包,整个握手就永远卡住,程序没有任何报错。这是 memory BIO 的必然结果——OpenSSL 的 DTLS 重传定时器依赖底层 BIO 提供当前时间戳,memory BIO 没有这套能力,定时器根本不会驱动重传。所以应用层必须自己负责重传。
稳妥的做法是应用层维护重传循环:记录上一次发送握手数据的时刻,到点调用握手驱动函数,让 OpenSSL 重新生成当前 flight,然后从 write BIO 里取出数据发出去。重传间隔可以参照 DTLS 规范的双倍退避,第一次失败后等约 1 秒、2 秒、4 秒这样递增,整体握手上限给到 20 到 30 秒比较稳。
也可以走另一条路:给 OpenSSL 提供自定义 dgram BIO,把当前时间、设置下次超时这几个控制接口实现出来,这样 OpenSSL 就能原生驱动重传。两条路都可靠,但千万别把 socket 循环写完就以为握手完成了——丢一个包就卡死,这种实现一到公网就是一测一个准。
4.3 导出 SRTP 密钥的顺序和长度:差一个字节都不行
握手完成并且拿到了 srtp profile 之后,用 SSL_export_keying_material 导出密钥。label 固定是 EXTRACTOR-dtls_srtp,长度不是随便给的。对 SRTP_AES128_CM_SHA1_80 来说,AES-128 密钥 16 字节、salt 14 字节,两端各一套,所以一共要导 2 乘以(16 加 14),也就是 60 字节。有人图省事只导 30 字节,结果 libsrtp 初始化时各种报错,或者握手明明成功、媒体却加解密错乱。
导出的字节顺序是 client key、server key、client salt、server salt。你作为 A 方,要判断自己是 DTLS client 还是 server,用的是前半套还是后半套。这里很容易出现"AB 互通正常、拿别人的客户端一测就不通"的经典问题,因为很多 demo 只测了一个方向,另一个方向的密钥分配是反的。
建议封装一个函数,输入角色和远端 SDP 信息,返回本端应收/应发的密钥和 salt。这样无论自己是 client 还是 server,代码都只写一遍,用角色参数区分,杜绝手写两套逻辑导致的不一致。
5. 从"能握手"到"能上线"的收尾清单:对参、互通、保活
握手通了并不代表完事。很多项目死在"本地通了、和浏览器通了,一上真实网络就间歇性失败"这个阶段。我把上线前要做的事整理成三块。
5.1 一张参数表对照半小时的排查
下面这张表是我的标准对参表,每次遇到 DTLS 相关的问题,先对着这张表过一遍,能节省大量无头绪的怀疑。
| 参数 | 推荐取值 | 典型坑 |
|---|---|---|
| 协议版本 | DTLS 1.2,min/max 固定 | 与 TLS 上下文混用,报 wrong version number |
| a=setup | offer 写 actpass,answer 写 passive/active | 两端都 passive 卡死,两端都 active 乱撞 |
| 证书 | ECDSA P-256 自签,无链 | RSA 证书导致握手分片和超时 |
| SRTP profile | SRTP_AES128_CM_SHA1_80 | 没注册 use_srtp,握手成功但导不出密钥 |
| 指纹 | sha-256,归一化后比对 | 用了本地证书、大小写不一致 |
| MTU | 按路径设置,控制握手包尺寸 | 公网 UDP 分片被中间设备丢弃 |
| BIO 生命周期 | SSL_set_bio 后归 SSL 管 | double free 或悬空引用 |
| 重传定时 | 应用层驱动,间隔双倍退避 | memory BIO 下不实现重传就卡死 |
这张表里的每一项,我都在前面的章节展开讲过了。每次排查先过一遍,能排除掉一半以上的低级问题。
5.2 互通测试做三件事,才算没白测
只跟自己的实现互通,等于没测试。上手 WebRTC DTLS 至少要验证三类互通:和常见浏览器互通,Chrome 和 Firefox 各跑一次;和另一个开源 WebRTC 库互通;以及自己写的 server 和 client 两个角色都要测通。浏览器最容易暴露的是 SDP 字段问题,因为浏览器的 SDP 解析比较严格,任何小字段错都会表现为握手失败或协商失败。
本地回环测不出 MTU 和 NAT 分片问题,所以还要在真实网络环境、高丢包环境各跑一遍。特别是双向通话测试,A 作 client 和 B 作 client 都要打通,否则密钥方向不对的问题根本发现不了。
5.3 握手期间的 ICE 保活:最容易被忽略的隐性丢包
最后一个常见的隐蔽问题:DTLS 握手耗时通常要几百毫秒到几秒,期间 NAT 映射如果因为长时间没有 binding 请求而失效,已经"连上"的 candidate pair 会悄悄断掉,表现就是握手进行到一半突然没响应了。解决方式是在握手定时器之外,定期给对端发 STUN binding 请求做保活。很多 WebRTC 开源实现默认会做,但如果你是从零分层实现,很容易漏掉这一块。
握手流程里加保活逻辑不麻烦,麻烦的是映射失效之后,重来一遍握手往往又会触发同样的问题。所以千万别省这几十条 STUN 包的成本,NAT 保活这件事,省出来的那点带宽远远比不上一次公网握手失败带来的排查时间浪费。
最后再说一个我个人的习惯:我会专门留一个十几行的命令行小工具,用来单独测试两个节点之间的 DTLS 握手,不掺任何 ICE、音视频逻辑。每次改完证书或密码套件配置,先跑这个小工具确认基础握手没问题,再回到完整的 WebRTC 链路里测。这个习惯帮我挡掉了至少一半的"改了这头坏了那头"的问题,强烈建议你也在项目里留一个类似的独立握手测试入口。