NAT与WebRTC的问题背景
在互联网环境中,大多数终端设备并不直接拥有公网 IP,而是位于 NAT(Network Address Translation,网络地址转换) 设备之后。例如家庭路由器、企业网关等都会对内部网络进行 NAT 转换。
典型网络结构如下:
bash
公网
|
+-----------+
| Router | (NAT)
+-----------+
/ \
192.168.1.2 192.168.1.3
ClientA ClientB
当两个客户端尝试建立 P2P 连接时会遇到问题:
- 内网地址无法直接访问
- NAT 会改变端口
- 防火墙阻止外部连接
因此 WebRTC 需要一套机制实现 NAT 穿透。
WebRTC 采用的解决方案是:
bash
ICE + STUN + TURN
三者协同工作完成 NAT 穿透。
WebRTC NAT穿透整体架构
WebRTC 的 NAT 穿透架构如下
bash
Signaling Server
|
+----------+----------+
| |
Client A Client B
| |
|----STUN Request-----|
| |
|-----ICE Candidate Exchange----|
| |
|----Connectivity Check(STUN)---|
| |
|---------P2P Media-----------|
整个过程分为五个阶段:
- 信令交换
- Candidate 收集
- Candidate 交换
- ICE 连通性检测
- 建立媒体连接
NAT类型
不同NAT类型对 P2P 连接影响很大。
常见NAT类型包括:
| NAT 类型 | 特点 | P2P 成功率 |
|---|---|---|
| Full Cone NAT | 任意外部主机可访问映射端口 | 高 |
| Restricted NAT | 只允许访问过的 IP | 中 |
| Port Restricted NAT | IP+端口限制 | 中 |
| Symmetric NAT | 每个连接不同映射 | 低 |
NAT 示例:
bash
ClientA 内网IP: 192.168.1.2:5000
NAT映射:
公网IP: 8.134.10.20:40000
远端访问:
bash
8.134.10.20:40000
即可访问 ClientA。
但 对称 NAT 会为不同目标生成不同端口:
bash
ClientA → STUN Server
映射: 8.134.10.20:40000
ClientA → ClientB
映射: 8.134.10.20:42000
这种情况 P2P 连接往往失败。
STUN协议原理
STUN(Session Traversal Utilities for NAT)用于获取公网地址。
STUN工作流程:
Client ----STUN Request----> STUN Server
Client <---STUN Response---- STUN Server
响应中包含:
Mapped Address = 公网IP + 端口
流程图:
Client
|
| STUN Binding Request
v
+------------+
| STUN Server|
+------------+
|
| 返回公网IP
v
Client
示例:
本地地址: 192.168.1.2:5000
STUN返回:
8.134.10.20:40000
此地址称为:
Server Reflexive Candidate
STUN Server 的部署形态
STUN Server 在实际部署中通常有三种形态。
1. 独立 STUN 服务
最常见形态是 独立服务程序。
例如:
coturn
reTurn
stund
部署方式:
Linux Server
|
|-- STUN Server
|
公网IP
客户端配置:
stun:stun.example.com:3478
特点:
- 实现简单
- 资源消耗低
- 支持高并发
2. STUN + TURN 一体服务
在 WebRTC 系统中,STUN 通常与 TURN 一起部署。
最典型实现:
coturn
架构:
Internet
|
+----------------+
| coturn |
| STUN + TURN |
+----------------+
优点:
- 一套服务
- 支持 NAT 穿透 + 中继
- WebRTC 标准部署方式
WebRTC 推荐配置:
stun:turn.example.com:3478
turn:turn.example.com:3478
3. 云 STUN 服务
很多系统直接使用 公共 STUN Server。
例如:
Google STUN
地址:
stun.l.google.com:19302
架构:
Client
|
v
Google STUN Server
优点:
- 不需要自己部署
- 稳定
缺点:
- 不可控
- 有访问限制
STUN Server获取公网地址
NAT地址转换原理
假设客户端在内网:
Client
192.168.1.5:5000
通过路由器 NAT 访问公网:
Router NAT
NAT 会创建一个映射:
192.168.1.5:5000
↓
8.134.20.10:42000
此时公网看到的地址是:
8.134.20.10:42000
通信流程:
Client → NAT → Internet → STUN Server
数据包在 NAT 设备处被修改。
STUN获取公网地址的核心机制
STUN Server 接收客户端请求时:
recvfrom()
操作系统会提供 发送方地址:
source IP
source port
这正是 NAT 映射后的公网地址。
示例:
Client 内网
192.168.1.5:5000
经过 NAT:
8.134.20.10:42000
STUN Server 接收到的数据:
src_ip = 8.134.20.10
src_port = 42000
STUN Server 就把这个地址写入响应。
STUN工作完整流程
完整流程如下:
Client STUN Server
| |
| Binding Request |
|------------------------>|
| |
| |
| 读取 source IP/port |
| |
| Binding Response |
|<------------------------|
客户端最终得到:
公网IP + 公网端口
STUN协议返回字段
STUN Server 在响应中返回:
XOR-MAPPED-ADDRESS
例如:
XOR-MAPPED-ADDRESS
IP = 8.134.20.10
PORT = 42000
客户端解析后得到自己的公网地址。
STUN Server实现逻辑
STUN Server 的核心逻辑非常简单:
UDP Socket
|
recvfrom()
|
获取source ip/port
|
构造STUN Response
|
sendto()
流程图:
+----------------+
Client --->| STUN Server |
+----------------+
|
| recvfrom()
|
获取 source IP
获取 source port
|
构造 STUN Response
|
v
Client <--- sendto()
STUN Server返回公网地址的原因
核心原因是NAT修改了 IP 包头。
数据包变化:
客户端发送:
SRC IP: 192.168.1.5
SRC PORT: 5000
DST IP: STUN_SERVER
经过 NAT:
SRC IP: 8.134.20.10
SRC PORT: 42000
DST IP: STUN_SERVER
STUN Server 看到的就是:
8.134.20.10:42000
因此无需计算。
完整 STUN Server (C++)
c++
#include <iostream>
#include <vector>
#include <cstring>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <unistd.h>
#include <netinet/in.h>
using namespace std;
static const uint16_t STUN_BINDING_REQUEST = 0x0001;
static const uint16_t STUN_BINDING_RESPONSE = 0x0101;
static const uint16_t STUN_ATTR_XOR_MAPPED_ADDRESS = 0x0020;
static const uint32_t STUN_MAGIC_COOKIE = 0x2112A442;
#pragma pack(push,1)
struct StunHeader
{
uint16_t type;
uint16_t length;
uint32_t magic;
uint8_t transaction_id[12];
};
struct StunAttributeHeader
{
uint16_t type;
uint16_t length;
};
#pragma pack(pop)
class Buffer
{
public:
vector<uint8_t> data;
void append(const void* buf,size_t len)
{
const uint8_t* p=(const uint8_t*)buf;
data.insert(data.end(),p,p+len);
}
uint8_t* ptr()
{
return data.data();
}
size_t size() const
{
return data.size();
}
};
class StunMessage
{
public:
StunHeader header;
vector<uint8_t> attributes;
StunMessage()
{
memset(&header,0,sizeof(header));
}
};
class StunParser
{
public:
static bool parse(const uint8_t* buf,int len,StunMessage& msg)
{
if(len < sizeof(StunHeader))
return false;
memcpy(&msg.header,buf,sizeof(StunHeader));
msg.header.type = ntohs(msg.header.type);
msg.header.length = ntohs(msg.header.length);
msg.header.magic = ntohl(msg.header.magic);
if(msg.header.magic != STUN_MAGIC_COOKIE)
return false;
int attr_len = msg.header.length;
if(attr_len + sizeof(StunHeader) > len)
return false;
msg.attributes.assign(buf + sizeof(StunHeader),
buf + sizeof(StunHeader) + attr_len);
return true;
}
};
class StunBuilder
{
public:
static void addXorMappedAddress(Buffer& buffer,
const sockaddr_in& addr,
const uint8_t transaction_id[12])
{
StunAttributeHeader attr;
attr.type = htons(STUN_ATTR_XOR_MAPPED_ADDRESS);
attr.length = htons(8);
buffer.append(&attr,sizeof(attr));
uint8_t family = 0x01;
uint16_t port = ntohs(addr.sin_port);
uint32_t ip = ntohl(addr.sin_addr.s_addr);
port ^= (STUN_MAGIC_COOKIE >> 16);
ip ^= STUN_MAGIC_COOKIE;
uint16_t xport = htons(port);
uint32_t xip = htonl(ip);
uint8_t reserved = 0;
buffer.append(&reserved,1);
buffer.append(&family,1);
buffer.append(&xport,2);
buffer.append(&xip,4);
}
static Buffer buildBindingResponse(const StunMessage& req,
const sockaddr_in& client_addr)
{
Buffer buf;
StunHeader header;
header.type = htons(STUN_BINDING_RESPONSE);
header.magic = htonl(STUN_MAGIC_COOKIE);
memcpy(header.transaction_id,
req.header.transaction_id,
12);
header.length = 0;
buf.append(&header,sizeof(header));
addXorMappedAddress(buf,client_addr,req.header.transaction_id);
uint16_t attr_len = buf.size() - sizeof(StunHeader);
uint16_t net_len = htons(attr_len);
memcpy(buf.ptr()+2,&net_len,2);
return buf;
}
};
class UdpServer
{
public:
int sock;
UdpServer()
{
sock = -1;
}
bool start(int port)
{
sock = socket(AF_INET,SOCK_DGRAM,0);
if(sock < 0)
{
cerr<<"socket failed\n";
return false;
}
sockaddr_in addr;
memset(&addr,0,sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = INADDR_ANY;
addr.sin_port = htons(port);
if(bind(sock,(sockaddr*)&addr,sizeof(addr))<0)
{
cerr<<"bind failed\n";
return false;
}
cout<<"STUN server listening "<<port<<endl;
return true;
}
void run()
{
while(true)
{
uint8_t buf[1500];
sockaddr_in client;
socklen_t len = sizeof(client);
int n = recvfrom(sock,
buf,
sizeof(buf),
0,
(sockaddr*)&client,
&len);
if(n <= 0)
continue;
handlePacket(buf,n,client);
}
}
void handlePacket(uint8_t* buf,
int len,
sockaddr_in& client)
{
StunMessage msg;
if(!StunParser::parse(buf,len,msg))
{
return;
}
if(msg.header.type != STUN_BINDING_REQUEST)
{
return;
}
char ip[64];
inet_ntop(AF_INET,&client.sin_addr,ip,sizeof(ip));
int port = ntohs(client.sin_port);
cout<<"Binding Request from "
<<ip<<":"<<port<<endl;
Buffer resp = StunBuilder::buildBindingResponse(msg,client);
sendto(sock,
resp.ptr(),
resp.size(),
0,
(sockaddr*)&client,
sizeof(client));
}
};
int main(int argc,char* argv[])
{
int port = 3478;
if(argc > 1)
port = atoi(argv[1]);
UdpServer server;
if(!server.start(port))
return -1;
server.run();
return 0;
}
ICE协议
ICE(Interactive Connectivity Establishment)是 WebRTC NAT 穿透的核心机制。
ICE 负责:
- 收集候选地址
- 交换 Candidate
- 连通性检测
- 选择最佳路径
ICE Candidate 类型:
Host Candidate
Server Reflexive Candidate
Relay Candidate
Candidate 收集过程
ICE Agent 会收集三类 Candidate。
1. Host Candidate
本机地址:
192.168.1.2:5000
2. STUN Candidate(srflx)
通过 STUN 获取:
8.134.10.20:40000
3. TURN Candidate(relay)
通过 TURN 获取中继地址:
34.210.10.5:52000
Candidate 收集流程:
Client
|
| 获取本地地址
v
Host Candidate
|
| STUN
v
srflx Candidate
|
| TURN
v
Relay Candidate
Candidate 交换
Candidate 通过信令服务器交换。
信令服务器可以是:
- WebSocket
- HTTP
- SIP
- MQTT
流程:
ClientA → Signaling → ClientB
ClientB → Signaling → ClientA
交换内容:
SDP + ICE Candidate
示例:
a=candidate:1 1 udp 2130706431 192.168.1.2 5000 typ host
a=candidate:2 1 udp 1694498815 8.134.10.20 40000 typ srflx
a=candidate:3 1 udp 33554431 34.210.10.5 52000 typ relay
ICE连通性检测
双方得到 Candidate 后会形成 Candidate Pair。
例如:
A: 192.168.1.2:5000
B: 10.0.0.5:6000
组合为:
Pair1
Pair2
Pair3
检测流程:
ClientA ----STUN Binding----> ClientB
ClientB <----STUN Response---- ClientA
流程图:
ClientA ClientB
| |
|----STUN Binding Request-->|
| |
|<---STUN Binding Response--|
| |
成功则该路径有效。
路径选择机制
ICE 会根据优先级选择最佳路径。
优先级顺序:
Host > srflx > relay
原因:
| 类型 | 延迟 | 成本 |
|---|---|---|
| Host | 最低 | 0 |
| srflx | 低 | 低 |
| relay | 高 | 高 |
ICE 会尝试:
host-host
host-srflx
srflx-srflx
relay
最终选择成功的最高优先级路径。
TURN中继机制
当 NAT 穿透失败时,使用 TURN 中继。
通信路径:
ClientA → TURN → ClientB
流程图:
ClientA
|
| RTP
v
+---------+
| TURN |
| Server |
+---------+
|
| RTP
v
ClientB
TURN 服务器负责:
- 分配 Relay 地址
- 转发 RTP / RTCP
- 维护连接状态
缺点:
- 延迟增加
- 消耗服务器带宽
优点:
- 成功率最高
Trickle ICE优化
传统 ICE 需要等待 Candidate 收集完成。
Trickle ICE 则 边收集边发送。
流程:
收集一个 Candidate
|
v
立即发送
|
v
远端开始检测
流程图:
ClientA ClientB
| |
| candidate1 ----> |
| candidate2 ----> |
| candidate3 ----> |
优点:
- 连接建立更快
- 用户体验更好
完整NAT穿透流程图
完整流程如下:
+-------------------+
| Signaling Server |
+-------------------+
^ ^
| |
| SDP/ICE |
| |
ClientA ClientB
| |
|---STUN--|
| |
|---TURN--|
| |
|--Candidate Exchange--|
| |
|--ICE Connectivity Check--|
| |
|------P2P Media------|
如果 P2P 失败:
ClientA → TURN → ClientB
总结
WebRTC NAT 穿透是通过 ICE + STUN + TURN 三个核心技术实现的。
主要流程包括:
- 收集 Candidate
- 交换 Candidate
- ICE 连通性检测
- 选择最佳路径
- 建立媒体连接
Candidate 类型:
Host
Server Reflexive
Relay
在大多数情况下 WebRTC 可以通过 STUN 实现 P2P 直连 。当 NAT 类型复杂或防火墙严格时,则通过 TURN 中继完成通信。
合理部署 STUN/TURN 服务器和优化 ICE 策略,是提升 WebRTC 连接成功率和系统性能的重要手段。