WebRTC DTLS握手高频BUG与排查思路:从ICE到SRTP的完整链路 做 WebRTC 第一个点对点 Demo 的时候我印象最深的不是 ICE 打洞有多难而是 ICE 已经显示 connectedDTLS 却一直卡在握手状态。日志里翻来覆去只有一行 waiting for peers ClientHello然后等几秒超时重启握手再超时。后来我把 WebRTC DTLS 相关的坑整个趟了一遍才发现这类问题里只有一小部分是真正的协议实现问题绝大多数是 API 用错、角色配反、SDP 字段没对上。这篇文章把我从第一次跑通 P2P 到稳定上线的过程中遇到的高频 BUG 和排查思路完整整理了出来按先懂链路、再看现象、用一套可复用的方法定位、最后回到代码层修正的顺序来写。适合刚把 ICE 跑通、正被 DTLS 握手折磨的读者也适合做网关、媒体服务器集成的人参考。内容全部基于我在实际项目中踩过、修过的坑部分配置和工具用法以 OpenSSL 的常见实践为准。1. 先把 DTLS 在 WebRTC 链路里的位置说清楚角色、SDP 与握手顺序WebRTC 的媒体链路可以简单地分成三段ICE 负责解决怎么把包从 A 传到 BDTLS 负责解决这段通道够不够安全、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 里的 asetup 属性以及 ICE 角色共同决定。这里多说一句 asetup。offer 里通常写 actpassanswer 里写 active 或 passive。active 的一方是 DTLS client负责先发 ClientHellopassive 的一方是 DTLS server等 ClientHello 来了再应答。最常见的搭配是offerer 写 actpassanswerer 回 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 的 asetup 判定后取对应 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 的 asetup 打出来如果两端都是 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 的 HelloVerifyRequestclient 收到后要用同样的 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_srtpprofile 两端保持一致。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会看到客户端主动丢弃对端版本。调试阶段可以先用 minDTLS1_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 解析结果把 asetup、afingerprint、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.2min/max 固定与 TLS 上下文混用报 wrong version numberasetupoffer 写 actpassanswer 写 passive/active两端都 passive 卡死两端都 active 乱撞证书ECDSA P-256 自签无链RSA 证书导致握手分片和超时SRTP profileSRTP_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 链路里测。这个习惯帮我挡掉了至少一半的改了这头坏了那头的问题强烈建议你也在项目里留一个类似的独立握手测试入口。