#!/usr/bin/env node process.title = 'multiparty-meeting-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 cookieParser = require('cookie-parser'); const compression = require('compression'); const mediasoup = require('mediasoup'); const AwaitQueue = require('awaitqueue'); const Logger = require('./lib/Logger'); const Room = require('./lib/Room'); const Peer = require('./lib/Peer'); const base64 = require('base-64'); const helmet = require('helmet'); const userRoles = require('./userRoles'); const { loginHelper, logoutHelper } = require('./httpHelper'); // auth const passport = require('passport'); const LTIStrategy = require('passport-lti'); const imsLti = require('ims-lti'); const redis = require('redis'); const redisClient = redis.createClient(config.redisOptions); const { Issuer, Strategy } = require('openid-client'); const expressSession = require('express-session'); const RedisStore = require('connect-redis')(expressSession); const sharedSession = require('express-socket.io-session'); const interactiveServer = require('./lib/interactiveServer'); const promExporter = require('./lib/promExporter'); /* 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 */ const logger = new Logger(); const queue = new AwaitQueue(); let statusLogger = null; if ('StatusLogger' in config) statusLogger = new config.StatusLogger(); // mediasoup Workers. // @type {Array} const mediasoupWorkers = []; // Index of next mediasoup Worker to use. // @type {Number} let nextMediasoupWorkerIdx = 0; // Map of Room instances indexed by roomId. const rooms = new Map(); // Map of Peer instances indexed by peerId. const peers = new Map(); // 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 }; const app = express(); app.use(helmet.hsts()); app.use(cookieParser()); app.use(bodyParser.json()); app.use(bodyParser.urlencoded({ extended: true })); const session = expressSession({ secret : config.cookieSecret, name : config.cookieName, resave : true, saveUninitialized : true, store : new RedisStore({ client: redisClient }), cookie : { secure : true, httpOnly : true, maxAge : 60 * 60 * 1000 // Expire after 1 hour since last request from user } }); if (config.trustProxy) { app.set('trust proxy', config.trustProxy); } app.use(session); passport.serializeUser((user, done) => { done(null, user); }); passport.deserializeUser((user, done) => { done(null, user); }); let mainListener; let io; let oidcClient; let oidcStrategy; async function run() { // Open the interactive server. await interactiveServer(rooms, peers); // start Prometheus exporter if (config.prometheus) { await promExporter(rooms, peers, config.prometheus); } if (typeof(config.auth) === 'undefined') { logger.warn('Auth is not configured properly!'); } else { await setupAuth(); } // Run a mediasoup Worker. await runMediasoupWorkers(); // Run HTTPS server. await runHttpsServer(); // Run WebSocketServer. await runWebSocketServer(); // Log rooms status every 30 seconds. setInterval(() => { for (const room of rooms.values()) { room.logStatus(); } }, 120000); // check for deserted rooms setInterval(() => { for (const room of rooms.values()) { room.checkEmpty(); } }, 10000); } function statusLog() { if (statusLogger) { statusLogger.log({ rooms : rooms.size, peers : peers.size }); } } function setupLTI(ltiConfig) { // Add redis nonce store ltiConfig.nonceStore = new imsLti.Stores.RedisStore(ltiConfig.consumerKey, redisClient); ltiConfig.passReqToCallback= true; const ltiStrategy = new LTIStrategy( ltiConfig, (req, lti, done) => { // LTI launch parameters if (lti) { const user = {}; if (lti.user_id && lti.custom_room) { user.id = lti.user_id; user._userinfo = { 'lti': lti }; } if (lti.custom_room) { user.room = lti.custom_room; } else { user.room = ''; } if (lti.lis_person_name_full) { user.displayName = lti.lis_person_name_full; } // Perform local authentication if necessary return done(null, user); } else { return done('LTI error'); } } ); passport.use('lti', ltiStrategy); } function setupOIDC(oidcIssuer) { oidcClient = new oidcIssuer.Client(config.auth.oidc.clientOptions); // ... any authorization request parameters go here // client_id defaults to client.client_id // redirect_uri defaults to client.redirect_uris[0] // response type defaults to client.response_types[0], then 'code' // scope defaults to 'openid' /* eslint-disable camelcase */ const params = (({ client_id, redirect_uri, scope }) => ({ client_id, redirect_uri, scope }))(config.auth.oidc.clientOptions); /* eslint-enable camelcase */ // optional, defaults to false, when true req is passed as a first // argument to verify fn const passReqToCallback = false; // optional, defaults to false, when true the code_challenge_method will be // resolved from the issuer configuration, instead of true you may provide // any of the supported values directly, i.e. "S256" (recommended) or "plain" const usePKCE = false; oidcStrategy = new Strategy( { client: oidcClient, params, passReqToCallback, usePKCE }, (tokenset, userinfo, done) => { if (userinfo && tokenset) { // eslint-disable-next-line camelcase userinfo._tokenset_claims = tokenset.claims(); } const user = { id : tokenset.claims.sub, provider : tokenset.claims.iss, _userinfo : userinfo }; return done(null, user); } ); passport.use('oidc', oidcStrategy); } async function setupAuth() { // LTI if ( typeof(config.auth.lti) !== 'undefined' && typeof(config.auth.lti.consumerKey) !== 'undefined' && typeof(config.auth.lti.consumerSecret) !== 'undefined' ) setupLTI(config.auth.lti); // OIDC if ( typeof(config.auth.oidc) !== 'undefined' && typeof(config.auth.oidc.issuerURL) !== 'undefined' && typeof(config.auth.oidc.clientOptions) !== 'undefined' ) { const oidcIssuer = await Issuer.discover(config.auth.oidc.issuerURL); // Setup authentication setupOIDC(oidcIssuer); } app.use(passport.initialize()); app.use(passport.session()); // loginparams app.get('/auth/login', (req, res, next) => { passport.authenticate('oidc', { state : base64.encode(JSON.stringify({ peerId : req.query.peerId, roomId : req.query.roomId })) })(req, res, next); }); // lti launch app.post('/auth/lti', passport.authenticate('lti', { failureRedirect: '/' }), (req, res) => { res.redirect(`/${req.user.room}`); } ); // logout app.get('/auth/logout', (req, res) => { const { peerId } = req.session; const peer = peers.get(peerId); if (peer) { for (const role of peer.roles) { if (role !== userRoles.NORMAL) peer.removeRole(role); } } req.logout(); req.session.destroy(() => res.send(logoutHelper())); }); // callback app.get( '/auth/callback', passport.authenticate('oidc', { failureRedirect: '/auth/login' }), async (req, res) => { const state = JSON.parse(base64.decode(req.query.state)); const { peerId, roomId } = state; req.session.peerId = peerId; req.session.roomId = roomId; let peer = peers.get(peerId); if (!peer) // User has no socket session yet, make temporary peer = new Peer({ id: peerId, roomId }); if (peer.roomId !== roomId) // The peer is mischievous throw new Error('peer authenticated with wrong room'); if (typeof config.userMapping === 'function') { await config.userMapping({ peer, roomId, userinfo : req.user._userinfo }); } peer.authenticated = true; res.send(loginHelper({ displayName : peer.displayName, picture : peer.picture })); } ); } async function runHttpsServer() { app.use(compression()); app.use('/.well-known/acme-challenge', express.static('public/.well-known/acme-challenge')); app.all('*', async (req, res, next) => { if (req.secure || config.httpOnly) { const ltiURL = new URL(`${req.protocol }://${ req.get('host') }${req.originalUrl}`); if ( req.isAuthenticated && req.user && req.user.displayName && !ltiURL.searchParams.get('displayName') && !isPathAlreadyTaken(req.url) ) { ltiURL.searchParams.append('displayName', req.user.displayName); res.redirect(ltiURL); } else return next(); } else res.redirect(`https://${req.hostname}${req.url}`); }); // Serve all files in the public folder as static files. app.use(express.static('public')); app.use((req, res) => res.sendFile(`${__dirname}/public/index.html`)); if (config.httpOnly === true) { // http mainListener = http.createServer(app); } else { // https mainListener = spdy.createServer(tls, app); // http const redirectListener = http.createServer(app); if (config.listeningHost) redirectListener.listen(config.listeningRedirectPort, config.listeningHost); else redirectListener.listen(config.listeningRedirectPort); } // https or http if (config.listeningHost) mainListener.listen(config.listeningPort, config.listeningHost); else mainListener.listen(config.listeningPort); } function isPathAlreadyTaken(url) { const alreadyTakenPath = [ '/config/', '/static/', '/images/', '/sounds/', '/favicon.', '/auth/' ]; alreadyTakenPath.forEach((path) => { if (url.toString().startsWith(path)) return true; }); return false; } /** * Create a WebSocketServer to allow WebSocket connections from browsers. */ async function runWebSocketServer() { io = require('socket.io')(mainListener); io.use( sharedSession(session, { autoSave : true }) ); // Handle connections from clients. io.on('connection', (socket) => { const { roomId, peerId } = socket.handshake.query; if (!roomId || !peerId) { logger.warn('connection request without roomId and/or peerId'); socket.disconnect(true); return; } logger.info( 'connection request [roomId:"%s", peerId:"%s"]', roomId, peerId); queue.push(async () => { const { token } = socket.handshake.session; const room = await getOrCreateRoom({ roomId }); let peer = peers.get(peerId); let returning = false; if (peer && !token) { // Don't allow hijacking sessions socket.disconnect(true); return; } else if (token && room.verifyPeer({ id: peerId, token })) { // Returning user, remove if old peer exists if (peer) peer.close(); returning = true; } peer = new Peer({ id: peerId, roomId, socket }); peers.set(peerId, peer); peer.on('close', () => { peers.delete(peerId); statusLog(); }); if ( Boolean(socket.handshake.session.passport) && Boolean(socket.handshake.session.passport.user) ) { const { id, displayName, picture, email, _userinfo } = socket.handshake.session.passport.user; peer.authId = id; peer.displayName = displayName; peer.picture = picture; peer.email = email; peer.authenticated = true; if (typeof config.userMapping === 'function') { await config.userMapping({ peer, roomId, userinfo: _userinfo }); } } room.handlePeer({ peer, returning }); statusLog(); }) .catch((error) => { logger.error('room creation or room joining failed [error:"%o"]', error); socket.disconnect(true); return; }); }); } /** * 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); } } /** * Get next mediasoup Worker. */ function getMediasoupWorker() { const worker = mediasoupWorkers[nextMediasoupWorkerIdx]; if (++nextMediasoupWorkerIdx === mediasoupWorkers.length) nextMediasoupWorkerIdx = 0; return worker; } /** * Get a Room instance (or create one if it does not exist). */ async function getOrCreateRoom({ roomId }) { let room = rooms.get(roomId); // If the Room does not exist create a new one. if (!room) { logger.info('creating a new Room [roomId:"%s"]', roomId); // const mediasoupWorker = getMediasoupWorker(); room = await Room.create({ mediasoupWorkers, roomId }); rooms.set(roomId, room); statusLog(); room.on('close', () => { rooms.delete(roomId); statusLog(); }); } return room; } run();