const CIDRMatcher = require('cidr-matcher'); const express = require('express'); const rtpCharacteristics = require('../data/rtp-transcoding'); const srtpCharacteristics = require('../data/srtp-transcoding'); let idx = 0; const isWSS = (req) => { return req.getParsedHeader('Via')[0].protocol.toLowerCase().startsWith('ws'); }; const getAppserver = (srf) => { const len = srf.locals.featureServers.length; return srf.locals.featureServers[idx++ % len]; }; function makeRtpEngineOpts(req, srcIsUsingSrtp, dstIsUsingSrtp, teams = false) { const rtpCopy = JSON.parse(JSON.stringify(rtpCharacteristics)); const srtpCopy = JSON.parse(JSON.stringify(srtpCharacteristics)); const from = req.getParsedHeader('from'); const srtpOpts = teams ? srtpCopy['teams'] : srtpCopy['default']; if ((req.locals.gateway?.pad_crypto || 0) > 0) { srtpOpts.flags.push('pad crypto'); } const dstOpts = dstIsUsingSrtp ? srtpOpts : rtpCopy; const srcOpts = srcIsUsingSrtp ? srtpOpts : rtpCopy; /* Allow Feature server to inject DTMF to both leg except call from Teams */ if (!teams) { dstOpts.flags.push('inject DTMF'); srcOpts.flags.push('inject DTMF'); } /** By default use strict source to secure aganst RTPInject vuln, * set env var to true to disable it and use media handover instead */ const disableStrictSource = !!process.env.RTPENGINE_DISABLE_STRICT_SOURCE; dstOpts.flags.push(disableStrictSource ? 'media handover' : 'strict source'); srcOpts.flags.push(disableStrictSource ? 'media handover' : 'strict source'); const acceptCodecs = process.env.JAMBONES_ACCEPT_AND_TRANSCODE ? process.env.JAMBONES_ACCEPT_AND_TRANSCODE : process.env.JAMBONES_ACCEPT_G729 ? 'g729' : ''; const common = { 'call-id': req.get('Call-ID'), 'replace': ['origin', 'session-connection'], 'record call': process.env.JAMBONES_RECORD_ALL_CALLS ? 'yes' : 'no', ...(acceptCodecs && { codec: { mask: acceptCodecs, transcode: 'pcmu,pcma' } }) }; return { common, uas: { tag: from.params.tag, mediaOpts: srcOpts }, uac: { tag: null, mediaOpts: dstOpts } }; } const SdpWantsSDES = (sdp) => { return /m=audio.*\s+RTP\/SAVP/.test(sdp); }; const SdpWantsSrtp = (sdp) => { return /m=audio.*SAVP/.test(sdp); }; const makeAccountCallCountKey = (sid) => `incalls:account:${sid}`; const makeSPCallCountKey = (sid) => `incalls:sp:${sid}:`; const makeAppCallCountKey = (sid) => `incalls:app${sid}:`; const normalizeDID = (tel) => { const regex = /^\+(\d+)$/; const arr = regex.exec(tel); return arr ? arr[1] : tel; }; const equalsIgnoreOrder = (a, b) => { if (a.length !== b.length) return false; const uniqueValues = new Set([...a, ...b]); for (const v of uniqueValues) { const aCount = a.filter((e) => e === v).length; const bCount = b.filter((e) => e === v).length; if (aCount !== bCount) return false; } return true; }; const systemHealth = async(redisClient, ping, getCount) => { await Promise.all([redisClient.ping(), ping()]); return getCount(); }; const doListen = (logger, app, port, resolve) => { return app.listen(port, () => { logger.info(`Health check server listening on http://localhost:${port}`); resolve(app); }); }; const handleErrors = (logger, app, resolve, reject, e) => { if (e.code === 'EADDRINUSE' && process.env.HTTP_PORT_MAX && e.port < process.env.HTTP_PORT_MAX) { logger.info(`Health check server failed to bind port on ${e.port}, will try next port`); const server = doListen(logger, app, ++e.port, resolve); server.on('error', handleErrors.bind(null, logger, app, resolve, reject)); return; } reject(e); }; const createHealthCheckApp = (port, logger) => { const app = express(); app.use(express.urlencoded({ extended: true })); app.use(express.json()); return new Promise((resolve, reject) => { const server = doListen(logger, app, port, resolve); server.on('error', handleErrors.bind(null, logger, app, resolve, reject)); }); }; /** * nudgeCallCounts - increment or decrement call counts in redis * * current nudge value * ----------------------------------------- * why | -1 | 0 | 1 | * ----------------------------------------- * init | no-op | +1 | N/A | * failure | N/A | -1 | -1 | * complete| N/A | N/A | -1 | * * */ const shouldNudge = (why, req) => { const {nudge, logger} = req.locals; let modifyCount = false; const originalNudge = nudge; switch (why) { case 'init': if (nudge === 0) { // normal case: new call, increment call count req.locals.nudge = 1; modifyCount = true; } else if (nudge === -1) { // extremely quick cancel, don't increment call count req.locals.nudge = 0; } else { logger.info(`shouldNudge: unexpected nudge value ${nudge} for ${why}`); } break; case 'failure': if (nudge === 1) { // normal case of call failed for any reason, decrement call count req.locals.nudge = 0; modifyCount = true; } else if (nudge === 0) { // very quick failure dont decrement call count req.locals.nudge = -1; } else { logger.info(`shouldNudge: unexpected nudge value ${nudge} for ${why}`); } break; case 'complete': if (nudge === 1) { // normal case of call completed, decrement call count req.locals.nudge = 0; modifyCount = true; } else { logger.info(`shouldNudge: unexpected nudge value ${nudge} for ${why}`); } break; default: logger.info(`shouldNudge: unexpected why value ${why}`); break; } logger.info(`shouldNudge: '${why}': updating count: ${modifyCount}, nudge: ${originalNudge} -> ${req.locals.nudge}`); return modifyCount; }; const nudgeCallCounts = async(req, why, sids, nudgeOperator, writers) => { const {logger} = req.locals; const {service_provider_sid, account_sid, application_sid, callId} = sids; const {writeCallCount, writeCallCountSP, writeCallCountApp} = writers; const nudges = []; const writes = []; if (!shouldNudge(why, req)) { return {callsSP: null, calls: null, callsApp: null}; } if (process.env.JAMBONES_DEBUG_CALL_COUNTS) { const {srf} = require('..'); const {addKey, deleteKey} = srf.locals.realtimeDbHelpers; if (why === 'init') { // save for 3 days await addKey(`debug:incalls:${account_sid}:${callId}`, new Date().toISOString(), 259200); } else { await deleteKey(`debug:incalls:${account_sid}:${callId}`); } } if (process.env.JAMBONES_TRACK_SP_CALLS) { const key = makeSPCallCountKey(service_provider_sid); nudges.push(nudgeOperator(key)); } else { nudges.push(() => Promise.resolve(null)); } if (process.env.JAMBONES_TRACK_ACCOUNT_CALLS || process.env.JAMBONES_HOSTING) { const key = makeAccountCallCountKey(account_sid); nudges.push(nudgeOperator(key)); } else { nudges.push(() => Promise.resolve(null)); } if (process.env.JAMBONES_TRACK_APP_CALLS && application_sid) { const key = makeAppCallCountKey(application_sid); nudges.push(nudgeOperator(key)); } else { nudges.push(() => Promise.resolve(null)); } try { const [callsSP, calls, callsApp] = await Promise.all(nudges); logger.debug({ calls, callsSP, callsApp, service_provider_sid, account_sid, application_sid}, 'call counts after adjustment'); if (process.env.JAMBONES_TRACK_SP_CALLS) { writes.push(writeCallCountSP({service_provider_sid, calls_in_progress: callsSP})); } if (process.env.JAMBONES_TRACK_ACCOUNT_CALLS || process.env.JAMBONES_HOSTING) { writes.push(writeCallCount({service_provider_sid, account_sid, calls_in_progress: calls})); } if (process.env.JAMBONES_TRACK_APP_CALLS && application_sid) { writes.push(writeCallCountApp({service_provider_sid, account_sid, application_sid, calls_in_progress: callsApp})); } /* write the call counts to the database */ Promise.all(writes).catch((err) => logger.error({err}, 'Error writing call counts')); return {callsSP, calls, callsApp}; } catch (err) { logger.error(err, 'error incrementing call counts'); } return {callsSP: null, calls: null, callsApp: null}; }; const roundTripTime = (startAt) => { const diff = process.hrtime(startAt); const time = diff[0] * 1e3 + diff[1] * 1e-6; return time.toFixed(0); }; const parseConnectionIp = (sdp) => { const regex = /c=IN IP4 ([0-9.]+)/; const arr = regex.exec(sdp); return arr ? arr[1] : null; }; /** * Checks if ip is one of MS Teams sip signalling ips * https://learn.microsoft.com/en-us/azure/communication-services/concepts * /telephony/direct-routing-infrastructure#sip-signaling-fqdns * @param ip IP address, example 172.31.0.1 * */ const isMSTeamsCIDR = (ip) => { const cidrs = [ '52.112.0.0/14', '52.120.0.0/14' ]; const matcher = new CIDRMatcher(cidrs); return matcher.contains(ip); }; const isPrivateVoipNetwork = (ip) => { const {srf, logger} = require('..'); const {privateNetworkCidr} = srf.locals; if (privateNetworkCidr) { try { const matcher = new CIDRMatcher(privateNetworkCidr.split(',')); return matcher.contains(ip); } catch (err) { logger.info({err, privateNetworkCidr}, 'Error checking private network CIDR, probably misconfigured must be a comma separated list of CIDRs'); } } return false; }; /** * @param hostports can be a string or an array of hostports */ const parseHostPorts = (logger, hostports, srf) => { typeof hostports === 'string' && (hostports = hostports.split(',')); const obj = {}; for (const hp of hostports) { const [, protocol, ipv4, port] = hp.match(/^(.*)\/(.*):(\d+)$/); if (protocol && ipv4 && port) { obj[protocol] = `${ipv4}:${port}`; } } if (!obj.tls) { obj.tls = `${srf.locals.sipAddress}:5061`; } if (!obj.tcp) { obj.tcp = `${srf.locals.sipAddress}:5060`; } logger.info({ obj }, 'sip endpoints'); return obj; }; const makeFullMediaReleaseKey = (callId) => { return `a_sdp:${callId}`; }; const makePartnerFullMediaReleaseKey = (callId) => { return `b_sdp:${callId}`; }; module.exports = { isWSS, SdpWantsSrtp, SdpWantsSDES, getAppserver, makeRtpEngineOpts, makeAccountCallCountKey, makeSPCallCountKey, makeAppCallCountKey, normalizeDID, equalsIgnoreOrder, systemHealth, createHealthCheckApp, nudgeCallCounts, roundTripTime, parseConnectionIp, isMSTeamsCIDR, isPrivateVoipNetwork, parseHostPorts, makeFullMediaReleaseKey, makePartnerFullMediaReleaseKey };