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

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

# 参考にしたところ
- http://dslab.work/2019/04/25/post-119/
- https://html5experts.jp/series/webrtc2016/

# 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://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/92080/106533b5-bc52-ca49-45cf-fe7745963564.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-image-store.s3.ap-northeast-1.amazonaws.com/0/92080/3b874af2-2da4-5cab-dfc6-d174c4e7e33e.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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/92080/3e08b6aa-bb84-030c-1ecb-5ab8b90c2d44.png)

## シグナリングサーバーを立てる
 - <https://nodejs.org/ja/docs/guides/nodejs-docker-webapp>を参考にdockerでnode.jsを動かす

 - Dockerfile

``` 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>
  &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>
```
## 複数接続

 - 参考：<https://html5experts.jp/mganeko/20112/>

``` 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にしてアドレス指定しただけなので割愛します
```