WebRTC(十五):NAT穿透机制深度解析

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-----------|

整个过程分为五个阶段:

  1. 信令交换
  2. Candidate 收集
  3. Candidate 交换
  4. ICE 连通性检测
  5. 建立媒体连接

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 三个核心技术实现的。

主要流程包括:

  1. 收集 Candidate
  2. 交换 Candidate
  3. ICE 连通性检测
  4. 选择最佳路径
  5. 建立媒体连接

Candidate 类型:

复制代码
Host
Server Reflexive
Relay

在大多数情况下 WebRTC 可以通过 STUN 实现 P2P 直连 。当 NAT 类型复杂或防火墙严格时,则通过 TURN 中继完成通信。

合理部署 STUN/TURN 服务器和优化 ICE 策略,是提升 WebRTC 连接成功率和系统性能的重要手段。

相关推荐
uzong2 小时前
职场上要懂的思维模型系列(第一章)
后端
代码探秘者2 小时前
【Redis】双写一致性:延迟双删 / 读写锁 / 异步通知 / Canal,一文全解
java·数据库·redis·后端·算法·缓存
Arman_2 小时前
深入浅出 RTP 协议:从原理到 WebRTC 实践
webrtc·tcp
IT枫斗者2 小时前
CentOS 7 一键部署 K8s 1.23 + Rancher 2.7 完整指南
java·linux·spring boot·后端·kubernetes·centos·rancher
ding_zhikai2 小时前
【Web应用开发笔记】Django笔记8:用户账户相关功能
笔记·后端·python·django
IMPYLH2 小时前
Lua 的 UTF-8 模块
开发语言·笔记·后端·游戏引擎·lua
qq_12498707532 小时前
基于springboot的个性化服装搭配推荐小程序(源码+论文+部署+安装)
spring boot·后端·spring·微信小程序·小程序·毕业设计·毕业设计源码
uzong2 小时前
我们常常谈复盘,那么什么是真正的复盘
后端
却道天凉_好个秋2 小时前
WebRTC(十四):Candidate
音视频·webrtc·candidate