はじめに
外部につながらないネットワークの中でビデオチャットをしたい
参考にしたところ
nginxを立てる
準備
- macにVirtualBoxをインストール
- CentOS7をインストール(minimal)
- ネットワークはブリッジにしておく
- dockerをいれる(https://docs.docker.com/engine/install/centos/)
テスト
# docker run -d 80:80 nginx
HTTPSのための証明書を準備する
[root@localhost nginx]# mkdir ssl
[root@localhost nginx]# openssl genrsa 2048 > ssl/key.pem
Generating RSA private key, 2048 bit long modulus
.........+++
..........................+++
e is 65537 (0x10001)
[root@localhost nginx]# openssl req -new -key ssl/key.pem > ssl/cacert.pem
You are about to be asked to enter information that will be incorporated
into your certificate request.
What you are about to enter is what is called a Distinguished Name or a DN.
There are quite a few fields but you can leave some blank
For some fields there will be a default value,
If you enter '.', the field will be left blank.
-----
Country Name (2 letter code) [XX]:JP
State or Province Name (full name) []:
Locality Name (eg, city) [Default City]:
Organization Name (eg, company) [Default Company Ltd]:
Organizational Unit Name (eg, section) []:
Common Name (eg, your name or your server's hostname) []:
Email Address []:
Please enter the following 'extra' attributes
to be sent with your certificate request
A challenge password []:
An optional company name []:
[root@localhost nginx]# openssl x509 -days 3650 -req -signkey ssl/key.pem < ssl/cacert.pem > ssl/cert.pem
Signature ok
subject=/C=JP/L=Default City/O=Default Company Ltd
Getting Private key
- confの変更
conf.d/default.conf
server {
listen 443 ssl;
ssl_certificate /etc/nginx/ssl/cert.pem;
ssl_certificate_key /etc/nginx/ssl/key.pem;
location / {
index index.html index.htm;
root /usr/share/nginx/html;
}
}
- 動作確認
# docker run -p 443:443 -p 80:80 -v $(pwd)/ssl:/etc/nginx/ssl -v $(pwd)/conf.d:/etc/nginx/conf.d nginx
https://qiita.com/colomney/items/887f9ea7b68a3b427060
を参考にしてchromeで表示できるようにする
カメラをブラウザから使う
index.html
<!doctype html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>Wrap old and new getUserMedia</title>
</head>
<body>
Wrap old and new getUserMedia<br />
<button type="button" onclick="startVideo();">Start</button>
<button type="button" onclick="stopVideo();">Stop</button>
<br />
<video id="local_video" autoplay style="width: 320px; height: 240px; border: 1px solid black;"></video>
</body>
<script type="text/javascript">
var localVideo = document.getElementById('local_video');
var localStream = null;
// --- prefix -----
navigator.getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia ||
navigator.mozGetUserMedia || navigator.msGetUserMedia;
// ---------------------- video handling -----------------------
// start local video
function startVideo() {
getDeviceStream({video: true, audio: false})
.then(function (stream) { // success
localStream = stream;
playVideo(localVideo, stream);
}).catch(function (error) { // error
console.error('getUserMedia error:', error);
return;
});
}
// stop local video
function stopVideo() {
pauseVideo(localVideo);
stopLocalStream(localStream);
}
function stopLocalStream(stream) {
let tracks = stream.getTracks();
if (! tracks) {
console.warn('NO tracks');
return;
}
for (let track of tracks) {
track.stop();
}
}
function getDeviceStream(option) {
if ('getUserMedia' in navigator.mediaDevices) {
console.log('navigator.mediaDevices.getUserMadia');
return navigator.mediaDevices.getUserMedia(option);
}
else {
console.log('wrap navigator.getUserMadia with Promise');
return new Promise(function(resolve, reject){
navigator.getUserMedia(option,
resolve,
reject
);
});
}
}
function playVideo(element, stream) {
if ('srcObject' in element) {
element.srcObject = stream;
}
else {
element.src = window.URL.createObjectURL(stream);
}
element.play();
element.volume = 0;
}
function pauseVideo(element) {
element.pause();
if ('srcObject' in element) {
element.srcObject = null;
}
else {
if (element.src && (element.src !== '') ) {
window.URL.revokeObjectURL(element.src);
}
element.src = '';
}
}
</script>
</html>
# docker run -p 443:443 -v $(pwd)/ssl:/etc/nginx/ssl -v $(pwd)/conf.d:/etc/nginx/conf.d -v $(pwd)/html:/usr/share/nginx/html
nginx
シグナリングサーバーを立てる
-
https://nodejs.org/ja/docs/guides/nodejs-docker-webappを参考にdockerでnode.jsを動かす
-
Dockerfile
FROM node:12
# アプリケーションディレクトリを作成する
WORKDIR /usr/src/app
# アプリケーションの依存関係をインストールする
# ワイルドカードを使用して、package.json と package-lock.json の両方が確実にコピーされるようにします。
# 可能であれば (npm@5+)
COPY package*.json ./
RUN npm install
# 本番用にコードを作成している場合
# RUN npm install --only=production
# アプリケーションのソースをバンドルする
COPY signaling.js ./
# サーバー証明書
COPY ssl /etc/ssl
CMD [ "node", "signaling.js" ]
package.json
{
"name": "node-signaling-server",
"version": "1.0.0",
"description": "Sigaling Server by Node.js on Docker",
"author": "First Last <first.last@example.com>",
"main": "signaling.js",
"scripts": {
"start": "node signaling.js"
},
"dependencies": {
"ws": "^7.3.0"
}
}
httpsを使っていので、WebSocketもSecure WebSocketにする必要があります。
(証明書はnginxと同じもをとりあえず使用)
signaling.js
"use strict";
let fs = require('fs');
let https = require('https');
let webSocketServer = require('ws').Server;
let port = 3001;
let httpsServer = https.createServer({
cert: fs.readFileSync('/etc/ssl/cert.pem'),
ca: fs.readFileSync('/etc/ssl/cacert.pem'),
key: fs.readFileSync('/etc/ssl/key.pem')
}).listen(port);
let wssServer = new webSocketServer({ server: httpsServer });
console.log('secure websocket server start. port=' + port);
wssServer.on('connection', function(wss) {
console.log('-- secure websocket connected --');
wss.on('message', function(message) {
wssServer.clients.forEach(function each(client) {
if (isSame(wss, client)) {
console.log('- skip sender -');
}
else {
client.send(message);
}
});
});
});
function isSame(ws1, ws2) {
// -- compare object --
return (ws1 === ws2);
}
".dockerignore"
node_modules
npm-debug.log
ビルドと実行
# docker build . -t node-signaling-server
# docker run -d -p 3001:3001 node-signaling-server
1対1でつなげる
- 基本的にhttps://html5experts.jp/mganeko/20013/をなぞっている
- https://github.com/mganeko/webrtcexpjp/blob/master/basic2016/ws_signaling_1to1_trickle.htmlこちらを元にwssにして、サーバーアドレスを固定にした。
wc_1to1.html
<!doctype html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>WebSocket Signaling 1to1</title>
</head>
<body>
WebSocket Signaling 1to1 (trickle ICE)<br />
<button type="button" onclick="startVideo();">Start Video</button>
<button type="button" onclick="stopVideo();">Stop Video</button>
<button type="button" onclick="connect();">Connect</button>
<button type="button" onclick="hangUp();">Hang Up</button>
<div>
<video id="local_video" autoplay style="width: 160px; height: 120px; border: 1px solid black;"></video>
<video id="remote_video" autoplay style="width: 160px; height: 120px; border: 1px solid black;"></video>
</div>
<p>SDP to send:<br />
<textarea id="text_for_send_sdp" rows="5" cols="60" readonly="readonly">SDP to send</textarea>
</p>
<p>SDP received:
<!--
<button type="button" onclick="onSdpText();">Receive remote SDP</button>
-->
<br />
<textarea id="text_for_receive_sdp" rows="5" cols="60"></textarea>
</p>
</body>
<script type="text/javascript">
let localVideo = document.getElementById('local_video');
let remoteVideo = document.getElementById('remote_video');
let localStream = null;
let peerConnection = null;
let textForSendSdp = document.getElementById('text_for_send_sdp');
let textToReceiveSdp = document.getElementById('text_for_receive_sdp');
// --- prefix -----
navigator.getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia ||
navigator.mozGetUserMedia || navigator.msGetUserMedia;
RTCPeerConnection = window.RTCPeerConnection || window.webkitRTCPeerConnection || window.mozRTCPeerConnection;
RTCSessionDescription = window.RTCSessionDescription || window.webkitRTCSessionDescription || window.mozRTCSessionDescription;
// -------- websocket ----
// please use node.js app
//
// or you can use chrome app (only work with Chrome)
// https://chrome.google.com/webstore/detail/simple-message-server/bihajhgkmpfnmbmdnobjcdhagncbkmmp
//
let wsUrl = 'wss://192.168.111.106:3001/';
let ws = new WebSocket(wsUrl);
ws.onopen = function(evt) {
console.log('ws open()');
};
ws.onerror = function(err) {
console.error('ws onerror() ERR:', err);
};
ws.onmessage = function(evt) {
console.log('ws onmessage() data:', evt.data);
let message = JSON.parse(evt.data);
if (message.type === 'offer') {
// -- got offer ---
console.log('Received offer ...');
textToReceiveSdp.value = message.sdp;
let offer = new RTCSessionDescription(message);
setOffer(offer);
}
else if (message.type === 'answer') {
// --- got answer ---
console.log('Received answer ...');
textToReceiveSdp.value = message.sdp;
let answer = new RTCSessionDescription(message);
setAnswer(answer);
}
else if (message.type === 'candidate') {
// --- got ICE candidate ---
console.log('Received ICE candidate ...');
let candidate = new RTCIceCandidate(message.ice);
console.log(candidate);
addIceCandidate(candidate);
}
};
// ---------------------- media handling -----------------------
// start local video
function startVideo() {
getDeviceStream({video: true, audio: false})
.then(function (stream) { // success
localStream = stream;
playVideo(localVideo, stream);
}).catch(function (error) { // error
console.error('getUserMedia error:', error);
return;
});
}
// stop local video
function stopVideo() {
pauseVideo(localVideo);
stopLocalStream(localStream);
}
function stopLocalStream(stream) {
let tracks = stream.getTracks();
if (! tracks) {
console.warn('NO tracks');
return;
}
for (let track of tracks) {
track.stop();
}
}
function getDeviceStream(option) {
if ('getUserMedia' in navigator.mediaDevices) {
console.log('navigator.mediaDevices.getUserMadia');
return navigator.mediaDevices.getUserMedia(option);
}
else {
console.log('wrap navigator.getUserMadia with Promise');
return new Promise(function(resolve, reject){
navigator.getUserMedia(option,
resolve,
reject
);
});
}
}
function playVideo(element, stream) {
if ('srcObject' in element) {
element.srcObject = stream;
}
else {
element.src = window.URL.createObjectURL(stream);
}
element.play();
element.volume = 0;
}
function pauseVideo(element) {
element.pause();
if ('srcObject' in element) {
element.srcObject = null;
}
else {
if (element.src && (element.src !== '') ) {
window.URL.revokeObjectURL(element.src);
}
element.src = '';
}
}
// ----- hand signaling ----
function onSdpText() {
let text = textToReceiveSdp.value;
if (peerConnection) {
console.log('Received answer text...');
let answer = new RTCSessionDescription({
type : 'answer',
sdp : text,
});
setAnswer(answer);
}
else {
console.log('Received offer text...');
let offer = new RTCSessionDescription({
type : 'offer',
sdp : text,
});
setOffer(offer);
}
textToReceiveSdp.value ='';
}
function sendSdp(sessionDescription) {
console.log('---sending sdp ---');
textForSendSdp.value = sessionDescription.sdp;
/*---
textForSendSdp.focus();
textForSendSdp.select();
----*/
let message = JSON.stringify(sessionDescription);
console.log('sending SDP=' + message);
ws.send(message);
}
function sendIceCandidate(candidate) {
console.log('---sending ICE candidate ---');
let obj = { type: 'candidate', ice: candidate };
let message = JSON.stringify(obj);
console.log('sending candidate=' + message);
ws.send(message);
}
// ---------------------- connection handling -----------------------
function prepareNewConnection() {
let pc_config = {"iceServers":[]};
let peer = new RTCPeerConnection(pc_config);
// --- on get remote stream ---
if ('ontrack' in peer) {
peer.ontrack = function(event) {
console.log('-- peer.ontrack()');
let stream = event.streams[0];
playVideo(remoteVideo, stream);
};
}
else {
peer.onaddstream = function(event) {
console.log('-- peer.onaddstream()');
let stream = event.stream;
playVideo(remoteVideo, stream);
};
}
// --- on get local ICE candidate
peer.onicecandidate = function (evt) {
if (evt.candidate) {
console.log(evt.candidate);
// Trickle ICE の場合は、ICE candidateを相手に送る
sendIceCandidate(evt.candidate);
// Vanilla ICE の場合には、何もしない
} else {
console.log('empty ice event');
// Trickle ICE の場合は、何もしない
// Vanilla ICE の場合には、ICE candidateを含んだSDPを相手に送る
//sendSdp(peer.localDescription);
}
};
// --- when need to exchange SDP ---
peer.onnegotiationneeded = function(evt) {
console.log('-- onnegotiationneeded() ---');
};
// --- other events ----
peer.onicecandidateerror = function (evt) {
console.error('ICE candidate ERROR:', evt);
};
peer.onsignalingstatechange = function() {
console.log('== signaling status=' + peer.signalingState);
};
peer.oniceconnectionstatechange = function() {
console.log('== ice connection status=' + peer.iceConnectionState);
if (peer.iceConnectionState === 'disconnected') {
console.log('-- disconnected --');
hangUp();
}
};
peer.onicegatheringstatechange = function() {
console.log('==***== ice gathering state=' + peer.iceGatheringState);
};
peer.onconnectionstatechange = function() {
console.log('==***== connection state=' + peer.connectionState);
};
peer.onremovestream = function(event) {
console.log('-- peer.onremovestream()');
pauseVideo(remoteVideo);
};
// -- add local stream --
if (localStream) {
console.log('Adding local stream...');
peer.addStream(localStream);
}
else {
console.warn('no local stream, but continue.');
}
return peer;
}
function makeOffer() {
peerConnection = prepareNewConnection();
peerConnection.createOffer()
.then(function (sessionDescription) {
console.log('createOffer() succsess in promise');
return peerConnection.setLocalDescription(sessionDescription);
}).then(function() {
console.log('setLocalDescription() succsess in promise');
// -- Trickle ICE の場合は、初期SDPを相手に送る --
sendSdp(peerConnection.localDescription);
// -- Vanilla ICE の場合には、まだSDPは送らない --
}).catch(function(err) {
console.error(err);
});
}
function setOffer(sessionDescription) {
if (peerConnection) {
console.error('peerConnection alreay exist!');
}
peerConnection = prepareNewConnection();
peerConnection.setRemoteDescription(sessionDescription)
.then(function() {
console.log('setRemoteDescription(offer) succsess in promise');
makeAnswer();
}).catch(function(err) {
console.error('setRemoteDescription(offer) ERROR: ', err);
});
}
function makeAnswer() {
console.log('sending Answer. Creating remote session description...' );
if (! peerConnection) {
console.error('peerConnection NOT exist!');
return;
}
peerConnection.createAnswer()
.then(function (sessionDescription) {
console.log('createAnswer() succsess in promise');
return peerConnection.setLocalDescription(sessionDescription);
}).then(function() {
console.log('setLocalDescription() succsess in promise');
// -- Trickle ICE の場合は、初期SDPを相手に送る --
sendSdp(peerConnection.localDescription);
// -- Vanilla ICE の場合には、まだSDPは送らない --
}).catch(function(err) {
console.error(err);
});
}
function setAnswer(sessionDescription) {
if (! peerConnection) {
console.error('peerConnection NOT exist!');
return;
}
peerConnection.setRemoteDescription(sessionDescription)
.then(function() {
console.log('setRemoteDescription(answer) succsess in promise');
}).catch(function(err) {
console.error('setRemoteDescription(answer) ERROR: ', err);
});
}
// --- tricke ICE ---
function addIceCandidate(candidate) {
if (peerConnection) {
peerConnection.addIceCandidate(candidate);
}
else {
console.error('PeerConnection not exist!');
return;
}
}
// start PeerConnection
function connect() {
if (! peerConnection) {
console.log('make Offer');
makeOffer();
}
else {
console.warn('peer already exist.');
}
}
// close PeerConnection
function hangUp() {
if (peerConnection) {
console.log('Hang up.');
peerConnection.close();
peerConnection = null;
pauseVideo(remoteVideo);
}
else {
console.warn('peer NOT exist.');
}
}
</script>
</html>
複数接続
package.json
{
"name": "node-signaling-server",
"version": "1.0.0",
"description": "Sigaling Server by Node.js on Docker",
"author": "First Last <first.last@example.com>",
"main": "signaling.js",
"scripts": {
"start": "node signaling.js"
},
"dependencies": {
"socket.io": "^2.3.0"
}
}
signaling.js
"use strict";
const fs = require('fs');
const https = require('https');
const httpsServer = https.createServer({
cert: fs.readFileSync('/etc/ssl/cert.pem'),
ca: fs.readFileSync('/etc/ssl/cacert.pem'),
key: fs.readFileSync('/etc/ssl/key.pem')
});
const io = require('socket.io')(httpsServer);
const port = 3002;
httpsServer.listen(port);
console.log('secure signaling server started on port:' + port);
// This callback function is called every time a socket
// tries to connect to the server
io.on('connection', function(socket) {
// ---- multi room ----
socket.on('enter', function(roomname) {
socket.join(roomname);
console.log('id=' + socket.id + ' enter room=' + roomname);
setRoomname(roomname);
});
function setRoomname(room) {
socket.roomname = room;
}
function getRoomname() {
var room = socket.roomname;
return room;
}
function emitMessage(type, message) {
// ----- multi room ----
var roomname = getRoomname();
if (roomname) {
console.log('===== message broadcast to room -->' + roomname);
socket.broadcast.to(roomname).emit(type, message);
}
else {
console.log('===== message broadcast all');
socket.broadcast.emit(type, message);
}
}
// When a user send a SDP message
// broadcast to all users in the room
socket.on('message', function(message) {
var date = new Date();
message.from = socket.id;
console.log(date + 'id=' + socket.id + ' Received Message: ' + JSON.stringify(message));
// get send target
var target = message.sendto;
if (target) {
console.log('===== message emit to -->' + target);
socket.to(target).emit('message', message);
return;
}
// broadcast in room
emitMessage('message', message);
});
// When the user hangs up
// broadcast bye signal to all users in the room
socket.on('disconnect', function() {
// close user connection
console.log((new Date()) + ' Peer disconnected. id=' + socket.id);
// --- emit ----
emitMessage('user disconnected', {id: socket.id});
// --- leave room --
var roomname = getRoomname();
if (roomname) {
socket.leave(roomname);
}
});
});
wc_multi.html
こちらは参照先のソースのhttp->httpsにしてアドレス指定しただけなので割愛します