UNPKG

janus-whip-server

Version:
757 lines (726 loc) 23.2 kB
'use strict'; /* * Simple WHIP server * * Author: Lorenzo Miniero <lorenzo@meetecho.com> * License: GPLv3 * * WHIP API and endpoint management * */ // Dependencies import express from 'express'; import cors from 'cors'; import fs from 'fs'; import http from 'http'; import https from 'https'; import Janode from 'janode'; import VideoRoomPlugin from 'janode/plugins/videoroom'; import { EventEmitter } from 'events'; // WHIP server class class JanusWhipServer extends EventEmitter { // Constructor constructor({ janus, rest, allowTrickle = true, strictETags = false, iceServers = [], debug }) { super(); // Parse configuration if(!janus || typeof janus !== 'object') throw new Error('Invalid configuration, missing parameter "janus" or not an object'); if(!janus.address) throw new Error('Invalid configuration, missing parameter "address" in "janus"'); if(!rest || typeof rest !== 'object') throw new Error('Invalid configuration, missing parameter "rest" or not an object'); if(!rest.basePath) throw new Error('Invalid configuration, missing parameter "basePath" in "rest"'); if(!rest.port && !rest.app) throw new Error('Invalid configuration, at least one of "port" and "app" should be set in "rest"'); const debugLevels = [ 'err', 'warn', 'info', 'verb', 'debug' ]; if(debug && debugLevels.indexOf(debug) === -1) throw new Error('Invalid configuration, unsupported "debug" level'); this.config = { janus: { address: janus.address }, rest: { port: rest.port, basePath: rest.basePath, app: rest.app }, allowTrickle: (allowTrickle === true), strictETags: (strictETags === true), iceServers: Array.isArray(iceServers) ? iceServers : [iceServers] }; // Resources this.janus = null; this.endpoints = new Map(); this.resources = new Map(); this.logger = new JanusWhipLogger({ prefix: '[WHIP] ', level: debug ? debugLevels.indexOf(debug) : 2 }); } async start() { if(this.started) throw new Error('WHIP server already started'); // Connect to Janus await this._connectToJanus(); // WHIP REST API if(!this.config.rest.app) { // Spawn a new app and server this.logger.verb('Spawning new Express app'); let app = express(); this._setupRest(app); let options = null; let useHttps = (this.config.rest.https && this.config.rest.https.cert && this.config.rest.https.key); if(useHttps) { options = { cert: fs.readFileSync(this.config.rest.https.cert, 'utf8'), key: fs.readFileSync(this.config.rest.https.key, 'utf8'), passphrase: this.config.rest.https.passphrase }; } this.server = await (useHttps ? https : http).createServer(options, app); await this.server.listen(this.config.rest.port); } else { // A server already exists, only add our endpoints to its router this.logger.verb('Reusing existing Express app'); this._setupRest(this.config.rest.app); } // We're up and running this.logger.info('WHIP server started'); this.started = true; return this; } async destroy() { if(!this.started) throw new Error('WHIP server not started'); if(this.janus) await this.janus.close(); if(this.server) this.server.close(); } generateRandomString(len) { const charSet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; let randomString = ''; for(let i=0; i<len; i++) { let randomPoz = Math.floor(Math.random() * charSet.length); randomString += charSet.substring(randomPoz,randomPoz+1); } return randomString; } createEndpoint({ id, room, secret, adminKey, pin, label, token, iceServers, recipient }) { if(!id || !room) throw new Error('Invalid arguments'); if(this.endpoints.has(id)) throw new Error('Endpoint already exists'); let endpoint = new JanusWhipEndpoint({ id: id, room: room, secret: secret, adminKey: adminKey, pin: pin, label: label ? label : 'WHIP Publisher ' + room, token: token, iceServers: iceServers, recipient: recipient }); this.logger.info('[' + id + '] Created new WHIP endpoint'); this.endpoints.set(id, endpoint); return endpoint; } listEndpoints() { let list = []; this.endpoints.forEach(function(endpoint, id) { list.push({ id: id, enabled: endpoint.enabled }); }); return list; } getEndpoint({ id }) { return this.endpoints.get(id); } async destroyEndpoint({ id }) { let endpoint = this.endpoints.get(id); if(!id || !endpoint) throw new Error('Invalid endpoint ID'); // Get rid of the Janus publisher, if there's one active if(this.janus && endpoint.handle) await endpoint.handle.detach().catch(_err => {}); if(endpoint.resourceId) delete this.resources[endpoint.resourceId]; delete endpoint.resourceId; this.endpoints.delete(id); this.logger.info('[' + id + '] Destroyed WHIP endpoint'); } // Janus setup async _connectToJanus() { const connection = await Janode.connect({ is_admin: false, address: { url: this.config.janus.address, }, retry_time_secs: 3, max_retries: Number.MAX_VALUE }); connection.once(Janode.EVENT.CONNECTION_ERROR, () => { this.logger.warn('Lost connectivity to Janus, reset the manager and try reconnecting'); // Teardown existing endpoints this.endpoints.forEach(function(endpoint, id) { endpoint.enabling = false; endpoint.enabled = false; delete endpoint.handle; delete endpoint.publisher; delete endpoint.sdpOffer; delete endpoint.ice; if(endpoint.resourceId) this.resources.delete(endpoint.resourceId); delete endpoint.resourceId; delete endpoint.resource; delete endpoint.latestEtag; this.logger.info('[' + id + '] Terminating WHIP session'); endpoint.emit('endpoint-inactive'); endpoint.emit('janus-disconnected'); this.emit('endpoint-inactive', id); }, this); this.emit('janus-disconnected'); // Reconnect this.janus = null; setTimeout(this._connectToJanus.bind(this), 1); }); this.janus = await connection.create(); this.logger.info('Connected to Janus:', this.config.janus.address); if(this.started) this.emit('janus-reconnected'); } // REST server setup _setupRest(app) { const router = express.Router(); // Just a helper to make sure this API is up and running router.get('/healthcheck', (_req, res) => { this.logger.debug('/healthcheck'); res.sendStatus(200); }); // OPTIONS associated with publishing to a WHIP endpoint router.options('/endpoint/:id', (req, res) => { // Prepare CORS headers for preflight res.setHeader('Access-Control-Allow-Methods', 'GET,HEAD,PUT,PATCH,POST,DELETE'); res.setHeader('Vary', 'Access-Control-Request-Headers'); // Authenticate the request, and only return Link headers if valid let id = req.params.id; let endpoint = this.endpoints.get(id); if(!id || !endpoint) { res.sendStatus(204); return; } if(endpoint.enabled) { res.sendStatus(204); return; } // Check the Bearer token let auth = req.headers['authorization']; if(endpoint.token) { if(!auth || auth.indexOf('Bearer ') < 0) { res.sendStatus(204); return; } let authtoken = auth.split('Bearer ')[1]; if(typeof endpoint.token === 'function') { if(!endpoint.token(authtoken)) { res.sendStatus(204); return; } } else if(!authtoken || authtoken.length === 0 || authtoken !== endpoint.token) { res.sendStatus(204); return; } } // Done let iceServers = endpoint.iceServers ? endpoint.iceServers : this.config.iceServers; if(iceServers && iceServers.length > 0) { // Add a Link header for each static ICE server res.setHeader('Access-Control-Expose-Headers', 'Link'); res.setHeader('Access-Post', 'application/sdp'); let links = []; for(let server of iceServers) { if(!server.uri || (server.uri.indexOf('stun:') !== 0 && server.uri.indexOf('turn:') !== 0 && server.uri.indexOf('turns:') !== 0)) continue; let link = '<' + server.uri + '>; rel="ice-server"'; if(server.username && server.credential) { link += ';'; link += ' username="' + server.username + '";' + ' credential="' + server.credential + '";' + ' credential-type="password"'; } links.push(link); } res.setHeader('Link', links); } res.sendStatus(204); }); // Publish to a WHIP endpoint router.post('/endpoint/:id', async (req, res) => { let id = req.params.id; let endpoint = this.endpoints.get(id); if(!id || !endpoint) { res.status(404); res.send('Invalid endpoint ID'); return; } if(endpoint.enabling || endpoint.enabled) { res.status(403); res.send('Endpoint ID already in use'); return; } this.logger.verb('/endpoint/:', id); this.logger.debug(req.body); // Make sure we received an SDP if(req.headers['content-type'] !== 'application/sdp' || req.body.indexOf('v=0') < 0) { res.status(406); res.send('Unsupported content type'); return; } // Check the Bearer token let auth = req.headers['authorization']; if(endpoint.token) { if(!auth || auth.indexOf('Bearer ') < 0) { res.status(403); res.send('Unauthorized'); return; } let authtoken = auth.split('Bearer ')[1]; if(typeof endpoint.token === 'function') { if(!endpoint.token(authtoken)) { res.status(403); res.send('Unauthorized'); return; } } else if(!authtoken || authtoken.length === 0 || authtoken !== endpoint.token) { res.status(403); res.send('Unauthorized'); return; } } // Make sure Janus is up and running if(!this.janus) { res.status(503); res.send('Janus unavailable'); return; } // Create a new session this.logger.info('[' + id + '] Publishing to WHIP endpoint'); endpoint.enabling = true; try { // Create a random ID for the resource path let rid = this.generateRandomString(16); while(this.resources.has(rid)) rid = this.generateRandomString(16); this.resources.set(rid, id); endpoint.resourceId = rid; endpoint.resource = this.config.rest.basePath + '/resource/' + rid; endpoint.latestEtag = this.generateRandomString(16); // Take note of SDP and ICE credentials endpoint.sdpOffer = req.body; endpoint.ice = { ufrag: endpoint.sdpOffer.match(/a=ice-ufrag:(.*)\r\n/)[1], pwd: endpoint.sdpOffer.match(/a=ice-pwd:(.*)\r\n/)[1] }; // Connect to the VideoRoom plugin endpoint.handle = await this.janus.attach(VideoRoomPlugin); endpoint.handle.on(Janode.EVENT.HANDLE_DETACHED, () => { // Janus notified us the session is gone, tear it down let endpoint = this.endpoints.get(id); if(endpoint) { this.logger.info('[' + id + '] PeerConnection detected as closed'); endpoint.enabling = false; endpoint.enabled = false; delete endpoint.handle; delete endpoint.publisher; delete endpoint.sdpOffer; delete endpoint.ice; if(endpoint.resourceId) this.resources.delete(endpoint.resourceId); delete endpoint.resourceId; delete endpoint.resource; delete endpoint.latestEtag; } }); endpoint.publisher = await endpoint.handle.joinConfigurePublisher({ room: endpoint.room, pin: endpoint.pin, display: endpoint.label, audio: true, video: true, jsep: { type: 'offer', sdp: req.body } }); if(endpoint.recipient && endpoint.recipient.host && (endpoint.recipient.audioPort > 0 || endpoint.recipient.videoPort > 0)) { // Configure an RTP forwarder too const max32 = Math.pow(2, 32) - 1; let details = { room: endpoint.room, feed: endpoint.publisher.feed, secret: endpoint.secret, admin_key: endpoint.adminKey, host: endpoint.recipient.host, audio_port: endpoint.recipient.audioPort, audio_ssrc: Math.floor(Math.random() * max32), video_port: endpoint.recipient.videoPort, video_ssrc: Math.floor(Math.random() * max32), video_rtcp_port: endpoint.recipient.videoRtcpPort }; await endpoint.handle.startForward(details); } endpoint.enabling = false; endpoint.enabled = true; // Done res.setHeader('Access-Control-Expose-Headers', 'Location, Link'); res.setHeader('Accept-Patch', 'application/trickle-ice-sdpfrag'); res.setHeader('Location', endpoint.resource); res.set('ETag', '"' + endpoint.latestEtag + '"'); let iceServers = endpoint.iceServers ? endpoint.iceServers : this.config.iceServers; if(iceServers && iceServers.length > 0) { // Add a Link header for each static ICE server let links = []; for(let server of iceServers) { if(!server.uri || (server.uri.indexOf('stun:') !== 0 && server.uri.indexOf('turn:') !== 0 && server.uri.indexOf('turns:') !== 0)) continue; let link = '<' + server.uri + '>; rel="ice-server"'; if(server.username && server.credential) { link += ';'; link += ' username="' + server.username + '";' + ' credential="' + server.credential + '";' + ' credential-type="password"'; } links.push(link); } res.setHeader('Link', links); } res.writeHeader(201, { 'Content-Type': 'application/sdp' }); res.write(endpoint.publisher.jsep.sdp); res.end(); endpoint.emit('endpoint-active'); this.emit('endpoint-active', id); } catch(err) { this.logger.err('Error publishing:', err); endpoint.enabling = false; endpoint.enabled = false; if(endpoint.handle) await endpoint.handle.detach(); delete endpoint.handle; delete endpoint.publisher; delete endpoint.sdpOffer; delete endpoint.ice; res.status(500); res.send(err.error); } }); // GET, HEAD and PUT on the endpoint must return a 405 router.get('/endpoint/:id', (_req, res) => { res.sendStatus(405); }); router.head('/endpoint/:id', (_req, res) => { res.sendStatus(405); }); router.put('/endpoint/:id', (_req, res) => { res.sendStatus(405); }); // Trickle a WHIP resource router.patch('/resource/:rid', async (req, res) => { if(!this.config.allowTrickle) { res.sendStatus(405); return; } let rid = req.params.rid; let id = this.resources.get(rid); if(!rid || !id) { res.status(404); res.send('Invalid resource ID'); return; } let endpoint = this.endpoints.get(id); if(!endpoint) { res.status(404); res.send('Invalid endpoint ID'); return; } if(endpoint.latestEtag) res.set('ETag', '"' + endpoint.latestEtag + '"'); this.logger.verb('/resource[trickle]/:', id); this.logger.debug(req.body); // Check the Bearer token let auth = req.headers['authorization']; if(endpoint.token) { if(!auth || auth.indexOf('Bearer ') < 0) { res.status(403); res.send('Unauthorized'); return; } let authtoken = auth.split('Bearer ')[1]; if(typeof endpoint.token === 'function') { if(!endpoint.token(authtoken)) { res.status(403); res.send('Unauthorized'); return; } } else if(!authtoken || authtoken.length === 0 || authtoken !== endpoint.token) { res.status(403); res.send('Unauthorized'); return; } } if(!endpoint.handle) { res.status(403); res.send('Endpoint ID not published'); return; } // Check the latest ETag if(req.headers['if-match'] !== '"*"' && req.headers['if-match'] !== ('"' + endpoint.latestEtag + '"')) { if(this.config.strictETags) { // Only return a failure if we're configured with strict ETag checking, ignore it otherwise res.status(412); res.send('Precondition Failed'); return; } } // Make sure Janus is up and running if(!this.janus) { res.status(503); res.send('Janus unavailable'); return; } // Make sure we received a trickle candidate if(req.headers['content-type'] !== 'application/trickle-ice-sdpfrag') { res.status(406); res.send('Unsupported content type'); return; } // Parse the RFC 8840 payload let fragment = req.body; let lines = fragment.split(/\r?\n/); let iceUfrag = null, icePwd = null, restart = false; let candidates = []; for(let line of lines) { if(line.indexOf('a=ice-ufrag:') === 0) { iceUfrag = line.split('a=ice-ufrag:')[1]; } else if(line.indexOf('a=ice-pwd:') === 0) { icePwd = line.split('a=ice-pwd:')[1]; } else if(line.indexOf('a=candidate:') === 0) { let candidate = { sdpMLineIndex: 0, candidate: line.split('a=')[1] }; candidates.push(candidate); } else if(line.indexOf('a=end-of-candidates') === 0) { // Signal there won't be any more candidates candidates.push({ completed: true }); } } // Check if there's a restart involved if(iceUfrag && icePwd && (iceUfrag !== endpoint.ice.ufrag || icePwd !== endpoint.ice.pwd)) { // We need to restart restart = true; } // Do one more ETag check (make sure restarts have '*' as ETag, and only them) if((req.headers['if-match'] === '*' && !restart) || (req.headers['if-match'] !== '"*"' && restart)) { if(this.config.strictETags) { // Only return a failure if we're configured with strict ETag checking, ignore it otherwise res.status(412); res.send('Precondition Failed'); return; } } try { if(!restart) { // Trickle the candidate(s) if(candidates.length > 0) await endpoint.handle.trickle(candidates); // We're done res.sendStatus(204); return; } // If we got here, we need to do an ICE restart, which we do // by generating a new fake offer and send it to Janus this.logger.info('[' + id + '] Performing ICE restart'); let oldUfrag = 'a=ice-ufrag:' + endpoint.ice.ufrag; let oldPwd = 'a=ice-pwd:' + endpoint.ice.pwd; let newUfrag = 'a=ice-ufrag:' + iceUfrag; let newPwd = 'a=ice-pwd:' + icePwd; endpoint.sdpOffer = endpoint.sdpOffer .replace(new RegExp(oldUfrag, 'g'), newUfrag) .replace(new RegExp(oldPwd, 'g'), newPwd); endpoint.ice.ufrag = iceUfrag; endpoint.ice.pwd = icePwd; // Generate a new ETag too endpoint.latestEtag = this.generateRandomString(16); this.logger.verb('New ETag: ' + endpoint.latestEtag); // Send the new offer const result = await endpoint.handle.configure({ jsep: { type: 'offer', sdp: endpoint.sdpOffer } }); // Now that we have a response, trickle the candidates we received if(candidates.length > 0 && this.janus) await endpoint.handle.trickle(candidates); // Read the ICE credentials and send them back let sdpAnswer = result.jsep.sdp; let serverUfrag = sdpAnswer.match(/a=ice-ufrag:(.*)\r\n/)[1]; let serverPwd = sdpAnswer.match(/a=ice-pwd:(.*)\r\n/)[1]; let payload = 'a=ice-ufrag:' + serverUfrag + '\r\n' + 'a=ice-pwd:' + serverPwd + '\r\n'; res.set('ETag', '"' + endpoint.latestEtag + '"'); res.writeHeader(200, { 'Content-Type': 'application/trickle-ice-sdpfrag' }); res.write(payload); res.end(); } catch(err) { this.logger.err('Error patching:', err); res.status(500); res.send(err.error); } }); // Stop publishing to a WHIP endpoint router.delete('/resource/:rid', async (req, res) => { let rid = req.params.rid; let id = this.resources.get(rid); if(!rid || !id) { res.status(404); res.send('Invalid resource ID'); return; } let endpoint = this.endpoints.get(id); if(!endpoint) { res.status(404); res.send('Invalid endpoint ID'); return; } // Check the Bearer token let auth = req.headers['authorization']; if(endpoint.token) { if(!auth || auth.indexOf('Bearer ') < 0) { res.status(403); res.send('Unauthorized'); return; } let authtoken = auth.split('Bearer ')[1]; if(typeof endpoint.token === 'function') { if(!endpoint.token(authtoken)) { res.status(403); res.send('Unauthorized'); return; } } else if(!authtoken || authtoken.length === 0 || authtoken !== endpoint.token) { res.status(403); res.send('Unauthorized'); return; } } this.logger.verb('/resource/:', id); // Get rid of the Janus publisher if(this.janus && endpoint.handle) await endpoint.handle.detach().catch(_err => {}); endpoint.enabled = false; endpoint.enabling = false; delete endpoint.handle; delete endpoint.publisher; delete endpoint.sdpOffer; delete endpoint.ice; if(endpoint.resourceId) this.resources.delete(endpoint.resourceId); delete endpoint.resourceId; delete endpoint.resource; delete endpoint.latestEtag; this.logger.info('[' + id + '] Terminating WHIP session'); endpoint.emit('endpoint-inactive'); this.emit('endpoint-inactive', id); // Done res.sendStatus(200); }); // GET, HEAD, POST and PUT on the resource must return a 405 router.get('/resource/:rid', (_req, res) => { res.sendStatus(405); }); router.head('/resource/:rid', (_req, res) => { res.sendStatus(405); }); router.post('/resource/:rid', (_req, res) => { res.sendStatus(405); }); router.put('/resource/:rid', (_req, res) => { res.sendStatus(405); }); // Setup CORS app.use(cors({ preflightContinue: true })); // Initialize the REST API app.use(express.json()); app.use(express.text({ type: 'application/sdp' })); app.use(express.text({ type: 'application/trickle-ice-sdpfrag' })); app.use(this.config.rest.basePath, router); } } // WHIP endpoint class class JanusWhipEndpoint extends EventEmitter { constructor({ id, room, secret, adminKey, pin, label, token, iceServers, recipient }) { super(); this.id = id; this.room = room; this.secret = secret; this.adminKey = adminKey; this.pin = pin; this.label = label; this.token = token; this.iceServers = iceServers; this.recipient = recipient; this.enabled = false; } } // Logger class const debugLevels = [ 'err', 'warn', 'info', 'verb', 'debug' ]; class JanusWhipLogger { constructor({ prefix, level }) { this.prefix = prefix; this.debugLevel = level; } err() { if(this.debugLevel < 0) return; let args = Array.prototype.slice.call(arguments); args.unshift(this.prefix + '[err]'); console.log.apply(console, args); } warn() { if(this.debugLevel < 1) return; let args = Array.prototype.slice.call(arguments); args.unshift(this.prefix + '[warn]'); console.log.apply(console, args); } info() { if(this.debugLevel < 2) return; let args = Array.prototype.slice.call(arguments); args.unshift(this.prefix + '[info]'); console.log.apply(console, args); } verb() { if(this.debugLevel < 3) return; let args = Array.prototype.slice.call(arguments); args.unshift(this.prefix + '[verb]'); console.log.apply(console, args); } debug() { if(this.debugLevel < 4) return; let args = Array.prototype.slice.call(arguments); args.unshift(this.prefix + '[debug]'); console.log.apply(console, args); } } // Exports export { JanusWhipServer, JanusWhipEndpoint };