import * as dotenv from 'dotenv'; import envalid from 'envalid'; import got from 'got'; import NodeCache from 'node-cache'; import { sign } from 'jsonwebtoken'; import { RecorderRequestMeta, Update, RecorderRequest } from './request_tracker'; import { JibriTracker } from './jibri_tracker'; import { recorderToken } from './token'; import { Context } from './context'; import Redis from 'ioredis'; dotenv.config(); const env = envalid.cleanEnv(process.env, { ASAP_PUB_KEY_TTL: envalid.num({ default: 3600 }), RECORDER_TOKEN_TTL_SECONDS: envalid.num({ default: 30 }), ASAP_PUB_KEY_BASE_URL: envalid.str(), ASAP_JWT_ISS: envalid.str(), ASAP_JWT_KID: envalid.str(), ASAP_JWT_AUD: envalid.str(), ASAP_JWT_ACCEPTED_AUD: envalid.str(), ASAP_JWT_ACCEPTED_ISS: envalid.str(), ASAP_JWT_ACCEPTED_HOOK_ISS: envalid.str(), TOKEN_SIGNING_KEY_FILE: envalid.str(), }); export const AsapPubKeyTTL = env.ASAP_PUB_KEY_TTL; export const RecorderTokenExpSeconds = env.RECORDER_TOKEN_TTL_SECONDS; export const AsapPubKeyBaseUrl = env.ASAP_PUB_KEY_BASE_URL; export const AsapJwtIss = env.ASAP_JWT_ISS; export const AsapJwtKid = env.ASAP_JWT_KID; export const AsapJwtAud = env.ASAP_JWT_AUD; export const AsapJwtAcceptedAud = env.ASAP_JWT_ACCEPTED_AUD; export const AsapJwtAcceptedIss = env.ASAP_JWT_ACCEPTED_ISS; export const AsapJwtAcceptedHookIss = env.ASAP_JWT_ACCEPTED_HOOK_ISS; export const TokenSigningKeyFile = env.TOKEN_SIGNING_KEY_FILE; export interface MeetProcessorOptions { jibriTracker: JibriTracker; signingKey: Buffer; } interface TokenResponse extends RecorderRequest { token: string; } export class MeetProcessor { private jibriTracker: JibriTracker; private signingKey: Buffer; private asapCache: NodeCache; private redisClient: Redis.Redis; constructor(options: MeetProcessorOptions) { this.jibriTracker = options.jibriTracker; this.signingKey = options.signingKey; this.asapCache = new NodeCache({ stdTTL: 60 * 45 }); // TTL of 45 minutes this.requestProcessor = this.requestProcessor.bind(this); this.updateProcessor = this.updateProcessor.bind(this); } authToken(): string { const cachedAuth: string = this.asapCache.get('asap'); if (cachedAuth) { return cachedAuth; } const auth = sign({}, this.signingKey, { issuer: AsapJwtIss, audience: AsapJwtAud, algorithm: 'RS256', keyid: AsapJwtKid, expiresIn: 60 * 60, // 1 hour }); this.asapCache.set('asap', auth); return auth; } async requestProcessor(ctx: Context, req: RecorderRequestMeta): Promise<boolean> { try { await this.jibriTracker.nextAvailable(ctx); } catch (err) { if (err.name === 'RecorderUnavailableError') { ctx.logger.debug('no recorders'); return false; } throw err; } const token = recorderToken( { issuer: AsapJwtIss, audience: 'jitsi', subject: '*', algorithm: 'RS256', keyid: AsapJwtKid, expiresIn: RecorderTokenExpSeconds, }, this.signingKey, ); const recorderResponse: TokenResponse = { conference: req.conference, roomParam: req.roomParam, externalApiUrl: req.externalApiUrl, eventType: 'QueueUpdate', participant: req.participant, requestId: req.requestId, token: token, }; ctx.logger.debug('sending response to signal api'); const response = await got.post(req.externalApiUrl, { throwHttpErrors: false, searchParams: { room: req.roomParam }, headers: { Authorization: `Bearer ${this.authToken()}`, }, json: recorderResponse, }); switch (response.statusCode) { case 200: { return true; } case 404: { // conference no longer exists ctx.logger.debug(`conference for ${req.requestId} no longer exists`); const err = new Error('conference canceled'); err.name = 'CanceledError'; throw err; } default: { ctx.logger.error(`unexpected response from signal api ${response.statusCode} - ${response.body}`); throw new Error('unexpected response from token response api'); } } } async updateProcessor(ctx: Context, req: RecorderRequestMeta, position: number): Promise<boolean> { const now = Date.now(); const created = parseInt(req.created, 10); const diffTime = Math.trunc(Math.abs((now - created) / 1000)); if (diffTime < 2) { // Not processing updates unless the request is older than 2 seconds. return true; } ctx.logger.debug(`request update ${req.requestId} position: ${position} time: ${diffTime}`); const update: Update = { conference: req.conference, roomParam: req.roomParam, externalApiUrl: req.externalApiUrl, eventType: 'QueueUpdate', participant: req.participant, requestId: req.requestId, position: position, time: diffTime, }; // TODO: metrics, retry const response = await got.post(req.externalApiUrl, { throwHttpErrors: false, searchParams: { room: req.roomParam }, headers: { Authorization: `Bearer ${this.authToken()}`, }, json: update, }); switch (response.statusCode) { case 200: { return true; } case 404: { // conference no longer exists ctx.logger.debug(`conference for ${req.requestId} no longer exists`); const err = new Error('conference canceled'); err.name = 'CanceledError'; throw err; } default: { if (response.statusCode != 200) { throw new Error('non-200 response from token response api'); } } } } }