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 连接成功率和系统性能的重要手段。

相关推荐
南囝coding17 分钟前
Anthropic 内部数百个 Claude Code Skills,他们总结的这套方法值得看
前端·后端
Rust研习社1 小时前
Ubuntu 全面拥抱 Rust 后,我意识到 Rust 社区要变了
linux·服务器·开发语言·后端·ubuntu·rust
小江的记录本1 小时前
【AI大模型选型指南】《2026年5月(最新版)国内外主流AI大模型选型指南》(个人版)
前端·人工智能·后端·ai·aigc·ai编程·ai写作
我叫黑大帅2 小时前
基于 Docker + Watchtower 自动化部署后端服务
后端·docker·面试
fox_lht2 小时前
12.3.使用生命周期使引用一直有用
开发语言·后端·rust
fengxin_rou2 小时前
用户模块架构实战:DTO 与 Domain 分层、Optional 空值处理、事务只读优化详解
java·后端·架构·用户实战
程序员cxuan3 小时前
看了一下姚顺宇的访谈,确实太顶了。
人工智能·后端·程序员
Wy_编程3 小时前
Go语言中的指针
开发语言·后端·golang
GetcharZp3 小时前
RabbitMQ 深度全解析,从 Docker 部署到 Go 语言高并发实战!
后端
小江的记录本4 小时前
【AI大模型选型指南】《2026年5月(最新版)国内外主流AI大模型选型指南》(企业版)
前端·人工智能·后端·ai作画·aigc·ai编程·ai写作