1.连接

对于 WebRTC 来讲,它认为通过中继的方式会增加 A 与 B 之间传输的时长,所以它优先使用 P2P 方式;如果 P2P 方式不通,才会使用中继的方式。

  • 在本机收集所有的 host 类型的 Candidate,通过 STUN 协议收集 srflx 类型的 Candidate,使用 TURN 协议收集 relay 类型的 Candidate

  • 优先使用内网连接,其次使用NAT穿透直接用公网ip连接,最后使用中继服务器来进行连接

1.1 Candidate

ICE Candidate (ICE 候选者)。它表示 WebRTC 与远端通信时使用的协议、IP 地址和端口,一般由以下字段组成:

名字
本地 IP 地址
本地端口号
候选者类型,包括 host、srflx 和 relay
优先级
传输协议访问服务的用户名
类型 类型 数值
host 表示本机候选者 即本机内网的 IP 和端口
srflx 表示内网主机映射的外网的地址和端口 即本机 NAT 映射后的外网的 IP 和端口
relay 表示中继候选者 即中继服务器的 IP 和端口

1.2 STUN 协议

  • Session Traversal Utilities for NAT,NAT会话穿越应用程序

  • 它允许位于NAT(或多重NAT)后的客户端找出自己的公网地址,查出自己位于哪种类型的NAT之后以及NAT为某一个本地端口所绑定的Internet端端口。这些信息被用来在两个同时处于NAT路由器之后的主机之间创建UDP通信。该协议由RFC 5389定义

  • 首先在外网搭建一个 STUN 服务器,现在比较流行的 STUN 服务器是 CoTURN,你可以到 GitHub 上自己下载源码编译安装。

  • 当 STUN 服务器安装好后,从内网主机发送一个 binding request 的 STUN 消息到 STUN 服务器。

  • STUN 服务器收到该请求后,会将请求的 IP 地址和端口填充到 binding response 消息中,然后顺原路将该消息返回给内网主机。此时,收到 binding response 消息的内网主机就可以解析 binding response 消息了,并可以从中得到自己的外网 IP 和端口

1.3 TURN 协议

  • TURN的全称为Traversal Using Relay NAT,即通过Relay方式穿越NAT

  • TURN方式解决NAT问题的思路与STUN相似,是基于私网接入用户通过某种机制预先得到其私有地址对应在公网的地址(STUN方式得到的地址为出口NAT上的地址,TURN方式得到地址为TURNServer上的地址),然后在报文负载中所描述的地址信息直接填写该公网地址的方式,实际应用原理也是一样的

  • TURN的全称为Traversal Using Relay NAT,即通过Relay方式穿越NAT,TURN应用模型通过分配TURNServer的地址和端口作为客户端对外的接受地址和端口,即私网用户发出的报文都要经过TURNServer进行Relay转发。

  • 这种方式应用模型除了具有STUN方式的优点外,还解决了STUN应用无法穿透对称NAT(SymmetricNAT)以及类似的Firewall设备的缺陷,即无论企业网/驻地网出口为哪种类型的NAT/FW,都可以实现NAT的穿透。

  • 同时TURN支持基于TCP的应用,如H323协议。此外TURNServer控制分配地址和端口,能分配RTP/RTCP地址对(RTCP端口号为RTP端口号加1)作为本端客户的接受地址,避免了STUN应用模型下出口NAT对RTP/RTCP地址端口号的任意分配,使得客户端无法收到对端发过来的RTCP报文(对端发RTCP报文时,目的端口号缺省按RTP端口号加1发送)

  • TURN的局限性在于所有报文都必须经过TURNServer转发,增大了包的延迟和丢包的可能性

1.4 NAT 打洞 /P2P 穿越

  • 双方是在同一局域网内,那么就直接在它们之间建立一条连接。

  • 但如果两台主机不在同一个内网,WebRTC 将尝试 NAT 打洞,即 P2P 穿越

WebRTC 将 NAT 分类为 4 种类型

分类
完全锥型 NATIP
限制型 NAT
端口限制型 NAT
对称型 NAT

2. 打洞

2.1 完全锥型 NAT

  • 当 host 主机通过 NAT 访问外网的 B 主机时,就会在 NAT 上打个“洞”,所有知道这个“洞”的主机都可以通过它与内网主机上的侦听程序通信

    1
    2
    3
    4
    5
    6
    
    {
    内网IP,
    内网端口,
    映射的外网IP,
    映射的外网端口
    }

2.2 IP 限制锥型 NAT

  • 只有 host 主机访问过的外网主机才能穿越 NAT。

  • 一定会发现大多数打洞都是使用的 UDP 协议。之所以会这样,是因为 UDP 是无连接协议,它没有连接状态的判断,也就是说只要你发送数据给它,它就能收到。而 TCP 协议就做不到这一点,它必须建立连接后,才能收发数据,因此大多数人都选用 UDP 作为打洞协议。

  • IP 限制型 NAT 只限制 IP 地址,如果是同一主机的不同端口穿越 NAT 是没有任何问题的。

映射表

1
2
3
4
5
6
7
{
  内网IP,
  内网端口,
  映射的外网IP,
  映射的外网端口,
  被访问主机的IP
}

2.3 端口限制锥型

  • 端口限制锥型比 IP 限制锥型 NAT 更加严格,它主要的特点是,不光在 NAT 上对打洞的 IP 地址做了限制,而且还对具体的端口做了限制

    1
    2
    3
    4
    5
    6
    7
    8
    
    {
    内网IP,
    内网端口,
    映射的外网IP,
    映射的外网端口,
    被访问主机的IP,
    被访问主机的端口
    }

2.4 对称型 NAT

  • 对称型 NAT 是所有 NAT 类型中最严格的一种类型

  • 也就是说对称型 NAT 对每个连接都使用不同的端口,甚至更换 IP 地址,而端口限制型 NAT 的多个连接则使用同一个端口

2.5 NAT 检测

NAT 检测

第一步,判断是否有 NAT 防护

  • 主机向服务器 #1 的某个 IP 和端口发送一个请求,服务器 #1 收到请求后,会通过同样的 IP 和端口返回一个响应消息。

  • 如果主机收不到服务器 #1 返回的消息,则说明用户的网络限制了 UDP 协议,直接退出。

  • 如果能收到包,则判断返回的主机的外网 IP 地址是否与主机自身的 IP 地址一样。如果一样,说明主机就是一台拥有公网地址的主机;如果不一样,就跳到下面的步骤 6。

  • 如果主机拥有公网 IP,则还需要进一步判断其防火墙类型。所以它会再向服务器 #1 发一次请求,此时,服务器 #1 从另外一个网卡的 IP 和不同端口返回响应消息。

  • 如果主机能收到,说明它是一台没有防护的公网主机;如果收不到,则说明有对称型的防火墙保护着它。

  • 继续分析第 3 步,如果返回的外网 IP 地址与主机自身 IP 不一致,说明主机是处于 NAT 的防护之下,此时就需要对主机的 NAT 防护类型做进一步探测。

第二步,探测 NAT 环境

  • 在 NAT 环境下,主机向服务器 #1 发请求,服务器 #1 通过另一个网卡的 IP 和不同端口给主机返回响应消息。

  • 如果此时主机可以收到响应消息,说明它是在一个完全锥型 NAT 之下。如果收不到消息还需要再做进一步判断。

  • 如果主机收不到消息,它向服务器 #2(也就是第二台服务器)发请求,服务器 #2 使用收到请求的 IP 地址和端口向主机返回消息。

  • 主机收到消息后,判断从服务器 #2 获取的外网 IP 和端口与之前从服务器 #1 获取的外网 IP 和端口是否一致,如果不一致说明该主机是在对称型 NAT 之下。

  • 如果 IP 地址一样,则需要再次发送请求。此时主机向服务器 #1 再次发送请求,服务器 #1 使用同样的 IP 和不同的端口返回响应消息。

  • 此时,如果主机可以收到响应消息说明是 IP 限制型 NAT,否则就为端口限制型 NAT

3. 信令服务器

  • 房间管理
  • 信令交换

3.1 Node.js

Node.js 的强大就在于 JavaScript 与 C/C++ 可以相互调用,从而达到使其能力可以无限扩展的效果

Node.js 事件处理模型图

  • 当有网络请求过来时,首先会被插入到一个事件处理队列中。libuv 会监控该事件队列,当发现有事件时,先对请求做判断,如果是简单的请求,就直接返回响应了;如果是复杂请求,则从线程池中取一个线程进行异步处理

  • 线程处理完后,有两种可能:一种是已经处理完成,则向用户发送响应;另一种情况是还需要进一步处理,则再生成一个事件插入到事件队列中等待处理。事件处理就这样循环往复下去,永不停歇。

3.2 Socket.io

客户端代码

  • socket.io.js 文件需要单独获取
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
//index.html

<!DOCTYPE html>
<html>
  <head>
    <title>WebRTC client</title>
  </head>
  <body>
    <script src='./js/socket.io.js'></script>
    <script src='./js/client.js'></script>
  </body>
</html>

//client.js

var isInitiator;
room = prompt('Enter room name:'); //弹出一个输入窗口
const socket = io.connect('http://localhost:2013'); //与服务端建立socket连接
if (room !== '') { //如果房间不空,则发送 "create or join" 消息
  console.log('Joining room ' + room);
  socket.emit('create or join', room);
}
socket.on('full', (room) => { //如果从服务端收到 "full" 消息
  console.log('Room ' + room + ' is full');
});
socket.on('empty', (room) => { //如果从服务端收到 "empty" 消息
  isInitiator = true;
  console.log('Room ' + room + ' is empty');
});
socket.on('join', (room) => { //如果从服务端收到 “join" 消息
  console.log('Making request to join room ' + room);
  console.log('You are the initiator!');
});
socket.on('log', (array) => {
  console.log.apply(console, array);
});

服务端代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
const static = require('node-static');
const http = require('http');
const file = new(static.Server)();
const app = http.createServer(function (req, res) {
  file.serve(req, res);
}).listen(2013);
console.log('init');
const io = require('socket.io').listen(app); //侦听 2013
io.sockets.on('connection', (socket) => {
  console.log('connection');
  // convenience function to log server messages to the client
  function log(){ 
    const array = ['>>> Message from server: ']; 
    for (var i = 0; i < arguments.length; i++) {
      array.push(arguments[i]);
    } 
      socket.emit('log', array);
  }
  socket.on('message', (message) => { //收到message时,进行广播
    console.log('Got message:', message);
    // for a real app, would be room only (not broadcast)
    socket.broadcast.emit('message', message); //在真实的应用中,应该只在房间内广播
  });
  socket.on('create or join', (room) => { //收到 “create or join” 消息
  var clientsInRoom = io.sockets.adapter.rooms[room];
    var numClients = clientsInRoom ? Object.keys(clientsInRoom.sockets).length : 0; //房间里的人数
    console.log('Room ' + room + ' has ' + numClients + ' client(s)');
    console.log('Request to create or join room ' + room);
    if (numClients === 0){ //如果房间里没人
      socket.join(room);
      socket.emit('created', room); //发送 "created" 消息
    } else if (numClients === 1) { //如果房间里有一个人
    io.sockets.in(room).emit('join', room);
      socket.join(room);
      socket.emit('joined', room); //发送 “joined”消息
    } else { // max two clients
      socket.emit('full', room); //发送 "full" 消息
    }
    socket.emit('emit(): client ' + socket.id +
      ' joined room ' + room);
    socket.broadcast.emit('broadcast(): client ' + socket.id +
      ' joined room ' + room);
  });
});