LoginSignup
5
5

More than 3 years have passed since last update.

自前ビデオチャットサーバーを立てる

Posted at

はじめに

外部につながらないネットワークの中でビデオチャットをしたい

参考にしたところ

nginxを立てる

準備

  • macにVirtualBoxをインストール
  • CentOS7をインストール(minimal)
    • ネットワークはブリッジにしておく
  • dockerをいれる(https://docs.docker.com/engine/install/centos/) ## テスト # docker run -d 80:80 nginx スクリーンショット 2020-07-01 22.05.51.png

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

スクリーンショット 2020-07-01 23.23.06.png

https://qiita.com/colomney/items/887f9ea7b68a3b427060
を参考にしてchromeで表示できるようにする

カメラをブラウザから使う

- 参考:https://html5experts.jp/mganeko/19728/

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

スクリーンショット 2020-07-01 23.51.27.png

シグナリングサーバーを立てる

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でつなげる

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>
  &nbsp;
  <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:&nbsp;
    <!--
    <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にしてアドレス指定しただけなので割愛します
5
5
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
5