#!/usr/bin/env node process.title = 'edumeet-server'; import Logger from './lib/logger/Logger'; const Room = require('./lib/Room'); const Peer = require('./lib/Peer'); const userRoles = require('./lib/access/roles'); const { loginHelper, logoutHelper } = require('./lib/helpers/httpHelper'); const { config, configError } = require('./lib/config/config'); const interactiveServer = require('./lib/interactive/Server'); const promExporter = require('./lib/stats/promExporter'); const bcrypt = require('bcrypt'); const fs = require('fs'); const http = require('http'); const https = require('https'); if (process.versions.node.split('.')[0] < 15) { /* eslint-disable no-unused-vars */ 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 base64 = require('base-64'); const helmet = require('helmet'); // auth const passport = require('passport'); const LTIStrategy = require('passport-lti'); const imsLti = require('ims-lti'); const SAMLStrategy = require('passport-saml').Strategy; const LocalStrategy = require('passport-local').Strategy; const redis = require('redis'); const { Issuer, Strategy, custom } = require('openid-client'); const expressSession = require('express-session'); const RedisStore = require('connect-redis')(expressSession); const sharedSession = require('express-socket.io-session'); const { v4: uuidv4 } = require('uuid'); if (configError) { /* eslint-disable no-console */ console.error(`Invalid config file: ${configError}`); process.exit(-1); } const redisClient = redis.createClient(config.redisOptions); /* 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 Map. const mediasoupWorkers = new Map(); // 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()); const sharedCookieParser = cookieParser(); app.use(sharedCookieParser); app.use(bodyParser.json({ limit: '5mb' })); app.use(bodyParser.urlencoded({ limit: '5mb', 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; let samlStrategy; let localStrategy; async function run() { try { // Open the interactive server. await interactiveServer(rooms, peers); 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(); // start Prometheus exporter if (config.prometheus.enabled) { await promExporter(mediasoupWorkers, rooms, peers); } // Run WebSocketServer. 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); }; // eslint-disable-next-line no-unused-vars app.use(errorHandler); } catch (error) { logger.error('run() [error:"%o"]', error); } } function statusLog() { if (statusLogger) { statusLogger.log({ rooms : rooms, peers : peers }); } } 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 setupSAML() { samlStrategy = new SAMLStrategy( config.auth.saml, function(profile, done) { return done(null, { id : profile.uid, _userinfo : profile }); } ); passport.use('saml', samlStrategy); } function setupLocal() { localStrategy = new LocalStrategy( function(username, plaintextPassword, done) { const found = config.auth.local.users.find((element) => { // TODO use encrypted password return element.username === username && bcrypt.compareSync(plaintextPassword, element.passwordHash); }); if (found === undefined) return done(null, null); else { const userinfo = { ...found }; delete userinfo.password; return done(null, { id: found.id, _userinfo: userinfo }); } } ); passport.use('local', localStrategy); } 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) !== 'undefined' && ( ( typeof (config.auth.strategy) !== 'undefined' && config.auth.strategy === 'oidc' ) // it is default strategy || typeof (config.auth.strategy) === 'undefined' ) && typeof (config.auth.oidc) !== 'undefined' && typeof (config.auth.oidc.issuerURL) !== 'undefined' && typeof (config.auth.oidc.clientOptions) !== 'undefined' ) { if (config.auth.oidc.HttpOptions) { // Set http options to allow e.g. http proxy // TODO check with Misi what this is about custom.setHttpOptionsDefaults(config.auth.oidc.HttpOptions); } const oidcIssuer = await Issuer.discover(config.auth.oidc.issuerURL); // Setup authentication setupOIDC(oidcIssuer); } // SAML if ( typeof (config.auth) !== 'undefined' && typeof (config.auth.strategy) !== 'undefined' && config.auth.strategy === 'saml' && typeof (config.auth.saml) !== 'undefined' && typeof (config.auth.saml.entryPoint) !== 'undefined' && typeof (config.auth.saml.issuer) !== 'undefined' && typeof (config.auth.saml.cert) !== 'undefined' ) { setupSAML(); } // Local if ( typeof (config.auth) !== 'undefined' && typeof (config.auth.strategy) !== 'undefined' && config.auth.strategy === 'local' && typeof (config.auth.local) !== 'undefined' && typeof (config.auth.local.users) !== 'undefined' ) { setupLocal(); } app.use(passport.initialize()); app.use(passport.session()); // Auth strategy (by default oidc) const authStrategy = (config.auth && config.auth.strategy) ? config.auth.strategy : 'oidc'; // loginparams app.get('/auth/login', (req, res, next) => { logger.debug('/auth/login'); let state; if (req.query.peerId && req.query.roomId) { state = { peerId : req.query.peerId, roomId : req.query.roomId }; if (authStrategy== 'saml' || authStrategy=='local') { req.session.authState=state; } } if (authStrategy === 'local' && !(req.user && req.password)) { res.redirect('/login_dialog'); } else { passport.authenticate(authStrategy, { state : base64.encode(JSON.stringify(state)) } )(req, res, next); } }); // lti launch app.post('/auth/lti', passport.authenticate('lti', { failureRedirect: '/' }), (req, res) => { logger.debug('/auth/lti'); res.redirect(`/${req.user.room}`); } ); app.get('/auth/check_login_status', (req, res) => { let loggedIn = false; if (Boolean(req.session.passport) && Boolean(req.session.passport.user)) { loggedIn = true; } res.send({ loggedIn: loggedIn }); }); // logout app.get('/auth/logout', (req, res) => { logger.debug('/auth/logout'); const { peerId } = req.session; const peer = peers.get(peerId); if (peer) { for (const role of peer.roles) { if (role.id !== userRoles.NORMAL.id) peer.removeRole(role); } } req.logout(); req.session.passport = undefined; req.session.touch(); req.session.save(); req.session.destroy(() => res.send(logoutHelper())); }); // SAML metadata app.get('/auth/metadata', (req, res) => { logger.debug('/auth/metadata'); if (config.auth && config.auth.saml && config.auth.saml.decryptionCert && config.auth.saml.signingCert) { const metadata = samlStrategy.generateServiceProviderMetadata( config.auth.saml.decryptionCert, config.auth.saml.signingCert ); if (metadata) { res.set('Content-Type', 'text/xml'); res.send(metadata); } else { res.status('Error generating SAML metadata', 500); } } else res.status('Missing SAML decryptionCert or signingKey from config', 500); }); // callback app.all( '/auth/callback', passport.authenticate(authStrategy, { failureRedirect: '/auth/login' }), async (req, res, next) => { logger.debug('/auth/callback'); try { let state; if (authStrategy == 'saml' || authStrategy == 'local') state=req.session.authState; else { if (req.method === 'GET') state = JSON.parse(base64.decode(req.query.state)); if (req.method === 'POST') state = JSON.parse(base64.decode(req.body.state)); } if (!state || !state.peerId || !state.roomId) { res.redirect('/auth/login'); logger.debug('Empty state or state.peerId or state.roomId in auth/callback'); } const { peerId, roomId } = state; req.session.peerId = peerId; req.session.roomId = roomId; let peer = peers.get(peerId); const room = rooms.get(roomId); 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, room, roomId, userinfo : req.user._userinfo }); } peer.authenticated = true; res.send(loginHelper({ displayName : peer.displayName, picture : peer.picture })); } catch (error) { return next(error); } } ); } async function runHttpsServer() { app.use(compression()); app.use('/.well-known/acme-challenge', express.static('dist/public/.well-known/acme-challenge')); app.all('*', async (req, res, next) => { if (req.secure || config.httpOnly) { let ltiURL; try { ltiURL = new URL(`${req.protocol}://${req.get('host')}${req.originalUrl}`); } catch (error) { logger.error('Error parsing LTI url: %o', error); } 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('dist/public', { maxAge : config.staticFilesCachePeriod })); app.use((req, res) => res.sendFile(`${__dirname}/public/index.html`, { maxAge : config.staticFilesCachePeriod })); if (config.httpOnly === true) { // http mainListener = http.createServer(app); } else { // https // spdy is not working anymore with node.js > 15 and express 5 // is not ready yet for http2 // https://github.com/spdy-http2/node-spdy/issues/380 if (typeof(spdy) === 'undefined') { logger.info('Found node.js version >= 15 disabling spdy / http2 and using node.js/https module'); mainListener = https.createServer(tls, app); } else { /* eslint-disable no-undef */ mainListener = spdy.createServer(tls, app); } // http -> https redirect server if (config.listeningRedirectPort) { 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(actualUrl) { const alreadyTakenPath = [ '/config/', '/static/', '/images/', '/sounds/', '/favicon.', '/auth/' ]; alreadyTakenPath.forEach((path) => { if (actualUrl.toString().startsWith(path)) return true; }); return false; } /** * Create a WebSocketServer to allow WebSocket connections from browsers. */ async function runWebSocketServer() { io = require('socket.io')(mainListener, { cookie: false }); io.use( sharedSession(session, sharedCookieParser, {}) ); // 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 room = await getOrCreateRoom({ roomId }); let token = null; if (socket.handshake.session.peerId === peerId) { token = room.getToken(peerId); } 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, room, roomId, userinfo: _userinfo }); } } room.handlePeer({ peer, returning }); socket.handshake.session.peerId = peer.id; socket.handshake.session.touch(); socket.handshake.session.save(); statusLog(); }) .catch((error) => { logger.error('room creation or room joining failed [error:"%o"]', error); if (socket) socket.disconnect(true); return; }); }); } /** * Launch as many mediasoup Workers as given in the configuration file. */ async function runMediasoupWorkers() { mediasoup.observer.on('newworker', (worker) => { worker.appData.routers = new Map(); worker.appData.transports = new Map(); worker.appData.producers = new Map(); worker.appData.consumers = new Map(); worker.appData.dataProducers = new Map(); worker.appData.dataConsumers = new Map(); worker.observer.on('close', () => { // not needed as we have 'died' listiner below logger.debug('worker closed [worker.pid:%d]', worker.pid); }); worker.observer.on('newrouter', (router) => { router.appData.transports = new Map(); router.appData.producers = new Map(); router.appData.consumers = new Map(); router.appData.dataProducers = new Map(); router.appData.dataConsumers = new Map(); router.appData.worker = worker; worker.appData.routers.set(router.id, router); router.observer.on('close', () => { worker.appData.routers.delete(router.id); }); router.observer.on('newtransport', (transport) => { transport.appData.producers = new Map(); transport.appData.consumers = new Map(); transport.appData.dataProducers = new Map(); transport.appData.dataConsumers = new Map(); transport.appData.router = router; router.appData.transports.set(transport.id, transport); transport.observer.on('close', () => { router.appData.transports.delete(transport.id); }); transport.observer.on('newproducer', (producer) => { producer.appData.transport = transport; transport.appData.producers.set(producer.id, producer); router.appData.producers.set(producer.id, producer); worker.appData.producers.set(producer.id, producer); producer.observer.on('close', () => { transport.appData.producers.delete(producer.id); router.appData.producers.delete(producer.id); worker.appData.producers.delete(producer.id); }); }); transport.observer.on('newconsumer', (consumer) => { consumer.appData.transport = transport; transport.appData.consumers.set(consumer.id, consumer); router.appData.consumers.set(consumer.id, consumer); worker.appData.consumers.set(consumer.id, consumer); consumer.observer.on('close', () => { transport.appData.consumers.delete(consumer.id); router.appData.consumers.delete(consumer.id); worker.appData.consumers.delete(consumer.id); }); }); transport.observer.on('newdataproducer', (dataProducer) => { dataProducer.appData.transport = transport; transport.appData.dataProducers.set(dataProducer.id, dataProducer); router.appData.dataProducers.set(dataProducer.id, dataProducer); worker.appData.dataProducers.set(dataProducer.id, dataProducer); dataProducer.observer.on('close', () => { transport.appData.dataProducers.delete(dataProducer.id); router.appData.dataProducers.delete(dataProducer.id); worker.appData.dataProducers.delete(dataProducer.id); }); }); transport.observer.on('newdataconsumer', (dataConsumer) => { dataConsumer.appData.transport = transport; transport.appData.dataConsumers.set(dataConsumer.id, dataConsumer); router.appData.dataConsumers.set(dataConsumer.id, dataConsumer); worker.appData.dataConsumers.set(dataConsumer.id, dataConsumer); dataConsumer.observer.on('close', () => { transport.appData.dataConsumers.delete(dataConsumer.id); router.appData.dataConsumers.delete(dataConsumer.id); worker.appData.dataConsumers.delete(dataConsumer.id); }); }); }); }); }); const { numWorkers } = config.mediasoup; logger.info('running %d mediasoup Workers...', numWorkers); const { logLevel, logTags, rtcMinPort, rtcMaxPort } = config.mediasoup.worker; const portInterval = Math.floor((rtcMaxPort - rtcMinPort) / numWorkers); for (let i = 0; i < numWorkers; i++) { const worker = await mediasoup.createWorker( { logLevel, logTags, rtcMinPort : rtcMinPort + (i * portInterval), rtcMaxPort : i === numWorkers - 1 ? rtcMaxPort : rtcMinPort + ((i + 1) * portInterval) - 1 }); worker.on('died', () => { logger.error( 'mediasoup Worker died, exiting in 2 seconds... [pid:%d]', worker.pid); setTimeout(() => process.exit(1), 2000); }); mediasoupWorkers.set(worker.pid, 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, peers }); rooms.set(roomId, room); statusLog(); room.on('close', () => { rooms.delete(roomId); statusLog(); }); } return room; } run();