WebRTCとは
Web Real-Time Communicationsの略でWebブラウザにプラグインを追加することなく、リアルタイムにコミュニケーションを可能にする技術のことです。
ネットワークトポロジー
WebRTCでの通信形態は以下3つあります。WebRTCを利用するアプリケーションの特性により何れかを選択することになります。
P2Pタイプ
クライアント同士がそれぞれで接続する仕組みで、メッシュ型と呼ばれたりします。
※本記事もP2Pタイプで実装しています。
MCU(Multipoint Control Unit)
通信を1本に統合する仕組みですが、中央サーバで暗号化を解き、動画や音声を合成し配信するのでCPUに大変負荷がかかります。ただし転送量は最適化されます。
SFU (Selective Forwarding Unit)
上りは各クライアントから1本に抑え、下りを複数本にする仕組みです。
中央サーバはクライアントから送られてきたデータをそれぞれのクライアントに転送します。
クライアント側は必要な分だけ接続をはり、中央サーバはそれに合わせてデータを配信する仕組みです。
P2Pの確立
P2Pの通信を確立するにあたり、SDPとCandidateの情報をブラウザ間で交換します。
SDP(Session Description Protocol)
SDPは通信を行う際お互いがどのような映像・音声のコーデックを使えるかなどストリーミングメディアの初期化パラメータを記述する形式の一つです。
自端末のChromeブラウザにて取得した同情報(一部)は以下の通りで、VP8(ビデオコーディック)やopus(音声圧縮方式)が列挙されているのが分かります。尚、リストの最初にある形式が優先的に使用されるようです。
m=video 9 UDP/TLS/RTP/SAVPF 96 97 98 99 100 101 102 122 127 121 125 107 108 109 124 120 123 119 114 115 116
a=rtpmap:96 VP8/90000
a=rtpmap:97 rtx/90000
a=rtpmap:98 VP9/90000
m=audio 9 UDP/TLS/RTP/SAVPF 111 103 104 9 0 8 106 105 13 110 112 113 126
a=rtpmap:111 opus/48000/2
a=rtpmap:103 ISAC/16000
a=rtpmap:104 ISAC/32000
ICE Candidate
ICE(Interactive Connectivity Establishment)はNAT越え(NAT トラバーサル) を実現するメカニズムで、通信相手と通信経路のアドレスの候補 (Candidate) を交換します。相手から受信したCandidateと自身の同情報から最適な経路を決定します。
以下のtyp hostはLAN上の経路情報でPCの物理NICや仮想NICの情報をブラウザが収集したものです。typ srflxはSTUNサーバを利用して確認したNATを経由する経路情報です。同一LAN内でWebRTC通信する際は、hostの経路を利用するのがもっともコストが掛からないためそのように接続しますが、ルータを介して通信する際は、srflxの経路を利用することになります。
candidate:2448668656 1 udp 2122260223 192.168.142.1 62373 typ host generation 0 ufrag PHWw network-id 1
candidate:1867667642 1 udp 2122194687 192.168.254.1 62374 typ host generation 0 ufrag PHWw network-id 5
candidate:747801767 1 udp 2122131711 [IPv6のアドレス] 62375 typ host generation 0 ufrag PHWw network-id 3 network-cost 10
candidate:1688413422 1 udp 2122066175 [IPv6のアドレス] 62376 typ host generation 0 ufrag PHWw network-id 4 network-cost 10
candidate:1189248530 1 udp 2121998079 192.168.1.49 62377 typ host generation 0 ufrag PHWw network-id 2 network-cost 10
candidate:3748678400 1 tcp 1518280447 192.168.142.1 9 typ host tcptype active generation 0 ufrag PHWw network-id 1
candidate:567387210 1 tcp 1518214911 192.168.254.1 9 typ host tcptype active generation 0 ufrag PHWw network-id 5
candidate:1645310039 1 tcp 1518151935 [IPv6のアドレス] 9 typ host tcptype active generation 0 ufrag PHWw network-id 3 network-cost 10
candidate:706795550 1 tcp 1518086399 [IPv6のアドレス] 9 typ host tcptype active generation 0 ufrag PHWw network-id 4 network-cost 10
candidate:140608226 1 tcp 1518018303 192.168.1.49 9 typ host tcptype active generation 0 ufrag PHWw network-id 2 network-cost 10
candidate:2968651718 1 udp 1685790463 [グローバルIP] 6817 typ srflx raddr 192.168.1.49 rport 54927 generation 0 ufrag Uliz network-id 2 network-cost 10
STUN(Session Traversal Utilities for NATs)
インターネット上のサーバにリクエストを送り、ルータによって変換されたグローバルIPアドレスをSTUNサーバに返してもらいます。
そこで得たグローバルIPアドレスを相手と交換します。
TURN(Traversal Using Relay around NAT)
外部の端末が発信した通信が自身に届かないネットワーク環境の場合は、STUNサーバだけでは解決しないので、TURNサーバを使って通信を確立します。
TURNサーバは自身も相手も通信できるインターネット上にいて通信を中継するサーバです。
RTCPeerConnectionを生成する際に、STUNサーバやTURNサーバのアドレスを設定できます。
システム構成
ブラウザとブラウザで直接やり取りをしますが全くサーバが不要ということはなく、初めにお互いの情報がわからなければやりとりしようがないため、最初だけシグナリングサーバと呼ばれるものが必要になります。また、NATを超える通信が必要な場合はSTUNサーバやTURNサーバも必要です。
本記事ではSTUNとTURNサーバにオープンソフトのCOTURNを採用しました。シグナリングサーバはNodeJSで作成・実行し、sample.htmlを返すWebサーバも用意します。sample.htmlを表示するブラウザはChromeを使用します。
全てhttpsでアクセスしないとChromeに怒られてしまうため、オレオレ証明書も作成しています。
最終的にはPC1とPC2でオンライン会話できることを目標にします。
COTURNサーバ
COTURNサーバを構築しますが、まず依存するツールやライブラリを先にインストールします。
$ sudo apt-get install gcc
$ sudo apt-get install sqlite
$ sudo apt-get install sqlite3
$ sudo apt-get install libssl-dev
$ sudo apt-get install libevent-dev
$ sudo apt-get install make
$ wget https://github.com/libevent/libevent/releases/download/release-2.1.11-stable/libevent-2.1.11-stable.tar.gz
$ tar xvfz libevent-2.1.11-stable.tar.gz
$ cd libevent-2.1.11-stable
$ ./configure
$ make
$ sudo make install
$ wget https://coturn.net/turnserver/v4.5.1.2/turnserver-4.5.1.2.tar.gz
$ tar xvfz turnserver-4.5.1.2.tar.gz
$ cd turnserver-4.5.1.2
$ ./configure
$ make
$ sudo make install
設定ファイルをコピーします。
$ sudo cp /usr/local/etc/turnserver.conf.default /usr/local/etc/turnserver.conf
設定ファイルを編集します。
$ sudo vi /usr/local/etc/turnserver.conf
--- 以下を有効にして内容を書き換えてください ---
realm=foo.bar.com
listening-ip=XXX.XXX.XXX.XXX(さくらVPSで割り振られたグローバルIPを指定)
no-udp
no-tcp
min-port=49152
max-port=65535
verbose
cert=/usr/local/etc/turn_server_cert.pem
pkey=/usr/local/etc/turn_server_pkey.pem
dh2066
tls-listening-port=5349
lt-cred-mech
user=test:password
secure-stun
オレオレ証明書を作成する準備をします。
$ vi /etc/ssl/openssl.cnf
--- 以下を有効にして内容を書き換えてください ---
unique_subject = no
copy_extensions = copy
サーバ証明書のSANに記述する文字列を別ファイルで用意します。
$ vi san.txt
--- 以下の内容で記述してください ---
subjectAltName = DNS:foo.bar.com
サーバ証明書を作成します。
$ openssl genrsa -out server_key.pem 2048
$ openssl req -batch -new -key server_key.pem -out server_csr.pem -subj "/C=JP/ST=Tokyo/L=Minato-ku/O=Foo/OU=Bar/CN=foo.bar.com"
$ openssl x509 -in server_csr.pem -out server_crt.pem -req -signkey server_key.pem -days 73000 -sha256 -extfile san.txt
作成したサーバ証明書と秘密鍵をコピーします。
$ sudo cp ./server_crt.pem /usr/local/etc/turn_server_cert.pem
$ sudo cp ./server_key.pem /usr/local/etc/turn_server_pkey.pem
COTURNサーバを起動させます。
尚、ログは /var/log/turn_*.log となります。
$ sudo /usr/local/bin/turnserver -o -v -c /usr/local/etc/turnserver.conf
シグナリングサーバ
まずファイル群を置くフォルダを作成します。
$ mkdir signaling
プログラムの動作に必要なパッケージをインストールします。
cd ./signaling
$ sudo apt install npm
$ npm install socket.io
$ npm install https
$ npm install fs
$ npm install express
先ほど作成したサーバ証明書と秘密鍵をこちらにもコピー(ファイル名はそのまま)します。
$ sudo cp ../server_crt.pem ./
$ sudo cp ../server_key.pem ./
signaling.jsを配置します。
"use strict";
var ssl_server_key = 'server_key.pem';
var ssl_server_crt = 'server_crt.pem';
var port = 10443;
var fs = require('fs');
var app = require('express')();
var https = require('https');
var server = https.createServer({
key: fs.readFileSync(ssl_server_key).toString(),
cert: fs.readFileSync(ssl_server_crt).toString()
}, app);
server.listen(port);
var io = require('socket.io').listen(server);
io.set('heartbeat interval', 5000);
io.set('heartbeat timeout', 15000);
console.log((new Date()) + " Server is listening on port: " + port);
var sockets = {};
var users = {};
io.sockets.on('connection', function(socket) {
console.log((new Date()) + " remoteAddress: " + socket.request.connection.remoteAddress);
socket.on('username', function(message) {
try {
if (message != null && message.length > 0) {
sockets[socket.id] = socket;
users[socket.id] = message;
}
let userlist = getUserList(socket.id);
console.log((new Date()) + " username: " + JSON.stringify(userlist));
socket.emit('message', userlist);
} catch(e) {
}
});
socket.on('message', function(message) {
let skt = sockets[message.sendto];
try {
if (skt) {
message.sendfrom = socket.id;
skt.emit('message', message);
}
} catch(e) {
}
});
socket.on('disconnect', function() {
console.log((new Date()) + " disconnect ");
try {
for (let key in sockets) {
if (key == socket.id) {
delete sockets[socket.id];
}
}
for (let key in users) {
if (key == socket.id) {
delete users[socket.id];
}
}
} catch(e) {
}
});
});
function getUserList(id) {
let aryUsers = [];
let aryIds = [];
for (let key in users) {
if (key != id) {
aryIds.push(key);
aryUsers.push(users[key]);
}
}
var userlist = {
type: 'userlist',
ids: aryIds,
users: aryUsers,
}
return userlist;
}
setInterval(() => {
console.log((new Date()) + " alluser: " + JSON.stringify(users));
}, 3000);
signalingフォルダのファイル一覧は以下の通りです。
node_modules/
package-lock.json
server_crt.pem
server_key.pem
signaling.js
以下のコマンドでsignaling.jsを実行します。
sudo nohup node signaling.js &
Webサーバ
Webサーバの構築については以下のsample.htmlを先ほど作成したサーバ証明書にてhttpsで応答するよう構築してください。apacheやnginx、その他お好きなサーバをご使用ください。
<!doctype html>
<html lang="jp">
<head>
<meta charset="utf-8"/>
<title>オンライン会話</title>
</head>
<body onload="startVideo()">
<script src="https://foo.bar.com:10443/socket.io/socket.io.js"></script>
<div class="display:table;">
<div class="display:table-cell; vertical-align: top; ">
<video id="local-video" autoplay style="width: 240px; height: 180px; border: 1px solid black;"></video>
<video id="remote-video" autoplay style="width: 240px; height: 180px; border: 1px solid black;"></video>
</div>
<div style="display:table-cell; height:100px; vertical-align: top; ">
<span style="vertical-align: top;">接続名</span><input type="text" id="username" value="">
<button style="vertical-align: top;" type="button" onclick="entry();">更新</button>
<select style="vertical-align: top; width: 100px; " size="6" id="userlist"></select>
<button id="connect-btn" style="vertical-align: top;" type="button" onclick="connect();">接続</button>
<button id="disconnect-btn" style="vertical-align: top;" type="button" onclick="disconnect();" disabled>切断</button>
</div>
</div>
<script type="text/javascript">
var localVideo = document.getElementById('local-video');
var remoteVideo = document.getElementById('remote-video');
var localStream = null;
var peerConnection = null;
var peerSocketId = null;
var localDescription = null;
var peerStarted = false;
var mediaConstraints = {'mandatory': {'OfferToReceiveAudio':true, 'OfferToReceiveVideo':true }};
var socketReady = false;
var port = 10443;
var socket = io.connect('https://foo.bar.com:' + port + '/');
socket.on('connect', onOpened)
.on('message', onMessage);
function onOpened(evt) {
socketReady = true;
}
function onMessage(evt) {
if (evt.type === 'offer') {
onOffer(evt);
} else if (evt.type === 'answer' && peerStarted) {
onAnswer(evt);
} else if (evt.type === 'candidate' && peerStarted) {
onCandidate(evt);
} else if (evt.type === 'user dissconnected' && peerStarted) {
stop();
} else if (evt.type === 'userlist') {
onUserList(evt);
}
}
var btnConnect = document.getElementById('connect-btn');
var btnDisconnect = document.getElementById('disconnect-btn');
var textUsername = document.getElementById('username');
var selectUserlist = document.getElementById("userlist");
var CR = String.fromCharCode(13);
function onOffer(evt) {
setOffer(evt);
sendAnswer(evt);
peerStarted = true;
btnConnect.disabled = true;
btnDisconnect.disabled = false;
}
function onAnswer(evt) {
setAnswer(evt);
}
function onCandidate(evt) {
var candidate = new RTCIceCandidate({sdpMLineIndex:evt.sdpMLineIndex, sdpMid:evt.sdpMid, candidate:evt.candidate});
peerConnection.addIceCandidate(candidate);
}
function sendSDP(message) {
socket.emit("message", message);
}
function sendCandidate(message) {
socket.emit("message", message);
}
function entry() {
if (textUsername.value.trim().length > 0) {
socket.emit('username', textUsername.value);
} else {
alert("接続名を入力してください。");
}
}
function startVideo() {
navigator.mediaDevices.getUserMedia({video: { width: 240, height: 180 }, audio: true}).
then(function (stream) {
localStream = stream;
localVideo.srcObject = stream;
localVideo.volume = 0.7;
localVideo.onloadedmetadata = function(e) {
localVideo.play();
};
})
.catch(function(err) {
});
}
function stopVideo() {
if (peerStarted) {
peerConnection.getSenders().forEach(function( sender ) {
peerConnection.removeTrack(sender);
delete sender;
sender = null;
});
}
localVideo.pause();
localVideo.srcObject = null;
localStream.getTracks().forEach(track => track.stop());
}
function prepareNewConnection() {
let pc_config = {"iceServers":[
{"urls": "stun:foo.bar.com:5349"},
{"urls": "turn:foo.bar.com:5349", "username":"test", "credential":"password"}
]};
var peer = null;
try {
peer = new webkitRTCPeerConnection(pc_config);
} catch (e) {
}
peer.onicecandidate = function (evt) {
if (evt.candidate) {
let message = {type: "candidate", candidate: evt.candidate.candidate, sdpMLineIndex:evt.candidate.sdpMLineIndex, sdpMid:evt.candidate.sdpMid};
message.sendto = peerSocketId;
sendCandidate(message);
} else {
}
};
peer.oniceconnectionstatechange = function() {
switch (peer.iceConnectionState) {
case 'disconnected':
case 'closed':
case 'failed':
if (peerConnection) {
disconnect();
}
break;
}
};
let videoSender = peer.addTrack(localStream.getVideoTracks()[0], localStream);
let audioSender = peer.addTrack(localStream.getAudioTracks()[0], localStream);
peer.ontrack = function(event) {
const track = event.track;
const stream = event.streams[0];
remoteVideo.srcObject = stream;
};
return peer;
}
function sendOffer(sendto) {
peerConnection = prepareNewConnection();
peerConnection.createOffer(function (sessionDescription) {
localDescription = sessionDescription;
peerConnection.setLocalDescription(localDescription);
let message = {type: sessionDescription.type, sdp: sessionDescription.sdp};
if (sendto) {
message.sendto = sendto;
}
peerSocketId = message.sendto;
sendSDP(message);
}, function () {
}, mediaConstraints);
}
function setOffer(evt) {
if (peerConnection) {
}
peerConnection = prepareNewConnection();
peerSocketId = evt.sendfrom;
peerConnection.setRemoteDescription(new RTCSessionDescription(evt));
}
function sendAnswer(evt) {
let sendfrom = evt.sendfrom;
if (! peerConnection) {
return;
}
peerConnection.createAnswer(function (sessionDescription) {
peerConnection.setLocalDescription(sessionDescription);
let message = {type: sessionDescription.type, sdp: sessionDescription.sdp};
message.sendto = evt.sendfrom;
sendSDP(message);
}, function () {
}, mediaConstraints);
}
function setAnswer(evt) {
if (! peerConnection) {
return;
}
peerConnection.setRemoteDescription(new RTCSessionDescription(evt));
}
function connect() {
if (selectUserlist.selectedIndex == -1) {
alert("接続先を選択してください。");
return;
}
let sendto = selectUserlist.options[selectUserlist.selectedIndex].value
if (!socketReady) {
alert("シグナリングサーバと接続できません。");
} else if (localStream == null) {
alert("ビデオが起動していません。");
} else if (peerStarted) {
alert("既に接続済みです。");
} else {
sendOffer(sendto);
peerStarted = true;
btnConnect.disabled = true;
btnDisconnect.disabled = false;
}
}
function disconnect() {
stop();
}
function stopRemoteVideo() {
remoteVideo.pause();
remoteVideo.srcObject = null;
}
function stop() {
peerConnection.close();
peerConnection = null;
peerStarted = false;
btnConnect.disabled = false;
btnDisconnect.disabled = true;
}
function onUserList(evt) {
let i = 0;
while(i < selectUserlist.length) {
if (evt.ids.length == 0) {
selectUserlist.remove(i);
} else {
for (let j = 0; j < evt.ids.length; j++) {
if (selectUserlist[i].value == evt.ids[j]) {
selectUserlist[i].text = evt.users[j];
evt.ids.splice(j, 1);
evt.users.splice(j, 1);
i++;
break;
} else if (j == evt.ids.length -1) {
selectUserlist.remove(i);
}
}
}
}
for(i = 0; i < evt.ids.length; i++) {
let option = document.createElement("option");
option.text = evt.users[i];
option.value = evt.ids[i];
selectUserlist.appendChild(option);
}
}
setInterval(() => {
try {
socket.emit('username', textUsername.value);
} catch(e) {
}
}, 3000);
</script>
</body>
</html>
クライアント端末
先ほど作成したサーバ証明書(server_crt.pem)をクライアント端末(Chrome)の「信頼されたルート証明機関」にインストールします。インストールしたらChromeを再起動してください。
また、hostsファイルに以下の1行を追加してください。
※xxx.xxx.xxx.xxxはさくらVPSで割り振られたグローバルIPです。
xxx.xxx.xxx.xxx foo.bar.com
動作確認
PC1とPC2でChromeを立ち上げて以下のURLをたたきます。
https://foo.bar.com/sample.html
ここでPC1には接続名に「太郎」、PC2では「花子」と入力して更新ボタンを押下します。
「花子」を選択して接続ボタンを押下すると無事接続されました。
Turnサーバの認証
現状の設定ではTurnサーバに固定のusernameとcredentialで接続していますが、その情報を知った利用者(または利用者から情報を得た第三者)に意図しない利用のされ方をする恐れがあります。そこで、Turnサーバの認証機能を有効にします。
Turnserverのパラメータ変更
Turnserverの設定ファイルを編集します。
$ sudo vi /usr/local/etc/turnserver.conf
--- 以下を有効にしてください ---
use-auth-secret
--- 以下を無効にしてください ---
# lt-cred-mech
# user=test:password
Turnserverを起動させますが、新たに秘密鍵(文字列)「pkey」を指定します。
sudo /usr/local/bin/turnserver -o -v -c /usr/local/etc/turnserver.conf --static-auth-secret pkey
signaling.jsでHMACを算出
Turnserverの認証で使用するusernameとcredentialをsignaling.jsで計算しクライアントに返却します。
まず、HMACの計算に必要なモジュールをインストールします。
cd ./signaling
$ npm install crypto-js
続いてsignaling.jsの最終行に以下のステップを追加します。
ここで返却されたusernameとpassword(credential)は5分後までTurnserverで認証できます。
var crypto = require('crypto');
function getTURNCredentials(name, secret) {
// シグナリングサーバとTurnサーバのタイムゾーンが異なる場合は修正が必要。
var unixTimeStamp = parseInt(new Date() / 1000) + 300, // 5分後まで認証可能
username = [unixTimeStamp, name].join(':'),
password,
hmac = crypto.createHmac('sha1', secret);
hmac.setEncoding('base64');
hmac.write(username);
hmac.end();
password = hmac.read();
return {
username: username,
password: password
};
}
また、getUserList関数を以下に書き換えてください。
function getUserList(id) {
let aryUsers = [];
let aryIds = [];
for (let key in users) {
if (key != id) {
aryIds.push(key);
aryUsers.push(users[key]);
}
}
let cdt = getTURNCredentials('anonymous', 'pkey'); // この行を追加
var userlist = {
type: 'userlist',
ids: aryIds,
users: aryUsers,
turn_username: cdt.username, // この行を追加
turn_password: cdt.password // この行を追加
}
return userlist;
}
WebサーバのHTMLを修正
さらに、シグナリングサーバから返却されたusernameとpassword(credential)をturnserverに渡すようプログラムを書き換えます。
--- 略 ---
var port = 10443;
var socket = io.connect('https://foo.bar.com:' + port + '/');
var turn_username = null; // この行を追加
var turn_password = null; // この行を追加
socket.on('connect', onOpened)
.on('message', onMessage);
--- 略 ---
let pc_config = {"iceServers":[
{"urls": "stun:foo.bar.com:5349"},
{"urls": "turn:foo.bar.com:5349", "username": turn_username, "credential": turn_password} // この行を書き換え
]};
--- 略 ---
for(i = 0; i < evt.ids.length; i++) {
let option = document.createElement("option");
option.text = evt.users[i];
option.value = evt.ids[i];
selectUserlist.appendChild(option);
}
if (turn_username == null) { // この行を追加
turn_username = evt.turn_username; // この行を追加
turn_password = evt.turn_password; // この行を追加
} // この行を追加
}
setInterval(() => {
try {
socket.emit('username', textUsername.value);
} catch(e) {
}
}, 3000);
--- 略 ---
以上で設定、及びプログラムの変更は終わりです。(プロセスの再起動を忘れずに)
今回の作りだとシグナリングサーバ自体の認証がないのと、シグナリングサーバでは求めに応じてcredentialを無条件に返却してしまうためセキュリティが向上しているとは言えませんが、実装の方法は示せたと思います。
帯域制限
現状のプログラムに帯域制限を追加します。APIが用意されているので実装はシンプルで、P2Pのコネクション確立時にvideoとaudioのビットレートを指定します。
WebサーバのHTMLを修正
--- 略 ---
peer.oniceconnectionstatechange = function() {
switch (peer.iceConnectionState) {
// 以下の行を追加 ---------------------------------
case 'connected':
// 帯域制限
peerConnection.getSenders().forEach((sender) => {
let parameters = sender.getParameters();
if (!parameters.encodings) {
parameters.encodings = [{}];
}
if(sender.track.kind === "video") {
let bandwidth = 500;
parameters.encodings[0].maxBitrate = bandwidth * 1000;
} else if(sender.track.kind === "audio") {
let bandwidth = 100;
parameters.encodings[0].maxBitrate = bandwidth * 1000;
}
sender.setParameters(parameters).then(() => {
console.log("adjust bandwidth success ");
}, (err) => {
console.log("adjust bandwidth error " + err);
});
sender = null;
});
// ----------------------------------------------
case 'disconnected':
case 'closed':
case 'failed':
if (peerConnection) {
disconnect();
}
break;
}
};
--- 略 ---
以上でした。