在没有 TURN 服务器的情况下,如何使用 WebRTC 绕过 NAT?
How do you get around NATs using WebRTC without a TURN server?
我正在尝试制作可以在移动浏览器上玩的点对点 Javascript 游戏。
- 我已经能够在本地 WiFi 网络中的两个 phone 之间成功建立 p2p 连接。
- 我无法通过移动网络连接两个 phone,或者一个在 WiFi 上,一个在移动网络上。
- 我尝试关闭 Windows 防火墙,但无法将我的 PC 连接到移动网络上的 phone。
- 我尝试让两个对等方建立自己的数据通道并设置协商。
我读到 80% 到 90% 的设备能够在没有 TURN 服务器的情况下通过 WebRTC 连接,所以我完全不知道下一步该做什么。
台式机:Google Chrome 79.0.3945.130(正式版)(64 位)(队列:稳定)
移动设备(Pixel 3/Android 10):Google Chrome 79.0.3945.116
移动网络
Time Event
1/24/2020, 11:58:17 PM createLocalDataChannel
label: Test, reliable: true
1/24/2020, 11:58:17 PM negotiationneeded
1/24/2020, 11:58:17 PM createOffer
1/24/2020, 11:58:17 PM createOfferOnSuccess
1/24/2020, 11:58:17 PM setLocalDescription
1/24/2020, 11:58:17 PM signalingstatechange
1/24/2020, 11:58:17 PM setLocalDescriptionOnSuccess
1/24/2020, 11:58:17 PM icegatheringstatechange
1/24/2020, 11:58:17 PM icecandidate (host)
1/24/2020, 11:58:17 PM icecandidate (srflx)
1/24/2020, 11:58:17 PM setRemoteDescription
1/24/2020, 11:58:17 PM addIceCandidate (host)
1/24/2020, 11:58:17 PM signalingstatechange
1/24/2020, 11:58:17 PM setRemoteDescriptionOnSuccess
1/24/2020, 11:58:17 PM iceconnectionstatechange
1/24/2020, 11:58:17 PM iceconnectionstatechange (legacy)
1/24/2020, 11:58:17 PM connectionstatechange
1/24/2020, 11:58:18 PM addIceCandidate (srflx)
1/24/2020, 11:58:33 PM iceconnectionstatechange
disconnected
1/24/2020, 11:58:33 PM iceconnectionstatechange (legacy)
failed
1/24/2020, 11:58:33 PM connectionstatechange
failed
无线网络
Time Event
1/25/2020, 12:02:45 AM
createLocalDataChannel
label: Test, reliable: true
1/25/2020, 12:02:45 AM negotiationneeded
1/25/2020, 12:02:45 AM createOffer
1/25/2020, 12:02:45 AM createOfferOnSuccess
1/25/2020, 12:02:45 AM setLocalDescription
1/25/2020, 12:02:45 AM signalingstatechange
1/25/2020, 12:02:45 AM setLocalDescriptionOnSuccess
1/25/2020, 12:02:45 AM icegatheringstatechange
1/25/2020, 12:02:45 AM icecandidate (host)
1/25/2020, 12:02:45 AM icecandidate (srflx)
1/25/2020, 12:02:46 AM setRemoteDescription
1/25/2020, 12:02:46 AM signalingstatechange
1/25/2020, 12:02:46 AM setRemoteDescriptionOnSuccess
1/25/2020, 12:02:46 AM icegatheringstatechange
1/25/2020, 12:02:46 AM addIceCandidate (host)
1/25/2020, 12:02:46 AM iceconnectionstatechange
1/25/2020, 12:02:46 AM iceconnectionstatechange (legacy)
1/25/2020, 12:02:46 AM connectionstatechange
1/25/2020, 12:02:46 AM addIceCandidate (srflx)
1/25/2020, 12:02:46 AM iceconnectionstatechange
connected
1/25/2020, 12:02:46 AM iceconnectionstatechange (legacy)
connected
1/25/2020, 12:02:46 AM connectionstatechange
connected
1/25/2020, 12:02:46 AM iceconnectionstatechange (legacy)
completed
点对点代码
"use strict";
import { isAssetLoadingComplete } from '/game/assetManager.js';
import { playerInputHandler } from '/game/game.js';
const rtcPeerConnectionConfiguration = {
// Server for negotiating traversing NATs when establishing peer-to-peer communication sessions
iceServers: [{
urls: [
'stun:stun.l.google.com:19302'
]
}]
};
let rtcPeerConn;
// For UDP semantics, set maxRetransmits to 0 and ordered to false
const dataChannelOptions = {
// TODO: Set this to a unique number returned from joinRoomResponse
//id: 1,
// json for JSON and raw for binary
protocol: "json",
// If true both peers can call createDataChannel as long as they use the same ID
negotiated: false,
// TODO: Set to false so the messages are faster and less reliable
ordered: true,
// If maxRetransmits and maxPacketLifeTime aren't set then reliable mode will be on
// TODO: Send multiple frames of player input every frame to avoid late/missing frames
//maxRetransmits: 0,
// The maximum number of milliseconds that attempts to transfer a message may take in unreliable mode.
//maxPacketLifeTime: 30000
};
let dataChannel;
export let isConnectedToPeers = false;
export function createDataChannel(roomName, socket) {
rtcPeerConn = new RTCPeerConnection(rtcPeerConnectionConfiguration);
// Send any ice candidates to the other peer
rtcPeerConn.onicecandidate = onIceCandidate(socket);
// Let the 'negotiationneeded' event trigger offer generation
rtcPeerConn.onnegotiationneeded = function () {
console.log("Creating an offer")
rtcPeerConn.createOffer(sendLocalDesc(socket), logError('createOffer'));
};
console.log("Creating a data channel");
dataChannel = rtcPeerConn.createDataChannel(roomName, dataChannelOptions);
dataChannel.onopen = dataChannelStateOpen;
dataChannel.onmessage = receiveDataChannelMessage;
dataChannel.onerror = logError('createAnswer');
dataChannel.onclose = function(TODO) {
console.log(`Data channel closed for scoket: ${socket}`, TODO)
};
}
export function joinDataChannel(socket) {
console.log("Joining a data channel");
rtcPeerConn = new RTCPeerConnection(rtcPeerConnectionConfiguration);
rtcPeerConn.ondatachannel = receiveDataChannel;
// Send any ice candidates to the other peer
rtcPeerConn.onicecandidate = onIceCandidate(socket);
}
function receiveDataChannel(rtcDataChannelEvent) {
console.log("Receiving a data channel", rtcDataChannelEvent);
dataChannel = rtcDataChannelEvent.channel;
dataChannel.onopen = dataChannelStateOpen;
dataChannel.onmessage = receiveDataChannelMessage;
dataChannel.onerror = logError('createAnswer');
dataChannel.onclose = function(TODO) {
console.log(`Data channel closed for scoket: ${socket}`, TODO)
};
}
function onIceCandidate(socket) {
return function (event) {
if (event.candidate) {
console.log("Sending ice candidates to peer.");
socket.emit('signalRequest', {
signal: event.candidate
});
}
}
}
function dataChannelStateOpen(event) {
console.log("Data channel opened", event);
isConnectedToPeers = true;
if(!isAssetLoadingComplete) {
document.getElementById("startGameButton").textContent = "Loading...";
}
else {
document.getElementById('startGameButton').removeAttribute('disabled');
document.getElementById("startGameButton").textContent = "Start Game";
}
}
function receiveDataChannelMessage(messageEvent) {
switch(dataChannel.protocol) {
case "json":
const data = JSON.parse(messageEvent.data)
playerInputHandler(data);
break;
case "raw":
break;
}
}
export function signalHandler(socket) {
return function (signal) {
if (signal.sdp) {
console.log("Setting remote description", signal);
rtcPeerConn.setRemoteDescription(
signal,
function () {
// If we received an offer, we need to answer
if (rtcPeerConn.remoteDescription.type === 'offer') {
console.log("Offer received, sending answer")
rtcPeerConn.createAnswer(sendLocalDesc(socket), logError('createAnswer'));
}
},
logError('setRemoteDescription'));
}
else if (signal.candidate){
console.log("Adding ice candidate ", signal)
rtcPeerConn.addIceCandidate(new RTCIceCandidate(signal));
}
}
}
function sendLocalDesc(socket) {
return function(description) {
rtcPeerConn.setLocalDescription(
description,
function () {
console.log("Setting local description", description);
socket.emit('signalRequest', {
playerNumber: socket.id,
signal: description
});
},
logError('setLocalDescription'));
};
}
export function sendPlayerInput(playerInput){
dataChannel.send(JSON.stringify(playerInput));
}
function logError(caller) {
return function(error) {
console.log('[' + caller + '] [' + error.name + '] ' + error.message);
}
}
这里有几个不同的因素可能在起作用。
- 双方的NAT类型
- IP 系列(IPv4 或 IPv6)
- 协议(是否允许 UDP?)
我会确定您在每一侧背后的 NAT 类型,您可以在此处阅读更多相关信息 https://webrtchacks.com/symmetric-nat。如果两个网络都位于对称 NAT 之后,您将需要一个 TURN 服务器。
如果您没有浏览器,也可以使用 Pion TURN Go TURN 客户端和服务器。
我还会在收集候选人时检查 IPv4/IPv6 上是否有交集。一些 phone 供应商只提供 IPv6。
UDP 可能根本不允许。这不常见,但有可能。在这种情况下,您将被迫使用 TURN。可以通过 TCP 穿越 NAT,但 WebRTC AFAIK 不支持。
TURN 服务器是该问题的解决方案。如果有不需要它的变通方法,就没有人会使用它。这里一个常见的误解是,如果您将 TURN 服务器添加到系统,它将中继所有流量。事实并非如此,它仅用作无法以其他方式建立的连接的后备。与通过 websocket 服务器路由所有游戏消息的替代方案相比,这仍将为您节省 80% 以上的流量。
下一步是安装 TURN 服务器。 coturn 被广泛使用并且有合理的记录。它足够稳定,一旦设置,所需的维护量非常低。
我正在尝试制作可以在移动浏览器上玩的点对点 Javascript 游戏。
- 我已经能够在本地 WiFi 网络中的两个 phone 之间成功建立 p2p 连接。
- 我无法通过移动网络连接两个 phone,或者一个在 WiFi 上,一个在移动网络上。
- 我尝试关闭 Windows 防火墙,但无法将我的 PC 连接到移动网络上的 phone。
- 我尝试让两个对等方建立自己的数据通道并设置协商。
我读到 80% 到 90% 的设备能够在没有 TURN 服务器的情况下通过 WebRTC 连接,所以我完全不知道下一步该做什么。
台式机:Google Chrome 79.0.3945.130(正式版)(64 位)(队列:稳定)
移动设备(Pixel 3/Android 10):Google Chrome 79.0.3945.116
移动网络
Time Event
1/24/2020, 11:58:17 PM createLocalDataChannel
label: Test, reliable: true
1/24/2020, 11:58:17 PM negotiationneeded
1/24/2020, 11:58:17 PM createOffer
1/24/2020, 11:58:17 PM createOfferOnSuccess
1/24/2020, 11:58:17 PM setLocalDescription
1/24/2020, 11:58:17 PM signalingstatechange
1/24/2020, 11:58:17 PM setLocalDescriptionOnSuccess
1/24/2020, 11:58:17 PM icegatheringstatechange
1/24/2020, 11:58:17 PM icecandidate (host)
1/24/2020, 11:58:17 PM icecandidate (srflx)
1/24/2020, 11:58:17 PM setRemoteDescription
1/24/2020, 11:58:17 PM addIceCandidate (host)
1/24/2020, 11:58:17 PM signalingstatechange
1/24/2020, 11:58:17 PM setRemoteDescriptionOnSuccess
1/24/2020, 11:58:17 PM iceconnectionstatechange
1/24/2020, 11:58:17 PM iceconnectionstatechange (legacy)
1/24/2020, 11:58:17 PM connectionstatechange
1/24/2020, 11:58:18 PM addIceCandidate (srflx)
1/24/2020, 11:58:33 PM iceconnectionstatechange
disconnected
1/24/2020, 11:58:33 PM iceconnectionstatechange (legacy)
failed
1/24/2020, 11:58:33 PM connectionstatechange
failed
无线网络
Time Event
1/25/2020, 12:02:45 AM
createLocalDataChannel
label: Test, reliable: true
1/25/2020, 12:02:45 AM negotiationneeded
1/25/2020, 12:02:45 AM createOffer
1/25/2020, 12:02:45 AM createOfferOnSuccess
1/25/2020, 12:02:45 AM setLocalDescription
1/25/2020, 12:02:45 AM signalingstatechange
1/25/2020, 12:02:45 AM setLocalDescriptionOnSuccess
1/25/2020, 12:02:45 AM icegatheringstatechange
1/25/2020, 12:02:45 AM icecandidate (host)
1/25/2020, 12:02:45 AM icecandidate (srflx)
1/25/2020, 12:02:46 AM setRemoteDescription
1/25/2020, 12:02:46 AM signalingstatechange
1/25/2020, 12:02:46 AM setRemoteDescriptionOnSuccess
1/25/2020, 12:02:46 AM icegatheringstatechange
1/25/2020, 12:02:46 AM addIceCandidate (host)
1/25/2020, 12:02:46 AM iceconnectionstatechange
1/25/2020, 12:02:46 AM iceconnectionstatechange (legacy)
1/25/2020, 12:02:46 AM connectionstatechange
1/25/2020, 12:02:46 AM addIceCandidate (srflx)
1/25/2020, 12:02:46 AM iceconnectionstatechange
connected
1/25/2020, 12:02:46 AM iceconnectionstatechange (legacy)
connected
1/25/2020, 12:02:46 AM connectionstatechange
connected
1/25/2020, 12:02:46 AM iceconnectionstatechange (legacy)
completed
点对点代码
"use strict";
import { isAssetLoadingComplete } from '/game/assetManager.js';
import { playerInputHandler } from '/game/game.js';
const rtcPeerConnectionConfiguration = {
// Server for negotiating traversing NATs when establishing peer-to-peer communication sessions
iceServers: [{
urls: [
'stun:stun.l.google.com:19302'
]
}]
};
let rtcPeerConn;
// For UDP semantics, set maxRetransmits to 0 and ordered to false
const dataChannelOptions = {
// TODO: Set this to a unique number returned from joinRoomResponse
//id: 1,
// json for JSON and raw for binary
protocol: "json",
// If true both peers can call createDataChannel as long as they use the same ID
negotiated: false,
// TODO: Set to false so the messages are faster and less reliable
ordered: true,
// If maxRetransmits and maxPacketLifeTime aren't set then reliable mode will be on
// TODO: Send multiple frames of player input every frame to avoid late/missing frames
//maxRetransmits: 0,
// The maximum number of milliseconds that attempts to transfer a message may take in unreliable mode.
//maxPacketLifeTime: 30000
};
let dataChannel;
export let isConnectedToPeers = false;
export function createDataChannel(roomName, socket) {
rtcPeerConn = new RTCPeerConnection(rtcPeerConnectionConfiguration);
// Send any ice candidates to the other peer
rtcPeerConn.onicecandidate = onIceCandidate(socket);
// Let the 'negotiationneeded' event trigger offer generation
rtcPeerConn.onnegotiationneeded = function () {
console.log("Creating an offer")
rtcPeerConn.createOffer(sendLocalDesc(socket), logError('createOffer'));
};
console.log("Creating a data channel");
dataChannel = rtcPeerConn.createDataChannel(roomName, dataChannelOptions);
dataChannel.onopen = dataChannelStateOpen;
dataChannel.onmessage = receiveDataChannelMessage;
dataChannel.onerror = logError('createAnswer');
dataChannel.onclose = function(TODO) {
console.log(`Data channel closed for scoket: ${socket}`, TODO)
};
}
export function joinDataChannel(socket) {
console.log("Joining a data channel");
rtcPeerConn = new RTCPeerConnection(rtcPeerConnectionConfiguration);
rtcPeerConn.ondatachannel = receiveDataChannel;
// Send any ice candidates to the other peer
rtcPeerConn.onicecandidate = onIceCandidate(socket);
}
function receiveDataChannel(rtcDataChannelEvent) {
console.log("Receiving a data channel", rtcDataChannelEvent);
dataChannel = rtcDataChannelEvent.channel;
dataChannel.onopen = dataChannelStateOpen;
dataChannel.onmessage = receiveDataChannelMessage;
dataChannel.onerror = logError('createAnswer');
dataChannel.onclose = function(TODO) {
console.log(`Data channel closed for scoket: ${socket}`, TODO)
};
}
function onIceCandidate(socket) {
return function (event) {
if (event.candidate) {
console.log("Sending ice candidates to peer.");
socket.emit('signalRequest', {
signal: event.candidate
});
}
}
}
function dataChannelStateOpen(event) {
console.log("Data channel opened", event);
isConnectedToPeers = true;
if(!isAssetLoadingComplete) {
document.getElementById("startGameButton").textContent = "Loading...";
}
else {
document.getElementById('startGameButton').removeAttribute('disabled');
document.getElementById("startGameButton").textContent = "Start Game";
}
}
function receiveDataChannelMessage(messageEvent) {
switch(dataChannel.protocol) {
case "json":
const data = JSON.parse(messageEvent.data)
playerInputHandler(data);
break;
case "raw":
break;
}
}
export function signalHandler(socket) {
return function (signal) {
if (signal.sdp) {
console.log("Setting remote description", signal);
rtcPeerConn.setRemoteDescription(
signal,
function () {
// If we received an offer, we need to answer
if (rtcPeerConn.remoteDescription.type === 'offer') {
console.log("Offer received, sending answer")
rtcPeerConn.createAnswer(sendLocalDesc(socket), logError('createAnswer'));
}
},
logError('setRemoteDescription'));
}
else if (signal.candidate){
console.log("Adding ice candidate ", signal)
rtcPeerConn.addIceCandidate(new RTCIceCandidate(signal));
}
}
}
function sendLocalDesc(socket) {
return function(description) {
rtcPeerConn.setLocalDescription(
description,
function () {
console.log("Setting local description", description);
socket.emit('signalRequest', {
playerNumber: socket.id,
signal: description
});
},
logError('setLocalDescription'));
};
}
export function sendPlayerInput(playerInput){
dataChannel.send(JSON.stringify(playerInput));
}
function logError(caller) {
return function(error) {
console.log('[' + caller + '] [' + error.name + '] ' + error.message);
}
}
这里有几个不同的因素可能在起作用。
- 双方的NAT类型
- IP 系列(IPv4 或 IPv6)
- 协议(是否允许 UDP?)
我会确定您在每一侧背后的 NAT 类型,您可以在此处阅读更多相关信息 https://webrtchacks.com/symmetric-nat。如果两个网络都位于对称 NAT 之后,您将需要一个 TURN 服务器。
如果您没有浏览器,也可以使用 Pion TURN Go TURN 客户端和服务器。
我还会在收集候选人时检查 IPv4/IPv6 上是否有交集。一些 phone 供应商只提供 IPv6。
UDP 可能根本不允许。这不常见,但有可能。在这种情况下,您将被迫使用 TURN。可以通过 TCP 穿越 NAT,但 WebRTC AFAIK 不支持。
TURN 服务器是该问题的解决方案。如果有不需要它的变通方法,就没有人会使用它。这里一个常见的误解是,如果您将 TURN 服务器添加到系统,它将中继所有流量。事实并非如此,它仅用作无法以其他方式建立的连接的后备。与通过 websocket 服务器路由所有游戏消息的替代方案相比,这仍将为您节省 80% 以上的流量。
下一步是安装 TURN 服务器。 coturn 被广泛使用并且有合理的记录。它足够稳定,一旦设置,所需的维护量非常低。