add support for ms teams

This commit is contained in:
Dave Horton
2020-05-22 19:16:11 -04:00
parent d7df23bfc1
commit 958c34efbc
8 changed files with 265 additions and 59 deletions

17
app.js
View File

@@ -20,14 +20,14 @@ const {getRtpEngine} = require('jambonz-rtpengine-utils')(process.env.JAMBONES_R
emitter: srf.locals.stats
});
srf.locals.getRtpEngine = getRtpEngine;
const activeCallIds = srf.locals.activeCallIds = new Set();
const activeCallIds = srf.locals.activeCallIds = new Map();
logger.info('starting..');
const {
lookupAuthHook,
lookupSipGatewayBySignalingAddress,
addSbcAddress
} = require('jambonz-db-helpers')({
} = require('@jambonz/db-helpers')({
host: process.env.JAMBONES_MYSQL_HOST,
user: process.env.JAMBONES_MYSQL_USER,
password: process.env.JAMBONES_MYSQL_PASSWORD,
@@ -64,6 +64,17 @@ if (process.env.NODE_ENV === 'test') {
srf.use('invite', [initLocals, challengeDeviceCalls]);
srf.invite((req, res) => {
if (req.has('Replaces')) {
const arr = /^(.*);from/.exec(req.get('Replaces'));
if (arr) logger.info(`replacing call-id ${arr}`);
else logger.info(`failed parsing ${req.get('Replaces')}`);
const session = arr ? activeCallIds.get(arr[1]) : null;
if (!session) {
logger.info(`failed to find session in Replaces header: ${req.has('Replaces')}`);
return res.send(404);
}
return session.replaces(req, res);
}
const session = new CallSession(logger, req, res);
session.connect();
});
@@ -75,6 +86,6 @@ srf.use((req, res, next, err) => {
setInterval(() => {
stats.gauge('sbc.sip.calls.count', activeCallIds.size, ['direction:inbound']);
}, 5000);
}, 20000);
module.exports = {srf, logger};

View File

@@ -3,5 +3,6 @@
"DTLS": "off",
"SDES": "off",
"ICE": "remove",
"rtcp-mux": ["demux"]
"flags": ["media handover"],
"rtcp-mux": ["accept"]
}

View File

@@ -1,7 +1,16 @@
{
"transport-protocol": "UDP/TLS/RTP/SAVPF",
"ICE": "force",
"SDES": "off",
"flags": ["generate mid", "SDES-no"],
"rtcp-mux": ["require"]
"default": {
"transport-protocol": "UDP/TLS/RTP/SAVPF",
"ICE": "force",
"SDES": "off",
"flags": ["generate mid", "SDES-no", "media handover"],
"rtcp-mux": ["require"]
},
"teams": {
"transport-protocol": "RTP/SAVP",
"ICE": "force",
"SDES": "off",
"flags": ["generate mid", "SDES-no", "media handover"],
"rtcp-mux": ["accept"]
}
}

View File

@@ -1,8 +1,10 @@
const Emitter = require('events');
const {isWSS, makeRtpEngineOpts} = require('./utils');
const {makeRtpEngineOpts, SdpWantsSrtp} = require('./utils');
const {forwardInDialogRequests} = require('drachtio-fn-b2b-sugar');
const {parseUri, SipError} = require('drachtio-srf');
const debug = require('debug')('jambonz:sbc-inbound');
const MS_TEAMS_USER_AGENT = 'Microsoft.PSTNHub.SIPProxy';
const MS_TEAMS_SIP_ENDPOINT = 'sip.pstnhub.microsoft.com';
/**
* this is to make sure the outgoing From has the number in the incoming From
@@ -29,6 +31,10 @@ class CallSession extends Emitter {
this.activeCallIds = this.srf.locals.activeCallIds;
}
get isFromMSTeams() {
return !!this.req.locals.msTeamsTenantFqdn;
}
async connect() {
this.logger.info('inbound call accepted for routing');
const engine = this.getRtpEngine();
@@ -53,22 +59,24 @@ class CallSession extends Emitter {
}
debug(`using feature server ${featureServer}`);
this.rtpEngineOpts = makeRtpEngineOpts(this.req, isWSS(this.req), false);
this.rtpEngineOpts = makeRtpEngineOpts(this.req, SdpWantsSrtp(this.req.body), false, this.isFromMSTeams);
this.rtpEngineResource = {destroy: this.del.bind(null, this.rtpEngineOpts.common)};
const obj = parseUri(this.req.uri);
let proxy, host, uri;
// replace host part of uri if its an ipv4 address, leave it otherwise
if (/\d{1-3}\.\d{1-3}\.\d{1-3}\.\d{1-3}/.test(obj.host)) {
if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(obj.host)) {
debug(`replacing host: was ${obj.host} is ${featureServer}`);
host = featureServer;
}
else {
debug(`not replacing host: "${obj.host}"`);
host = obj.host;
proxy = `sip:${featureServer}`;
}
else {
host = featureServer;
}
if (obj.user) uri = `${obj.scheme}:${obj.user}@${host}`;
else uri = `${obj.scheme}:${host}`;
debug(`uri will be: ${uri}, proxy ${proxy}`);
this.logger.info(`uri will be: ${uri}, proxy ${proxy}`);
try {
const response = await this.offer(this.rtpEngineOpts.offer);
@@ -81,10 +89,21 @@ class CallSession extends Emitter {
// now send the INVITE in towards the feature servers
const headers = {
'From': createBLegFromHeader(this.req),
'To': this.req.get('To'),
'X-CID': this.req.get('Call-ID'),
'X-Forwarded-For': `${this.req.source_address}:${this.req.source_port}`
};
const responseHeaders = {};
if (this.req.locals.carrier) Object.assign(headers, {'X-Originating-Carrier': this.req.locals.carrier});
if (this.req.locals.msTeamsTenantFqdn) {
Object.assign(headers, {'X-MS-Teams-Tenant-FQDN': this.req.locals.msTeamsTenantFqdn});
// for Microsoft Teams the Contact header must include the tenant FQDN
Object.assign(responseHeaders, {
Allow: 'INVITE, ACK, OPTIONS, CANCEL, BYE, NOTIFY, UPDATE, PRACK',
Contact: `sip:${this.req.locals.msTeamsTenantFqdn}`
});
}
if (this.req.locals.application_sid) {
Object.assign(headers, {'X-Application-Sid': this.req.locals.application_sid});
}
@@ -104,7 +123,8 @@ class CallSession extends Emitter {
const {uas, uac} = await this.srf.createB2BUA(this.req, this.res, uri, {
proxy,
headers,
proxyRequestHeaders: ['all', '-Authorization', '-Max-Forwards'],
responseHeaders,
proxyRequestHeaders: ['all', '-Authorization', '-Max-Forwards', '-Record-Route'],
proxyResponseHeaders: ['all'],
localSdpB: response.sdp,
localSdpA: async(sdp, res) => {
@@ -121,6 +141,7 @@ class CallSession extends Emitter {
// successfully connected
this.logger.info('call connected successfully to feature server');
this.toTag = uas.sip.localTag;
this._setHandlers({uas, uac});
return;
} catch (err) {
@@ -139,11 +160,23 @@ class CallSession extends Emitter {
}
}
_setDlgHandlers(dlg) {
this.activeCallIds.set(this.req.get('Call-ID'), this);
dlg.on('destroy', () => {
this.logger.info('call ended with normal termination');
this.rtpEngineResource.destroy().catch((err) => {});
this.activeCallIds.delete(this.req.get('Call-ID'));
if (dlg.other && dlg.other.connected) dlg.other.destroy().catch((e) => {});
});
//re-invite
dlg.on('modify', this._onReinvite.bind(this, dlg));
}
_setHandlers({uas, uac}) {
this.emit('connected');
const tags = ['accepted:yes', 'sipStatus:200', `originator:${this.req.locals.originator}`];
this.stats.increment('sbc.terminations', tags);
this.activeCallIds.add(this.req.get('Call-ID'));
this.activeCallIds.set(this.req.get('Call-ID'), this);
this.uas = uas;
this.uac = uac;
@@ -151,34 +184,89 @@ class CallSession extends Emitter {
//hangup
dlg.on('destroy', () => {
this.logger.info('call ended with normal termination');
this.rtpEngineResource.destroy();
this.rtpEngineResource.destroy().catch((err) => {});
this.activeCallIds.delete(this.req.get('Call-ID'));
dlg.other.destroy();
dlg.other.destroy().catch((e) => {});
});
});
uas.on('modify', this._onReinvite.bind(this, uas));
uac.on('modify', this._onFeatureServerReinvite.bind(this, uac));
uac.on('refer', this._onFeatureServerTransfer.bind(this, uac));
uas.on('refer', this._onRefer.bind(this, uas));
// default forwarding of other request types
forwardInDialogRequests(uas);
forwardInDialogRequests(uas, ['info', 'notify', 'options', 'message']);
}
/**
* handle INVITE with Replaces header from uas side (this will never come from the feature server)
* @param {*} req incoming request
* @param {*} res incoming response
*/
async replaces(req, res) {
try {
let opts = Object.assign(this.rtpEngineOpts.offer, {sdp: req.body});
let response = await this.offer(opts);
if ('ok' !== response.result) {
res.send(488);
throw new Error(`replaces: rtpengine failed: offer: ${JSON.stringify(response)}`);
}
this.logger.info({opts, response}, 'sent offer for reinvite to rtpengine');
const sdp = await this.uac.modify(response.sdp);
opts = Object.assign(this.rtpEngineOpts.answer, {sdp, 'to-tag': this.toTag});
response = await this.answer(opts);
if ('ok' !== response.result) {
res.send(488);
throw new Error(`replaces: rtpengine failed: ${JSON.stringify(response)}`);
}
this.logger.info({opts, response}, 'sent answer for reinvite to rtpengine');
const headers = {};
if (this.req.locals.msTeamsTenantFqdn) {
Object.assign(headers, {'X-MS-Teams-Tenant-FQDN': this.req.locals.msTeamsTenantFqdn});
// for Microsoft Teams the Contact header must include the tenant FQDN
Object.assign(headers, {
Allow: 'INVITE, ACK, OPTIONS, CANCEL, BYE, NOTIFY',
Contact: `sip:${this.req.locals.msTeamsTenantFqdn}`
});
}
const uas = await this.srf.createUAS(req, res, {
localSdp: response.sdp,
headers
});
this.logger.info('successfully connected new INVITE w/replaces, hanging up leg being replaced');
this.uas.destroy();
this.req = req;
this.uas = uas;
this.uas.other = this.uac;
this.uac.other = this.uas;
this.activeCallIds.delete(this.req.get('Call-ID'));
this._setDlgHandlers(uas);
} catch (err) {
this.logger.error(err, 'Error handling invite with replaces');
res.send(err.status || 500);
}
}
async _onReinvite(dlg, req, res) {
try {
let response = await this.offer(Object.assign({sdp: req.body}, this.rtpEngineOpts.offer));
let opts = Object.assign(this.rtpEngineOpts.offer, {sdp: req.body});
let response = await this.offer(opts);
if ('ok' !== response.result) {
res.send(488);
throw new Error(`_onReinvite: rtpengine failed: offer: ${JSON.stringify(response)}`);
}
this.logger.info({opts, response}, 'sent offer for reinvite to rtpengine');
const sdp = await dlg.other.modify(response.sdp);
const opts = Object.assign({sdp, 'to-tag': this.toTag}, this.rtpEngineOpts.answer);
opts = Object.assign({sdp, 'to-tag': this.toTag}, this.rtpEngineOpts.answer);
response = await this.answer(opts);
if ('ok' !== response.result) {
res.send(488);
throw new Error(`_onReinvite: rtpengine failed: ${JSON.stringify(response)}`);
}
this.logger.info({opts, response}, 'sent answer for reinvite to rtpengine');
res.send(200, {body: response.sdp});
} catch (err) {
this.logger.error(err, 'Error handling reinvite');
@@ -245,6 +333,77 @@ class CallSession extends Emitter {
}
}
async _onRefer(dlg, req, res) {
const ua = req.get('User-Agent');
const referTo = req.get('Refer-To');
const rt = req.getParsedHeader('Refer-To');
const uri = parseUri(rt.uri);
this.logger.info({referTo, ua, rt, uri}, 'got a REFER');
/**
* send NOTIFY of INVITE status, return true if call answered
*/
const sendNotify = (dlg, body) => {
const arr = /SIP\/2.0\s+(\d+).*$/.exec(body);
const status = arr ? parseInt(arr[1]) : null;
dlg.request({
method: 'NOTIFY',
headers: {
'Content-Type': 'message/sipfrag;version=2.0',
'Contact': `sip:${this.req.locals.msTeamsTenantFqdn}`
},
body
});
this.logger.info(`sent NOTIFY for REFER with status ${status}`);
return status === 200;
};
if (this.isFromMSTeams && ua.startsWith(MS_TEAMS_USER_AGENT) &&
referTo.startsWith(`<sip:${MS_TEAMS_SIP_ENDPOINT}`) &&
!uri.user) {
// the Refer-To endpoint is within Teams itself, so we can handle
res.send(202);
try {
const dlg = await this.srf.createUAC(rt.uri, {
localSdp: this.uas.local.sdp.replace(/a=inactive/g, 'a=sendrecv'),
headers: {
'From': `sip:${this.req.callingNumber}@${this.req.locals.msTeamsTenantFqdn}`,
'Contact': `sip:${this.req.callingNumber}@${this.req.locals.msTeamsTenantFqdn}`
}
},
{
cbRequest: (err, inviteSent) => {
if (err) return sendNotify(this.uas, `SIP/2.0 ${err.status || '500'}`);
sendNotify(this.uas, '100 Trying ');
this.referInvite = inviteSent;
},
cbProvisional: (prov) => {
sendNotify(this.uas, `${prov.status} ${prov.reason}`);
}
});
// successfully connected
this.logger.info('successfully connected new call leg for REFER');
this.referInvite = null;
sendNotify(this.uas, '200 OK');
this.uas.destroy();
this.uas = dlg;
this.uas.other = this.uac;
this.activeCallIds.delete(this.req.get('Call-ID'));
this._setDlgHandlers(dlg);
} catch (err) {
this.logger.error({err}, 'Error creating new call leg for REFER');
sendNotify(this.uas, `${err.status || 500} ${err.reason || ''}`);
}
}
else {
// TODO: forward on to feature server
res.send(501);
}
}
}
module.exports = CallSession;

View File

@@ -1,5 +1,7 @@
const debug = require('debug')('jambonz:sbc-inbound');
const Emitter = require('events');
const parseUri = require('drachtio-srf').parseUri;
const msProxyIps = process.env.MS_TEAMS_SIP_PROXY_IPS ? process.env.MS_TEAMS_SIP_PROXY_IPS.split(',').map((i) => i.trim()) : [];
class AuthOutcomeReporter extends Emitter {
constructor(stats) {
@@ -35,16 +37,23 @@ module.exports = function(srf, logger) {
async function challengeDeviceCalls(req, res, next) {
try {
const gateway = await lookupSipGatewayBySignalingAddress(req.source_address, req.source_port);
if (!gateway) {
// TODO: if uri.host is not a domain, just reject
req.locals.originator = 'device';
return authenticator(req, res, next);
if (gateway) {
debug(`challengeDeviceCalls: call came from gateway: ${JSON.stringify(gateway)}`);
req.locals.originator = 'trunk';
req.locals.carrier = gateway.name;
if (gateway.application_sid) req.locals.application_sid = gateway.application_sid;
return next();
}
debug(`challengeDeviceCalls: call came from gateway: ${JSON.stringify(gateway)}`);
req.locals.originator = 'trunk';
req.locals.carrier = gateway.name;
if (gateway.application_sid) req.locals.application_sid = gateway.application_sid;
next();
if (msProxyIps.includes(req.source_address)) {
logger.debug({source_address: req.source_address}, 'challengeDeviceCalls: incoming call from Microsoft Teams');
const uri = parseUri(req.uri);
req.locals.originator = 'teams';
req.locals.carrier = 'Microsoft Teams';
req.locals.msTeamsTenantFqdn = uri.host;
return next();
}
req.locals.originator = 'device';
return authenticator(req, res, next);
} catch (err) {
stats.increment('sbc.terminations', ['sipStatus:500']);
logger.error(err, `${req.get('Call-ID')} Error looking up related info for inbound call`);

View File

@@ -11,19 +11,30 @@ function getAppserver(srf) {
return srf.locals.featureServers[ idx++ % len];
}
function makeRtpEngineOpts(req, srcIsUsingSrtp, dstIsUsingSrtp) {
function makeRtpEngineOpts(req, srcIsUsingSrtp, dstIsUsingSrtp, teams = false) {
const srtpOpts = teams ? srtpCharacteristics['teams'] : srtpCharacteristics['default'];
const from = req.getParsedHeader('from');
const common = {'call-id': req.get('Call-ID'), 'from-tag': from.params.tag};
return {
common,
offer: Object.assign({'sdp': req.body, 'replace': ['origin', 'session-connection']}, common,
dstIsUsingSrtp ? srtpCharacteristics : rtpCharacteristics),
answer: Object.assign({}, common, srcIsUsingSrtp ? srtpCharacteristics : rtpCharacteristics)
offer: Object.assign(
{'sdp': req.body, 'replace': ['origin', 'session-connection']},
common,
dstIsUsingSrtp ? srtpOpts : rtpCharacteristics),
answer: Object.assign(
{'replace': ['origin', 'session-connection']},
common,
srcIsUsingSrtp ? srtpOpts : rtpCharacteristics)
};
}
function SdpWantsSrtp(sdp) {
return /m=audio.*SAVP/.test(sdp);
}
module.exports = {
isWSS,
SdpWantsSrtp,
getAppserver,
makeRtpEngineOpts
};

48
package-lock.json generated
View File

@@ -1,6 +1,6 @@
{
"name": "sbc-inbound",
"version": "0.3.1",
"version": "0.3.2",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
@@ -274,6 +274,23 @@
"integrity": "sha512-tsAQNx32a8CoFhjhijUIhI4kccIAgmGhy8LZMZgGfmXcpMbPRUqn5LWmgRttILi6yeGmBJd2xsPkFMs0PzgPCw==",
"dev": true
},
"@jambonz/db-helpers": {
"version": "0.3.6",
"resolved": "https://registry.npmjs.org/@jambonz/db-helpers/-/db-helpers-0.3.6.tgz",
"integrity": "sha512-yKi9yX1DGH2MK4GSkllpEh1MqfdSxePn9ChOSZKmcMX0/PBe95YenUCTssLRGhr4+naKUhaWgBJkvQgkwQEsEw==",
"requires": {
"debug": "^4.1.1",
"mysql2": "^2.0.2",
"uuid": "^7.0.3"
},
"dependencies": {
"uuid": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-7.0.3.tgz",
"integrity": "sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg=="
}
}
},
"@protobufjs/aspromise": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
@@ -903,9 +920,9 @@
}
},
"drachtio-srf": {
"version": "4.4.28",
"resolved": "https://registry.npmjs.org/drachtio-srf/-/drachtio-srf-4.4.28.tgz",
"integrity": "sha512-gY/wmH6JFmeEv2/jhwFbky1NYUmDwgIJzjNeGlDiAkQEA6GgcI/CFi5RRHAUzghpczTwXQNDXpARPB8QDWX1JA==",
"version": "4.4.33",
"resolved": "https://registry.npmjs.org/drachtio-srf/-/drachtio-srf-4.4.33.tgz",
"integrity": "sha512-2bVOObbP9m9ASZ+XXgnaeEk9v1a2Vn8d4Oaz6anrRCuRUzirUOJ/c5nJh9VQvbmXqUx+VubPmXa+AGJuFyYNow==",
"requires": {
"async": "^1.4.2",
"debug": "^3.1.0",
@@ -916,6 +933,7 @@
"lodash": "^4.17.13",
"node-noop": "0.0.1",
"only": "0.0.2",
"sdp-transform": "^2.14.0",
"sip-methods": "^0.3.0",
"utils-merge": "1.0.0",
"uuid": "^3.0.0"
@@ -1987,23 +2005,6 @@
"istanbul-lib-report": "^3.0.0"
}
},
"jambonz-db-helpers": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/jambonz-db-helpers/-/jambonz-db-helpers-0.3.4.tgz",
"integrity": "sha512-0ygVMhrHxO4wE30LqszKaGYXOgwoGLERQZHw1f5A4vKrpLZrcG1a/jF6W2bCpcfLfJ5owewx45AeUYeEvdvIGw==",
"requires": {
"debug": "^4.1.1",
"mysql2": "^2.0.2",
"uuid": "^7.0.3"
},
"dependencies": {
"uuid": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-7.0.3.tgz",
"integrity": "sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg=="
}
}
},
"jambonz-http-authenticator": {
"version": "0.1.5",
"resolved": "https://registry.npmjs.org/jambonz-http-authenticator/-/jambonz-http-authenticator-0.1.5.tgz",
@@ -2880,6 +2881,11 @@
"resolved": "https://registry.npmjs.org/sax/-/sax-1.2.1.tgz",
"integrity": "sha1-e45lYZCyKOgaZq6nSEgNgozS03o="
},
"sdp-transform": {
"version": "2.14.0",
"resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.14.0.tgz",
"integrity": "sha512-8ZYOau/o9PzRhY0aMuRzvmiM6/YVQR8yjnBScvZHSdBnywK5oZzAJK+412ZKkDq29naBmR3bRw8MFu0C01Gehg=="
},
"semver": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",

View File

@@ -27,8 +27,8 @@
"dependencies": {
"debug": "^4.1.1",
"drachtio-fn-b2b-sugar": "0.0.12",
"drachtio-srf": "^4.4.28",
"jambonz-db-helpers": "^0.3.4",
"drachtio-srf": "^4.4.33",
"@jambonz/db-helpers": "^0.3.6",
"jambonz-http-authenticator": "0.1.5",
"jambonz-realtimedb-helpers": "^0.2.4",
"jambonz-rtpengine-utils": "0.1.1",