)
背景与动机在典型的 WebRTC 场景中ICE 代理通过 UDP 与 TURN 服务器通信获取中继候选者relayed candidate来穿透对称 NAT。然而在某些网络环境中——企业防火墙、公共 WiFi、移动运营商的受限网络——UDP 可能被完全封锁或严重限速而 TCP 443/80 端口通常是开放的。RFC 8656Traversal Using Relays around NAT定义了 TURN 协议可以在 TCP 或 TLS 之上运行控制连接。这意味着客户端与 TURN 服务器之间的 Allocate、Refresh、CreatePermission、ChannelBind 等 STUN 事务以及 Send/Data 指示和 ChannelData 帧都可以通过一条 TCP 连接传输。这使得 TURN 能够穿透只允许 TCP 的防火墙。需要区分两个容易混淆的概念概念 规范 控制连接 REQUESTED-TRANSPORT Relay 传输TURN over TCP (control) RFC 8656 TCP/TLS 17 (UDP) UDPTURN TCP Allocation RFC 6062 TCP 6 (TCP) TCP本文聚焦前者控制连接运行在 TCP 上中继传输仍然是 UDP。stice 的实现中当传输配置为 STICE_TURN_TRANSPORT_TCP 时会同时启用 RFC 6062 TCP allocationREQUESTED-TRANSPORT6因为这是 ICE-TCP 场景的典型需求而 STICE_TURN_TRANSPORT_TLS 则使用标准的 UDP relayREQUESTED-TRANSPORT17。2. RFC 8656 中的关键规范2.1 传输层与帧定界TURN over UDP 时每个 UDP 数据报天然就是一个完整的 STUN 消息或 ChannelData 帧不需要额外的定界信息。TURN over TCP 时情况完全不同。TCP 是一个面向字节流的协议接收方无法仅凭 TCP 层知道一条 STUN 消息从哪里开始、到哪里结束。RFC 8656 规定 TURN over TCP 不使用 RFC 4571 的 2 字节长度前缀而是依赖 STUN 消息和 ChannelData 帧本身的自定界self-delimiting特性STUN 消息20 字节固定头部其中第 2-3 字节是 16 位的 MESSAGE LENGTH 字段表示头部之后的载荷字节数。因此一帧的总长度 20 length。 ChannelData 帧4 字节头部其中第 2-3 字节是 16 位长度字段。在 TCP 上数据部分会被填充到 4 字节边界。总长度 4 padded(length)。2.2 流上的多路分解在同一条 TCP 连接上STUN 消息和 ChannelData 帧可能交错到达。接收方需要根据首字节判断当前帧的类型STUN 消息的首字节高 2 位为 00RFC 5389 §6因此首字节范围是 0x00-0x3F。 ChannelData 的首字节是通道号的高字节通道号范围为 0x4000-0x7FFF因此首字节范围是 0x40-0x7F。这两个范围互不重叠使得在 TCP 字节流上可以无歧义地分解帧。2.3 长期凭证认证TURN over TCP 使用与 UDP 相同的长期凭证机制long-term credential客户端发送不带认证的 Allocate 请求。 服务器返回 401 Unauthorized携带 REALM 和 NONCE 属性。 客户端使用 username:realm:password 计算 MD5 作为密钥在后续请求中携带 USERNAME、REALM、NONCE 和 MESSAGE-INTEGRITY。 如果服务器返回 438 Stale Nonce客户端使用新的 NONCE 重试最多 3 次防止无限循环。2.4 重传与超时TCP 提供可靠传输因此 TURN over TCP 不需要应用层重传。但 stice 仍然维护事务 ID 映射和超时机制用于检测服务器无响应连接可能半开。 在 UDP 和 TCP 之间统一状态机代码路径。stice 的架构设计stice 将 TURN 客户端的职责清晰地划分为三层┌─────────────────────────────────────────────────┐│ Agent (ICE) ││ 拥有 socket / TcpTransport驱动事件循环 │├─────────────────────────────────────────────────┤│ turnTcpTransport_ (TcpTransport, Raw framing) ││ turnStunConn_ (StunConn, 自定界解析) │├─────────────────────────────────────────────────┤│ turn::Client (状态机) ││ Allocate / Refresh / Permission / Channel ││ 不拥有 socket通过 TurnSink 回调发送原始字节 │└─────────────────────────────────────────────────┘3.1 turn::Client — 无 socket 的状态机turn::Client 是纯状态机不拥有任何网络 socket。它通过 TurnSink 回调与上层通信struct TurnSink {// 发送原始 STUN 消息或 ChannelData 帧到 TURN 服务器std::functionvoid(const unsigned char *data, std::size_t size) sendRaw;// 分配成功提供中继地址和生命周期std::functionvoid(const net::AddrRecord relayed, std::uint32_t lifetime) onAllocated;// 分配永久失败std::functionvoid(int errorCode, const std::string reason) onFailed;// 通过中继收到应用数据std::functionvoid(const net::AddrRecord peer, const unsigned char *data, std::size_t size) onData;// …};这种设计使得 turn::Client 可以复用于 UDP、TCP、TLS 三种传输上层只需要提供不同的 sendRaw 实现。3.2 turn::StunConn — TCP 流的自定界解析器StunConn 是 TURN over TCP 的核心组件负责从连续的 TCP 字节流中提取完整的 STUN 消息或 ChannelData 帧。class StunConn {public:void feed(const unsigned char *data, std::size_t size);std::size_t readFrame(const unsigned char *out);std::size_t buffered() const;private:bytes buf_;std::size_t consumed_ 0;};工作流程feed()将从 TCP 连接收到的原始字节追加到内部缓冲区。 readFrame()尝试从缓冲区头部提取一帧 检查首字节 0x40 是 STUN 消息0x40-0x7F 是 ChannelData。 对于 STUN读取 20 字节头部中的 length 字段总长度 20 length。 对于 ChannelData读取 4 字节头部中的 length 字段总长度 4 向上取整到 4 的倍数(length)。 如果缓冲区数据不足一帧返回 0等待更多数据。 如果首字节既不是 STUN 也不是 ChannelData返回 SIZE_MAX致命流错误应关闭连接。 消费已读取的帧推进 consumed_ 偏移。3.3 TcpTransport — Raw 帧模式stice 的 TcpTransport 支持两种帧模式FramingMode::RFC4571每帧前加 2 字节大端长度前缀用于 ICE-TCP 数据通道。 FramingMode::Raw不加任何前缀直接写入原始字节用于 TURN over TCP 控制连接。TURN over TCP 控制连接必须使用 Raw 模式因为 STUN/ChannelData 已经是自定界的再加长度前缀会导致服务器无法解析。4. 核心实现分析4.1 建立 TCP 控制连接当 Agent 检测到 TURN 服务器配置为 TCP/TLS 传输时调用 beginTurnTcpConnect()bool Agent::beginTurnTcpConnect(const net::AddrRecord turnServer, bool useTls,const std::string sni, bool skipVerify) {turnTcpTransport_ std::make_uniquenet::TcpTransport();// 关键使用 Raw 帧模式不加 RFC 4571 前缀turnTcpTransport_-setFramingMode(net::FramingMode::Raw);if (!turnTcpTransport_-beginConnect(turnServer, sni, useTls, skipVerify)) {turnTcpTransport_.reset();// 通知 TURN Client 分配失败return false;}return true;}TcpTransport::beginConnect() 发起非阻塞 connect通过 PollRegistry 的 onWritable 回调检测连接完成。TLS 模式下连接建立后立即执行 TLS 握手。4.2 发送路径turn::Client 构建 STUN 消息后通过 TurnSink::sendRaw 回调发送。Agent 的回调实现根据传输类型路由sink.sendRaw [this, useTcp](const unsigned char *data, std::size_t size) {if (useTcp) {// TCP/TLS通过 turnTcpTransport_ 发送原始字节// Raw 模式下直接写入不加任何前缀turnTcpTransport_-send(data, size);} else {// UDP通过 UDP socket 或共享 UDPMux 发送sock_.sendto(data, size, turnServerAddr);}};对于 TCP 传输TcpTransport::send() 将原始字节写入发送缓冲区由 PollRegistry 的 onWritable 回调逐步 flush 到内核。4.3 接收路径TCP 数据到达时PollRegistry 触发 onTurnTcpReadable()void Agent::onTurnTcpReadable() {char buf[4096];net::AddrRecord peer; // TCP 上不需要对端地址while (true) {int n turnTcpTransport_-recv(buf, sizeof(buf), peer);if (n 0) break;// 将原始字节喂给 StunConn 解析器turnStunConn_.feed(reinterpret_castconst unsigned char *(buf),static_caststd::size_t(n));}// 从 StunConn 中提取完整帧路由到 TURN Clientconst unsigned char *frame nullptr;while (true) {std::size_t frameSize turnStunConn_.readFrame(frame);if (frameSize 0 || !frame) break;if (frameSize static_caststd::size_t(-1)) {// 致命流错误关闭连接通知 TURN Client 失败turnTcpTransport_.reset();return;}// 将完整帧交给 TURN Client 处理STUN 响应 / Data 指示 / ChannelDatafor (auto e : entries_) {if (e.type StunEntryType::Relay e.turn) {e.turn-handleInbound(frame, frameSize);}}}}这个流程体现了 TURN over TCP 的核心设计TCP 层只负责字节流传输StunConn 负责帧定界turn::Client 负责协议语义。4.4 Allocate 事务turn::Client::allocate() 构建 Allocate 请求void Client::allocate() {state_ AllocState::Allocating;// RFC 8656TLS 控制连接使用 UDP relay (17)// RFC 6062TCP 控制连接使用 TCP allocation (6)isTcpAllocation_ (cfg_.transport TurnTransport::TCP);std::uint8_t proto isTcpAllocation_ ? 6 : 17;stun::Message m; m.method stun::Method::Allocate; m.cls stun::Class::Request; m.newTransactionID(); allocateTid_ m.transactionID; stun::addRequestedTransport(m, proto); sendRequest(m); // 通过 TurnSink::sendRaw 发送 // 注册待处理事务用于响应分发和超时}sendRequest() 在没有长期凭证时发送不带认证的请求。收到 401 响应后handleAllocateResponse() 捕获 REALM 和 NONCE然后用凭证重新发送void Client::handleAllocateResponse(const stun::Message msg, bool isError) {if (isError) {int code 0;stun::readErrorCode(msg, code, reason);if (code 401) {// 捕获 realm/nonce用长期凭证重试creds_ stun::Credentials{…};sendRequest(allocateMsgAgain);return;}if (code 438 nonceRetries_ MaxNonceRetries) {// Stale Nonce刷新 nonce 重试nonceRetries_;sendRequest(allocateMsgAgain);return;}sink_.onFailed(code, reason);return;}// 成功提取 XOR-RELAYED-ADDRESS 和 LIFETIMEstun::readXorAddress(msg, stun::AttrType::XorRelayedAddress, relayedAddr_, msg.transactionID);stun::readLifetime(msg, lifetime_);state_ AllocState::Allocated;sink_.onAllocated(relayedAddr_, lifetime_);}4.5 数据传输Send 指示与 ChannelData分配成功后应用数据通过两种方式传输Send 指示Send IndicationSTUN 消息方法为 Send携带 XOR-PEER-ADDRESS 和 DATA 属性。不需要通道绑定适用于偶尔发送数据。 ChannelData 帧4 字节头部通道号 长度 数据。需要先通过 ChannelBind 请求绑定通道号适用于持续传输开销更小。turn::Client::sendData() 自动管理这个状态机如果对端已绑定通道使用 ChannelData否则使用 Send 指示并在后台发起 CreatePermission ChannelBind。在 TCP 控制连接上这两种帧都通过 turnTcpTransport_ 发送由 StunConn 在接收端解析。5. 使用示例C API5.1 配置 TURN over TCP#include stice/stice.hstatic void on_state_changed(stice_agent_t *agent, stice_state_t state, void *user) {printf(“ICE state: %s\n”, stice_state_to_string(state));}static void on_candidate(stice_agent_t *agent, const char *sdp, void *user) {// 通过信令发送给对端printf(“Local candidate: %s\n”, sdp);}int main(void) {stice_config_t config {0};// 配置 TURN over TCP 控制连接 stice_turn_server_t turn {0}; turn.host turn.example.com; turn.port 3478; turn.username user; turn.password pass; turn.transport STICE_TURN_TRANSPORT_TCP; // TCP 控制连接 config.turn_servers turn; config.turn_servers_count 1; config.cb_state_changed on_state_changed; config.cb_candidate on_candidate; stice_agent_t *agent stice_create(config); stice_gather_candidates(agent); // ... 交换 SDP 和候选者 ... stice_destroy(agent); return 0;}5.2 配置 TURN over TLSTCP 控制连接 TLS 加密stice_turn_server_t turn {0};turn.host “turn.example.com”;turn.port 5349; // TURN/TLS 标准端口turn.username “user”;turn.password “pass”;turn.transport STICE_TURN_TRANSPORT_TLS; // TLS 控制连接turn.tls_skip_verify 0; // 验证服务器证书默认安全TLS 模式下控制连接被 TLS 加密可以穿透对明文 TCP 进行 DPI 检测的防火墙。REQUESTED-TRANSPORT 仍然是 UDP(17)中继传输是 UDP。5.3 通过 stserver 测试stice 内置的 stserver 支持 TCP 控制连接。使用测试配置启动启动 stserver监听 UDP 3478 TCP 3478./stserver --config stserver.test.conf运行 TURN relay 测试TCP 模式./test_stserver_relay.exe 127.0.0.1 3478 tcp测试输出示例[stice/INF] TURN TCP: beginning TCP connect to 127.0.0.1:3478[stice/INF] TURN allocate: called state0[stice/INF] TURN: allocation success relayed127.0.0.1:50123 lifetime600 PASS: relay data exchange OK 与 UDP 控制连接的对比维度 UDP 控制连接 TCP/TLS 控制连接帧定界 数据报天然定界 StunConn 自定界解析帧前缀 无 无Raw 模式不加 RFC 4571应用层重传 需要RTO 200ms指数退避 不需要TCP 可靠传输穿透能力 差UDP 常被封 好TCP 443/80 通常开放延迟 低无连接建立 稍高TCP 握手 TLS 握手头部开销 20 字节 STUN / 4 字节 ChannelData 相同TCP 头部由内核处理多路分解 按源地址端口 按首字节STUN vs ChannelData适用场景 普通 NAT 穿透 企业防火墙 / 受限网络注意事项与最佳实践7.1 不要在 TURN over TCP 上使用 RFC 4571 前缀这是最常见的实现错误。ICE-TCPRFC 6544的数据通道使用 RFC 4571 的 2 字节长度前缀但 TURN over TCP 控制连接RFC 8656明确不使用。stice 通过 TcpTransport::setFramingMode(FramingMode::Raw) 确保这一点。7.2 StunConn 的错误恢复如果 StunConn::readFrame() 返回 SIZE_MAX说明 TCP 流上出现了既不是 STUN 也不是 ChannelData 的数据。这通常意味着对端不是合法的 TURN 服务器。 连接被中间盒篡改。 帧解析状态不同步理论上不应发生因为帧是自定界的。正确的处理方式是关闭 TCP 连接并标记分配失败而不是尝试跳过字节重新同步——因为无法确定从哪里开始重新同步。7.3 TCP 连接的保活与超时虽然 TCP 提供可靠传输但 TURN 分配仍然需要定期 Refresh默认 600 秒生命周期提前刷新。此外stice 在应用层维护事务超时用于检测半开连接TCP 连接看似建立但服务器无响应。7.4 TLS 证书验证生产环境中务必保持 tls_skip_verify 0验证 TURN 服务器的 TLS 证书。跳过验证虽然方便测试但容易受到中间人攻击。stice 使用系统信任存储验证证书。7.5 端口选择TURN over TCP 标准端口是 3478TURN/TLS 是 5349。但在受限网络中建议使用 443 端口TLS因为 443 几乎总是开放的且 TLS 加密使得 DPI 无法区分 TURN 流量与 HTTPS 流量。8. 总结stice 对 TURN over TCP 控制连接的实现遵循 RFC 8656 规范核心设计要点包括分层架构turn::Client无 socket 状态机 StunConn自定界解析 TcpTransportRaw 帧模式职责清晰易于测试和维护。 自定界帧解析利用 STUN 消息和 ChannelData 帧头部的 length 字段在 TCP 字节流上无歧义地分解帧不需要额外的长度前缀。 统一的状态机turn::Client 的 Allocate/Refresh/Permission/Channel 状态机在 UDP 和 TCP 上完全复用仅 sendRaw 回调不同。 长期凭证认证401/438 处理、nonce 刷新限制、MESSAGE-INTEGRITY 验证与 UDP 路径一致。 错误处理StunConn 流错误 → 关闭连接 → 标记分配失败避免在损坏的流上继续操作。通过 TURN over TCP/TLSstice 能够在 UDP 被封锁的网络环境中仍然完成 ICE 连接建立为 WebRTC 应用提供更广泛的网络适应性。