From 03f2c21bb801f882c0c8d3db53c699cd9e317fcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20D=C3=B6ring?= <simon.doering@stud.hs-bochum.de> Date: Wed, 3 Feb 2021 00:56:17 +0100 Subject: [PATCH] Make camera server create/destroy its janus room --- README.md | 28 +++- camera-server/example-config.json | 7 +- camera-server/package-lock.json | 13 ++ camera-server/package.json | 1 + camera-server/src/config/config.ts | 14 +- camera-server/src/janus/janus-api.ts | 111 ++++++++++++++ camera-server/src/janus/janus-room.ts | 185 ++++++++++++++++++++++++ camera-server/src/server.ts | 23 ++- camera-server/src/util/cleanup.ts | 70 ++++----- camera-server/src/util/random-string.ts | 11 ++ 10 files changed, 415 insertions(+), 48 deletions(-) create mode 100644 camera-server/src/janus/janus-api.ts create mode 100644 camera-server/src/janus/janus-room.ts create mode 100644 camera-server/src/util/random-string.ts diff --git a/README.md b/README.md index d956102..c464599 100644 --- a/README.md +++ b/README.md @@ -20,13 +20,13 @@ with the values `value1` and `value2`. The following list explains the usage of the parameters: -* `room`: The number of the janus room. Defaults to `1000`. +* `room`: The number of the Janus room. Defaults to `1000`. * `slot`: The camera slot used in the room. Defaults to `0`. * `token`: The token required for authentication on the camera server (see below). Defaults to an empty string, which will yield to the user not being able to transmit his camera feed. -* `pin` *optional*: The pin for the janus room. If the janus room has no pin, provide the value `none`. If this parameter is not provided, an input field for the pin is shown. +* `pin` *optional*: The pin for the Janus room. If the Janus room has no pin, provide the value `none`. If this parameter is not provided, an input field for the pin is shown. * `customNameAllowed` *optional*: If this parameter is present (even when holding no value), an input field for a custom name is shown. If a value is provided for this field, it will be used as initial value of the input field. The user can also update his name after starting a transmission. The names are escaped on the server to prevent Cross-Site-Scripting (XSS) attacks. @@ -34,7 +34,10 @@ The following list explains the usage of the parameters: CVH-Camera uses a Nodejs server with the socket.io library to send events regarding the camera feeds out to the receivers in realtime. The socket connection is also used to verify feeds that are published in the Janus rooms and only send those to the receivers. This way no unwanted feeds can be shown to the receivers while still having a public password for the Janus room itself. -One instance of the camera server is meant to manage one Janus room. It does this by defining slots for cameras which are all disabled by default. To activate a slot, one has to provide a token for that slot. This token will be required to be able to send a feed on that slot. As mentioned above, only feeds that are verified in that way are shown to the receivers. +One instance of the camera server is meant to manage one Janus room. +This is done by creating a new Janus room on startup and destroying it on shutdown of the server. + +The room is managed by defining slots for cameras which are all disabled by default. To activate a slot, one has to provide a token for that slot. This token will be required to be able to send a feed on that slot. As mentioned above, only feeds that are verified in that way are shown to the receivers. One can also refresh the token for a given slot or simply deactivate it. ## Compiling and Running the Server @@ -63,6 +66,23 @@ Below is a description of the config file's properties: If this property is emitted or an empty string is provided, the notify feature will not be used. +* `janusURL`: The url of the janus server. Defaults to `http://localhost:8088/janus`. + Note that by default `/janus` has to be appended to the url. + +* `janusRoom`: The janus room which will be used. Make sure that the room is unique + and not used for anything else, as the server will destroy it on startup to create + a new room. Defaults to `1000`. + +* `janusRoomSecret`: The secret used by the janus room. This is required to make + severe request regarding the room like destroying it. Make sure that this is a long + arbitrary string. Defaults to `default`. + +* `janusRoomPin`: The pin required to join the room as a viewer or sender. Defaults to + no pin (empty string). + +* `janusBitrate`: The default bitrate with which a camera feed is transmitted by Janus. + Defaults to `128000`. + ## Stdin-Interface The camera server is controlled by PULT via its stdin. One could also implement the interface in any other program to manage the CVH-Camera. @@ -144,6 +164,6 @@ Server responses to request will always include the following fields: In order to authenticate itself, the sender has to provide a slot and a token by emitting a `sender_init` event. These values are provided through the query string of the sender web page. When the server receives the `sender_init` event it validates the slot and the token and sends a response. -After a successful initialisation the connectin to janus is established. When the camera is shared, the id of the transmitted Janus feed is determined and then sent to the server using the `set_feed_id` event. On the server the corresponding slot will save that feed id. This will then be used to tell all receivers which feed id to attach to. +After a successful initialisation the connectin to Janus is established. When the camera is shared, the id of the transmitted Janus feed is determined and then sent to the server using the `set_feed_id` event. On the server the corresponding slot will save that feed id. This will then be used to tell all receivers which feed id to attach to. When the sender socket disconnects, an event will be emitted, notifying the receivers to remove the corresponding feed. diff --git a/camera-server/example-config.json b/camera-server/example-config.json index 21696d5..5bfb42b 100644 --- a/camera-server/example-config.json +++ b/camera-server/example-config.json @@ -1,5 +1,10 @@ { "port": 5000, "cameraSlots": 4, - "notifyPath": "./path-relative-to-config/or-absolute-path/camera-server-output" + "notifyPath": "./path-relative-to-config/or-absolute-path/camera-server-output", + "janusURL": "http://localhost:8088/janus", + "janusRoom": 1000, + "janusRoomSecret": "changeit", + "janusRoomPin": "abc123", + "janusBitrate": 128000 } diff --git a/camera-server/package-lock.json b/camera-server/package-lock.json index 6041ed6..216c600 100644 --- a/camera-server/package-lock.json +++ b/camera-server/package-lock.json @@ -62,6 +62,14 @@ "negotiator": "0.6.2" } }, + "axios": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz", + "integrity": "sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==", + "requires": { + "follow-redirects": "^1.10.0" + } + }, "base64-arraybuffer": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-0.1.4.tgz", @@ -121,6 +129,11 @@ "base64-arraybuffer": "0.1.4" } }, + "follow-redirects": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.13.2.tgz", + "integrity": "sha512-6mPTgLxYm3r6Bkkg0vNM0HTjfGrOEtsfbhagQvbxDEsEkpNhw582upBaoRZylzen6krEmxXJgt9Ju6HiI4O7BA==" + }, "mime-db": { "version": "1.44.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.44.0.tgz", diff --git a/camera-server/package.json b/camera-server/package.json index d3cc088..a8546ed 100644 --- a/camera-server/package.json +++ b/camera-server/package.json @@ -6,6 +6,7 @@ "build": "npx tsc" }, "dependencies": { + "axios": "^0.21.1", "socket.io": "^3.0.4" }, "devDependencies": { diff --git a/camera-server/src/config/config.ts b/camera-server/src/config/config.ts index 5863a51..f74834a 100644 --- a/camera-server/src/config/config.ts +++ b/camera-server/src/config/config.ts @@ -5,6 +5,11 @@ interface Config { port: number; cameraSlots: number; notifyPath: string; + janusURL: string; + janusRoom: number; + janusRoomSecret: string; + janusRoomPin: string; + janusBitrate: number; } // Required to access config with config[key] @@ -34,13 +39,18 @@ if (configPath) { const indexableConfig: IndexableConfig = { port: 5000, cameraSlots: 4, - notifyPath: '' + notifyPath: '', + janusURL: 'http://localhost:8088/janus', + janusRoom: 1000, + janusRoomSecret: 'default', + janusRoomPin: '', + janusBitrate: 128000 }; if (fileContent) { let readConfig: any; - // No need to process error because if-check below sanitises read config + // No need to process error because if-check below sanitizes read config try { readConfig = JSON.parse(fileContent); } catch (err) {} diff --git a/camera-server/src/janus/janus-api.ts b/camera-server/src/janus/janus-api.ts new file mode 100644 index 0000000..7ab08b0 --- /dev/null +++ b/camera-server/src/janus/janus-api.ts @@ -0,0 +1,111 @@ +// Janus API documentation: https://janus.conf.meetecho.com/docs/rest.html + +import axios, { AxiosRequestConfig, CancelToken } from 'axios'; + +import { randomString } from '../util/random-string'; +import { config } from '../config/config'; + +type JanusVerb = 'create' | 'destroy' | 'attach' | 'detach' | 'message'; + +interface RoomConfig { + room: number; + publishers: number; + bitrate: number; + secret?: string; + pin?: string; + description?: string; +} + +export const api = axios.create({ + baseURL: config.janusURL +}); + +export const postRequest = <T extends { janus: JanusVerb }>( + path: string, + body: T, + config: AxiosRequestConfig = {} +) => { + return api.post(path, { ...body, transaction: randomString(12) }, config); +}; + +export const sessionLongPoll = ( + sessionId: number, + cancelToken: CancelToken, + maxExents = 10 +) => { + return api.get(`/${sessionId}?rid=${Date.now()}&maxev=${maxExents}`, { + cancelToken + }); +}; + +export const createSession = () => { + return postRequest('/', { + janus: 'create' + }); +}; + +export const destroySession = (sessionId: number) => { + return postRequest(`/${sessionId}`, { + janus: 'destroy' + }); +}; + +export const attachVideoroomPlugin = (sessionId: number) => { + return postRequest(`/${sessionId}`, { + janus: 'attach', + plugin: 'janus.plugin.videoroom' + }); +}; + +export const detachVideoroomPlugin = ( + sessionId: number, + videoroomId: number +) => { + return postRequest(`/${sessionId}/${videoroomId}`, { + janus: 'detach' + }); +}; + +export const configureVideoroomBitrate = ( + sessionId: number, + videoroomId: number, + bitrate: number +) => { + return postRequest(`/${sessionId}/${videoroomId}`, { + janus: 'message', + body: { + request: 'configure', + bitrate + } + }); +}; + +export const createRoom = ( + sessionId: number, + videoroomId: number, + config: RoomConfig +) => { + return postRequest(`/${sessionId}/${videoroomId}`, { + janus: 'message', + body: { + request: 'create', + ...config + } + }); +}; + +export const destroyRoom = ( + sessionId: number, + videoroomId: number, + room: number, + secret: string +) => { + return postRequest(`/${sessionId}/${videoroomId}`, { + janus: 'message', + body: { + request: 'destroy', + room, + secret + } + }); +}; diff --git a/camera-server/src/janus/janus-room.ts b/camera-server/src/janus/janus-room.ts new file mode 100644 index 0000000..7012aa7 --- /dev/null +++ b/camera-server/src/janus/janus-room.ts @@ -0,0 +1,185 @@ +import axios from 'axios'; + +import * as janusAPI from './janus-api'; +import { AxiosResponse } from 'axios'; +import { config } from '../config/config'; + +class JanusRoom { + private sessionId: number; + private videoroomId: number; + private _sessionAlive = false; + private source = axios.CancelToken.source(); + + get sessionAlive() { + return this._sessionAlive; + } + + async init() { + await this.createSession(); + this.doSessionLongPoll(); + await this.attachVideoroomPlugin(); + await this.createRoom(); + } + + private async createSession() { + const { data } = await janusAPI.createSession(); + + if (data?.janus === 'success') { + this.sessionId = data.data.id; + this._sessionAlive = true; + console.log( + `Established session with janus server (session id: ${this.sessionId})` + ); + } else { + throw new Error( + `Could not create janus session. Server response: ${JSON.stringify( + data, + null, + 2 + )}` + ); + } + } + + private async doSessionLongPoll() { + console.log('Starting janus long polling to keep session alive'); + try { + let response: AxiosResponse; + do { + // Response data will be an array of events with maximum length of 10 + // If no events occured for 30s, the array will contain one keepalive event + response = await janusAPI.sessionLongPoll( + this.sessionId, + this.source.token + ); + } while (response.data.length >= 0); + } catch (err) { + if (axios.isCancel(err)) { + console.log( + 'Janus session long poll got canceled:', + err.message + ); + } else { + console.log( + 'Error: An unexpected error occured while performing janus long poll' + ); + } + } + console.log( + 'Warning: Janus session will timeout because long poll got no response' + ); + this._sessionAlive = false; + } + + private async attachVideoroomPlugin() { + const { data } = await janusAPI.attachVideoroomPlugin(this.sessionId); + + if (data?.janus === 'success') { + this.videoroomId = data.data.id; + console.log( + `Attached janus videoroom plugin (plugin id: ${this.videoroomId})` + ); + } else { + throw new Error( + `Could not attach janus videoroom plugin. Janus response: ${JSON.stringify( + data, + null, + 2 + )}` + ); + } + } + + private async createRoom() { + console.log('Trying to destroy old janus room'); + const { data: destroyData } = await janusAPI.destroyRoom( + this.sessionId, + this.videoroomId, + config.janusRoom, + config.janusRoomSecret + ); + const pluginData = destroyData?.plugindata?.data; + // Room could not be destroyed (excluding the case where the room was just not existing) + if ( + destroyData?.janus === 'success' && + (pluginData.videoroom === 'destroyed' || + (pluginData.videoroom === 'event' && + pluginData.error_code === 426)) + ) { + console.log( + `Janus room ${config.janusRoom} was destroyed or not existing` + ); + } else { + throw new Error( + `Could not destroy old janus room. Janus response: ${JSON.stringify( + destroyData, + null, + 2 + )}` + ); + } + + console.log('Creating new janus room'); + const { data: createData } = await janusAPI.createRoom( + this.sessionId, + this.videoroomId, + { + room: config.janusRoom, + bitrate: config.janusBitrate, + publishers: config.cameraSlots, + pin: config.janusRoomPin, + secret: config.janusRoomSecret + } + ); + + if ( + createData?.janus === 'success' && + createData.plugindata.data.videoroom === 'created' + ) { + console.log(`Created new janus room ${config.janusRoom}`); + } else { + throw new Error( + `Could not create Janus room. Server response: ${JSON.stringify( + createData, + null, + 2 + )}` + ); + } + } + + async cleaup() { + console.log(`Cleaning up janus room ${config.janusRoom}`); + if (this._sessionAlive) { + this.source.cancel('Cleanup'); + console.log('Destroying janus room'); + const { data } = await janusAPI.destroyRoom( + this.sessionId, + this.videoroomId, + config.janusRoom, + config.janusRoomSecret + ); + if ( + data?.janus === 'success' && + data.plugindata.data.videoroom === 'destroyed' + ) { + console.log('Successfully destroyed room'); + } else { + console.log('Error: Could not destroy room'); + } + + console.log('Detaching videoroom plugin'); + await janusAPI.detachVideoroomPlugin( + this.sessionId, + this.videoroomId + ); + + console.log('Destroying janus session'); + await janusAPI.destroySession(this.sessionId); + } else { + console.log("Can't clean up janus room because session timed out"); + } + } +} + +export const room = new JanusRoom(); diff --git a/camera-server/src/server.ts b/camera-server/src/server.ts index e2ec3a4..975ca77 100644 --- a/camera-server/src/server.ts +++ b/camera-server/src/server.ts @@ -6,13 +6,24 @@ import { handleQueryState } from './socket-io/handlers/common-handlers'; import { readlineInterface } from './io-interface/readline-interface'; import { handleCommand } from './io-interface/handlers/input-handlers'; import { registerCleanupLogic } from './util/cleanup'; +import { room } from './janus/janus-room'; -socketIO.on('connection', (socket: Socket) => { - socket.on('query_state', handleQueryState); +(async () => { + try { + await room.init(); + } catch (err) { + console.log(err); + console.log('Exiting process'); + process.exit(1); + } - socket.on('sender_init', handleSenderInit.bind(null, socket)); -}); + socketIO.on('connection', (socket: Socket) => { + socket.on('query_state', handleQueryState); -readlineInterface.on('line', handleCommand); + socket.on('sender_init', handleSenderInit.bind(null, socket)); + }); -registerCleanupLogic(); + readlineInterface.on('line', handleCommand); + + registerCleanupLogic(); +})(); diff --git a/camera-server/src/util/cleanup.ts b/camera-server/src/util/cleanup.ts index 0a78da2..f543780 100644 --- a/camera-server/src/util/cleanup.ts +++ b/camera-server/src/util/cleanup.ts @@ -1,45 +1,45 @@ import { socketIO } from '../socket-io/socket-io'; import { isBlocking } from '../io-interface/handlers/output-handlers'; +import { room } from '../janus/janus-room'; -interface ExitHandlerOptions { - cleanup?: boolean; - exit?: boolean; -} - -const exitHandler = ( - options: ExitHandlerOptions, - exitCode: string | number -) => { - if (options.cleanup) { - console.log('cleanup'); - socketIO.emit('remove_all_feeds'); - } - if (exitCode || exitCode === 0) { - console.log('Exit code:', exitCode); - if (exitCode === 0 && isBlocking()) { - console.log('Aborting process due to blocking file append'); - process.abort(); - } +const asyncExitHandler = async (reason: number | string | Error) => { + console.log('Async exit handler'); + try { + await room.cleaup(); + } catch (err) { + console.log('Error in async exit handler:', err); } - if (options.exit) { - process.exit(); + + process.exit(isNaN(+reason) ? 1 : +reason); +}; + +const syncExitHandler = () => { + console.log('Sync exit handler'); + socketIO.emit('remove_all_feeds'); + if (isBlocking()) { + console.log('Aborting process due to blocking file append'); + process.abort(); } }; export const registerCleanupLogic = () => { - // do something when app is closing - process.on('exit', exitHandler.bind(null, { cleanup: true })); - - // catches ctrl+c event - process.on('SIGINT', exitHandler.bind(null, { exit: true })); - - // catches "kill pid" (for example: nodemon restart) - process.on('SIGUSR1', exitHandler.bind(null, { exit: true })); - process.on('SIGUSR2', exitHandler.bind(null, { exit: true })); - - // catches uncaught exceptions - process.on('uncaughtException', exitHandler.bind(null, { exit: true })); + [ + 'beforeExit', + 'uncaughtException', + 'unhandledRejection', + 'SIGHUP', + 'SIGINT', + 'SIGQUIT', + 'SIGILL', + 'SIGTRAP', + 'SIGABRT', + 'SIGBUS', + 'SIGFPE', + 'SIGUSR1', + 'SIGSEGV', + 'SIGUSR2', + 'SIGTERM' + ].forEach((evt) => process.on(evt, asyncExitHandler)); - // catches termination - process.on('SIGTERM', exitHandler.bind(null, { exit: true })); + process.on('exit', syncExitHandler); }; diff --git a/camera-server/src/util/random-string.ts b/camera-server/src/util/random-string.ts new file mode 100644 index 0000000..ba96cc8 --- /dev/null +++ b/camera-server/src/util/random-string.ts @@ -0,0 +1,11 @@ +const charSet = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + +export const randomString = (length: number) => { + let str = ''; + for (let i = 0; i < length; i++) { + const pos = Math.floor(Math.random() * charSet.length); + str += charSet.substring(pos, pos + 1); + } + return str; +}; -- GitLab