尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

使用 stice 实现 TURN over TCP 控制连接(RFC 8656)

使用 stice 实现 TURN over TCP 控制连接(RFC 8656) 背景与动机在典型的 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 应用提供更广泛的网络适应性。
返回列表