diff --git a/meet/server/config/config.js b/meet/server/config/config.js index 65c5ef37..5e59e809 100644 --- a/meet/server/config/config.js +++ b/meet/server/config/config.js @@ -1,124 +1,126 @@ const os = require('os'); module.exports = { // Authentication token for API (not websocket) requests authToken: process.env.AUTH_TOKEN, // Turn server configuration turn: process.env.TURN_SERVER === 'none' ? null : { urls: [ // Using transport=tcp prevents the use of udp for the connection to the server, which is useful for testing, // but most likely not desired for production: https://datatracker.ietf.org/doc/html/rfc5766#section-2.1 process.env.TURN_SERVER || 'turn:127.0.0.1:3478?transport=tcp' ], staticSecret: process.env.TURN_STATIC_SECRET || 'uzYguvIl9tpZFMuQOE78DpOi6Jc7VFSD0UAnvgMsg5n4e74MgIf6vQvbc6LWzZjz', }, // Webhook URL webhookURL: process.env.WEBHOOK_URL, // Webhook authentication token webhookToken: process.env.WEBHOOK_TOKEN, // if you use encrypted private key the set the passphrase tls: process.env.SSL_CERT === 'none' ? null : { // passphrase: 'key_password' cert: process.env.SSL_CERT || `/etc/pki/tls/certs/kolab.hosted.com.cert`, key: process.env.SSL_KEY || `/etc/pki/tls/certs/kolab.hosted.com.key`, }, + // force a wss websocket if ssl is terminated externally + forceWSS: process.env.FORCE_WSS, // listening Host or IP // Use "0.0.0.0" or "::") to listen on every IP. listeningHost: process.env.LISTENING_HOST || "0.0.0.0", // Listening port for https server. listeningPort: process.env.LISTENING_PORT || 12443, // Used to establish the websocket connection from the client. publicDomain: process.env.PUBLIC_DOMAIN || '127.0.0.1:12443', // API path prefix pathPrefix: '/meetmedia', // Room size before spreading to new router routerScaleSize: process.env.ROUTER_SCALE_SIZE || 16, // Socket timeout value requestTimeout: 20000, // Socket retries when timeout requestRetries: 3, // Mediasoup settings mediasoup: { numWorkers: process.env.MEDIASOUP_NUM_WORKERS || Object.keys(os.cpus()).length, // mediasoup Worker settings. worker: { logLevel: 'warn', logTags: [ 'info', 'ice', 'dtls', 'rtp', 'srtp', 'rtcp' ], rtcMinPort: 40000, rtcMaxPort: 49999 }, // mediasoup Router settings. router: { // Router media codecs. mediaCodecs: [ { kind : 'audio', mimeType : 'audio/opus', clockRate : 48000, channels : 2 }, { kind : 'video', mimeType : 'video/VP8', clockRate : 90000, parameters : { 'x-google-start-bitrate' : 1000 } }, { kind : 'video', mimeType : 'video/VP9', clockRate : 90000, parameters : { 'profile-id' : 2, 'x-google-start-bitrate' : 1000 } }, { kind : 'video', mimeType : 'video/h264', clockRate : 90000, parameters : { 'packetization-mode' : 1, 'profile-level-id' : '4d0032', 'level-asymmetry-allowed' : 1, 'x-google-start-bitrate' : 1000 } }, { kind : 'video', mimeType : 'video/h264', clockRate : 90000, parameters : { 'packetization-mode' : 1, 'profile-level-id' : '42e01f', 'level-asymmetry-allowed' : 1, 'x-google-start-bitrate' : 1000 } } ] }, // mediasoup WebRtcTransport settings. webRtcTransport: { listenIps: [ { ip: process.env.WEBRTC_LISTEN_IP, announcedIp: null } ], // Initial bitrate estimation initialAvailableOutgoingBitrate: 1000000, // Additional options that are not part of WebRtcTransportOptions. maxIncomingBitrate: 1500000 } } }; diff --git a/meet/server/server.js b/meet/server/server.js index c4199dad..2a045178 100755 --- a/meet/server/server.js +++ b/meet/server/server.js @@ -1,421 +1,421 @@ #!/usr/bin/env node process.title = 'kolabmeet-server'; const config = require('./config/config'); const fs = require('fs'); const http = require('http'); const spdy = require('spdy'); const express = require('express'); const bodyParser = require('body-parser'); const compression = require('compression'); const mediasoup = require('mediasoup'); const Logger = require('./lib/Logger'); const Room = require('./lib/Room'); const Peer = require('./lib/Peer'); const helmet = require('helmet'); const axios = require('axios'); const interactiveServer = require('./lib/interactiveServer'); const { v4: uuidv4 } = require('uuid'); /* eslint-disable no-console */ console.log('- process.env.DEBUG:', process.env.DEBUG); console.log('- config.mediasoup.worker.logLevel:', config.mediasoup.worker.logLevel); console.log('- config.mediasoup.worker.logTags:', config.mediasoup.worker.logTags); /* eslint-enable no-console */ if (!config.mediasoup.webRtcTransport.listenIps[0].ip) { console.error('A webrtc listen ip is reuquired'); process.exit(3) } const logger = new Logger(); // mediasoup Workers. // @type {Array} const mediasoupWorkers = []; // Map of Room instances indexed by roomId. const rooms = new Map(); // Map of Peer instances indexed by peerId. const peers = new Map(); // HTTP client instance for webhook "pushes" let webhook = null; if (config.webhookURL) { webhook = axios.create({ baseURL: config.webhookURL, headers: { 'X-Auth-Token': config.webhookToken }, timeout: 5000 }); } const app = express(); app.use(helmet.hsts()); app.use((req, res, next) => { if (req.get('X-Auth-Token') !== config.authToken) { logger.debug("X-Auth-Token mismatch") res.status(403).send(); } else { next(); } }); app.use(bodyParser.json({ limit: '5mb' })); app.use(bodyParser.urlencoded({ limit: '5mb', extended: true })); let mainListener; let io; async function run() { try { await interactiveServer(rooms, peers); await runMediasoupWorkers(); await runHttpsServer(); await runWebSocketServer(); // eslint-disable-next-line no-unused-vars const errorHandler = (err, req, res, next) => { const trackingId = uuidv4(); res.status(500).send( `

Internal Server Error

If you report this error, please also report this tracking ID which makes it possible to locate your session in the logs which are available to the system administrator: ${trackingId}

` ); logger.error( 'Express error handler dump with tracking ID: %s, error dump: %o', trackingId, err); }; app.use(errorHandler); } catch (error) { logger.error('run() [error:"%o"]', error); } app.emit('ready'); } async function runHttpsServer() { app.use(compression()); app.get(`${config.pathPrefix}/api/stats`, async function (req, res) { let stats = {}; for (const room of rooms) { let roomStats; for (const peer of Object.values(room._peers)) { let peerStats = { id: peer.id, nickname: peer._nickname, consumers: [], producers: [], transports: [], }; for (const entry of peer._consumers.values()) { peerStats.consumers.push(await entry.getStats()) } for (const entry of peer._producers.values()) { peerStats.producers.push(await entry.getStats()) } for (const entry of peer._transports.values()) { peerStats.transports.push(await entry.getStats()) } roomStats[peer.id] = peerStats; } stats[room.id] = roomStats; } res.send(stats); }); app.get(`${config.pathPrefix}/api/health`, function (req, res) { res.send({ success: true, message: "Healthy" }); }); app.get(`${config.pathPrefix}/api/ping`, function (req, res) { res.send('PONG'); }) app.get(`${config.pathPrefix}/api/sessions`, function (req, res) { let list = []; rooms.forEach(room => { list.push({ roomId: room.id, createdAt: room.createdAt }) }) res.json(list) }) // Check if the room exists app.get(`${config.pathPrefix}/api/sessions/:session_id`, function (req, res) { const room = rooms.get(req.params.session_id); if (!room) { res.status(404).send(); } else { res.status(200).send(); } }) // Create room and return id app.post(`${config.pathPrefix}/api/sessions`, async function (req, res) { console.log("Creating new room"); const room = await createRoom(); res.json({ id : room.id }) }) // Send a websocket notification signals to the room participants app.post(`${config.pathPrefix}/api/signal`, async function (req, res) { const data = req.body; const roomId = data.roomId; const emit = (socket) => { socket.emit('notification', { method: `signal:${data.type}`, data: data.data }) }; if ('role' in data) { peers.forEach(peer => { if (peer.socket && peer.roomId == roomId && peer.hasRole(data.role)) { emit(peer.socket); } }) } else { emit(io.to(roomId)); } res.json({}); }); // Create connection in room (just wait for websocket instead? // $post = [ // 'json' => [ // 'role' => self::OV_ROLE_PUBLISHER, // 'data' => json_encode(['role' => $role]) // ] // ]; app.post(`${config.pathPrefix}/api/sessions/:session_id/connection`, function (req, res) { logger.info('Creating peer connection [roomId:"%s"]', req.params.session_id); const roomId = req.params.session_id; const room = rooms.get(roomId); if (!room) { res.status(404).send(); return; } const peer = new Peer({ roomId }); peers.set(peer.id, peer); peer.on('close', () => { peers.delete(peer.id); }); const data = req.body; if ('role' in data) peer.setRole(data.role); - const proto = config.tls ? 'wss' : 'ws'; + const proto = config.tls || config.forceWSS ? 'wss' : 'ws'; res.json({ id: peer.id, // Note: socket.io client will end up using (hardcoded) /meetmedia/signaling path token: `${proto}://${config.publicDomain}?peerId=${peer.id}&roomId=${roomId}&authToken=${peer.authToken}` }); }) if (config.tls) { // TLS server configuration. const tls = { cert: fs.readFileSync(config.tls.cert), key: fs.readFileSync(config.tls.key), secureOptions: 'tlsv12', ciphers: [ 'ECDHE-ECDSA-AES128-GCM-SHA256', 'ECDHE-RSA-AES128-GCM-SHA256', 'ECDHE-ECDSA-AES256-GCM-SHA384', 'ECDHE-RSA-AES256-GCM-SHA384', 'ECDHE-ECDSA-CHACHA20-POLY1305', 'ECDHE-RSA-CHACHA20-POLY1305', 'DHE-RSA-AES128-GCM-SHA256', 'DHE-RSA-AES256-GCM-SHA384' ].join(':'), honorCipherOrder: true }; mainListener = spdy.createServer(tls, app); } else { mainListener = http.createServer(app); } console.info(`Listening on ${config.listeningPort} ${config.listeningHost}`) mainListener.listen(config.listeningPort, config.listeningHost); } /** * Create a WebSocketServer to allow WebSocket connections from browsers. */ async function runWebSocketServer() { io = require('socket.io')(mainListener, { path: `${config.pathPrefix}/signaling`, cookie: false }); // Handle connections from clients. io.on('connection', async (socket) => { const { roomId, peerId, authToken } = socket.handshake.query; if (!roomId || !peerId || !authToken) { logger.warn('connection request without roomId and/or peerId'); socket.disconnect(true); return; } logger.info('connection request [roomId:"%s", peerId:"%s"]', roomId, peerId); try { const room = rooms.get(roomId); if (!room) { logger.warn("Room does not exist %s", roomId); socket.disconnect(true); return; } const peer = peers.get(peerId); if (!peer || peer.roomId != roomId || peer.authToken != authToken) { logger.warn("Peer does not exist %s", peerId); socket.disconnect(true); return; } await room.joinRoom({ peer, socket }); } catch (error) { logger.error('room creation or room joining failed [error:"%o"]', error); if (socket) socket.disconnect(true); } }); } /** * Launch as many mediasoup Workers as given in the configuration file. */ async function runMediasoupWorkers() { const { numWorkers } = config.mediasoup; logger.info('running %d mediasoup Workers...', numWorkers); for (let i = 0; i < numWorkers; ++i) { const worker = await mediasoup.createWorker( { logLevel : config.mediasoup.worker.logLevel, logTags : config.mediasoup.worker.logTags, rtcMinPort : config.mediasoup.worker.rtcMinPort, rtcMaxPort : config.mediasoup.worker.rtcMaxPort }); worker.on('died', () => { logger.error( 'mediasoup Worker died, exiting in 2 seconds... [pid:%d]', worker.pid); setTimeout(() => process.exit(1), 2000); }); mediasoupWorkers.push(worker); } } async function getLeastLoadedWorker() { let workerLoads = new Map(); for (const worker of mediasoupWorkers) { workerLoads.set(worker.pid, 0); } for (const peer of peers.values()) { if (peer.workerId) { const workerId = peer.workerId; workerLoads.set(workerId, workerLoads.get(workerId) + 1); } } const sortedWorkerLoads = new Map([ ...workerLoads.entries() ].sort( (a, b) => a[1] - b[1])); const workerId = sortedWorkerLoads.keys().next().value; return mediasoupWorkers.find((worker) => worker.pid == workerId) } /** * Get a Room instance (or create one if it does not exist). */ async function createRoom() { logger.info('creating a new Room'); // Create the room const room = await Room.create({ getLeastLoadedWorker }); room.on('close', () => { logger.info('closing a Room [roomId:"%s"]', room.id); rooms.delete(room.id); if (webhook) { webhook.post('', { roomId: room.id, event: 'roomClosed' }) .then(function (/* response */) { logger.info(`Room ${room.id} closed. Webhook succeeded.`); }) .catch(function (error) { logger.error(error); }); } }); room.on('joinRequestAccepted', (requestId) => { if (webhook) { webhook.post('', { requestId, roomId: room.id, event: 'joinRequestAccepted' }) .then(function (/* response */) { logger.info(`Accepted join request ${requestId}. Webhook succeeded.`); }) .catch(function (error) { logger.error(error); }); } }); room.on('joinRequestDenied', (requestId) => { if (webhook) { webhook.post('', { requestId, roomId: room.id, event: 'joinRequestDenied' }) .then(function (/* response */) { logger.info(`Denied join request ${requestId}. Webhook succeeded.`); }) .catch(function (error) { logger.error(error); }); } }); rooms.set(room.id, room); return room; } run(); module.exports = app; // export for testing