diff --git a/README.md b/README.md index d9561026e42071a0011d5a5628793943e623d669..c46459949aa61be61a46dad982e78d385100e6bc 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 21696d58dbd4b344a42bc2451e9874d1536f77e5..5bfb42bce5f938379ad68adc5df1435202d065c0 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 6041ed6799024ed91e72e03edb47941d2522b4a4..216c6009f1f4b1c2c201e65eb35946a0c010a2a6 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 d3cc08856af15e7ac46eceae884c401c5e9f23cb..a8546ed518896e7127218837d15594738a466dba 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 5863a51c6053f410438ceac1384f4be875b994ee..f74834ae6aadcc96838ce1c97d2575a43c8a6820 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 0000000000000000000000000000000000000000..7ab08b0420bae03c1f19ce4603b1697731918270 --- /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 0000000000000000000000000000000000000000..7012aa7857d55dad158c163645e06f0d13dde975 --- /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 e2ec3a48bbba527993f5537d64d4bb63a107aa2c..975ca77f38006ae111adc9a2935873257030c35c 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 0a78da277e82507dc1291fe4700d9944b376a5ae..f543780d1b38a443f43c9918e835c00a9d0d0e42 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 0000000000000000000000000000000000000000..ba96cc85084f3bc29c9f78d1bdf30f6a743d51ca --- /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; +};