Compare commits

..
Author SHA1 Message Date
Quan HL 771aa9d06e INVITE from gw should have register_from_domain if it's available 2023-12-13 17:06:15 +07:00
12 changed files with 4818 additions and 1721 deletions
+3 -8
View File
@@ -6,17 +6,12 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 20.x
node-version: lts/*
- run: npm ci
- run: npm run jslint
- name: Install Docker Compose
run: |
sudo curl -L "https://github.com/docker/compose/releases/download/1.29.2/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose
docker-compose --version
- run: npm test
+1 -1
View File
@@ -1,4 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npm run jslint
npm run jslint
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2018-2024 FirstFive8, Inc.
Copyright (c) 2021 Drachtio Communications Services, LLC
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+5 -21
View File
@@ -50,8 +50,7 @@ const {
lookupSipGatewaysByCarrier,
lookupCarrierBySid,
queryCallLimits,
lookupCarrierByAccountLcr,
lookupSystemInformation
lookupCarrierByAccountLcr
} = require('@jambonz/db-helpers')({
host: process.env.JAMBONES_MYSQL_HOST,
port: process.env.JAMBONES_MYSQL_PORT || 3306,
@@ -85,7 +84,6 @@ srf.locals = {...srf.locals,
queryCdrs,
activeCallIds,
idleEmitter,
privateNetworkCidr: process.env.PRIVATE_VOIP_NETWORK_CIDR || null,
dbHelpers: {
ping,
lookupOutboundCarrierForAccount,
@@ -96,11 +94,9 @@ srf.locals = {...srf.locals,
lookupSipGatewaysByCarrier,
lookupCarrierBySid,
queryCallLimits,
lookupCarrierByAccountLcr,
lookupSystemInformation
lookupCarrierByAccountLcr
},
realtimeDbHelpers: {
client: redisClient,
createHash,
retrieveHash,
incrKey,
@@ -187,22 +183,10 @@ if (process.env.K8S || process.env.HTTP_PORT) {
});
}
if ('test' !== process.env.NODE_ENV) {
/* update call stats periodically as well as definition of private network cidr */
setInterval(async() => {
/* update call stats periodically */
setInterval(() => {
stats.gauge('sbc.sip.calls.count', activeCallIds.size, ['direction:outbound',
`instance_id:${process.env.INSTANCE_ID || 0}`]);
const r = await lookupSystemInformation();
if (r) {
if (r.private_network_cidr !== srf.locals.privateNetworkCidr) {
logger.info(`updating private network cidr from ${srf.locals.privateNetworkCidr} to ${r.private_network_cidr}`);
srf.locals.privateNetworkCidr = r.private_network_cidr;
}
// Update system log level
if (r.log_level) {
logger.level = r.log_level;
}
}
}, 20000);
}
@@ -277,4 +261,4 @@ function handle(signal) {
}
}
module.exports = {srf, logger};
module.exports = {srf};
+90 -238
View File
@@ -1,53 +1,35 @@
const Emitter = require('events');
const sdpTransform = require('sdp-transform');
const SrsClient = require('@jambonz/siprec-client-utils');
const {makeRtpEngineOpts, nudgeCallCounts, isPrivateVoipNetwork, isBlackListedSipGateway} = require('./utils');
const {makeRtpEngineOpts, nudgeCallCounts, isPrivateVoipNetwork} = require('./utils');
const {forwardInDialogRequests} = require('drachtio-fn-b2b-sugar');
const {SipError, stringifyUri, parseUri} = require('drachtio-srf');
const debug = require('debug')('jambonz:sbc-outbound');
const makeInviteInProgressKey = (callid) => `sbc-out-iip${callid}`;
const IMMUTABLE_HEADERS = ['via', 'from', 'to', 'call-id', 'cseq', 'max-forwards', 'content-length'];
const createBLegFromHeader = ({
logger,
req,
host,
fromUser,
register_from_domain,
transport,
teams = false,
scheme = 'sip'
}) => {
/**
* this is to make sure the outgoing From has the number in the incoming From
* and not the incoming PAI
*/
const createBLegFromHeader = (req, teams, register_from_domain = null) => {
const from = req.getParsedHeader('From');
const uri = parseUri(from.uri);
const transportParam = transport ? `;transport=${transport}` : '';
logger.debug({from, fromUser, uri, host, scheme, transport, teams}, 'createBLegFromHeader');
/* user */
const user = fromUser || req.get('X-Preferred-From-User') || uri.user || 'anonymous';
/* host */
if (!host) {
if (teams) {
host = req.get('X-MS-Teams-Tenant-FQDN');
}
else if (req.has('X-Preferred-From-Host')) {
host = req.get('X-Preferred-From-Host');
} else if (register_from_domain) {
host = register_from_domain;
}
else {
host = 'localhost';
}
let user = uri.user || 'anonymous';
let host = 'localhost';
if (teams) {
host = req.get('X-MS-Teams-Tenant-FQDN');
}
else if (req.has('X-Preferred-From-User') || req.has('X-Preferred-From-Host')) {
user = req.get('X-Preferred-From-User') || user;
host = req.get('X-Preferred-From-Host') || host;
} else if (register_from_domain) {
host = register_from_domain;
}
if (from.name) {
return `${from.name} <${scheme}:${user}@${host}${transportParam}>`;
return `${from.name} <sip:${user}@${host}>`;
}
return `<${scheme}:${user}@${host}${transportParam}>`;
return `sip:${user}@${host}`;
};
const createBLegToHeader = (req, teams) => {
const to = req.getParsedHeader('To');
const host = teams ? req.get('X-MS-Teams-Tenant-FQDN') : 'localhost';
@@ -71,7 +53,6 @@ const initCdr = (req, invite) => {
account_sid: req.get('X-Account-Sid'),
call_sid: req.get('X-Call-Sid'),
sip_callid: invite.get('Call-ID'),
...(req.has('X-CID') && {sip_parent_callid: req.get('X-CID')}),
...(applicationSid && {application_sid: applicationSid}),
from,
to,
@@ -121,7 +102,6 @@ class CallSession extends Emitter {
this.lookupCarrierByAccountLcr = lookupCarrierByAccountLcr;
this._mediaReleased = false;
this.recordingNoAnswerTimeout = (process.env.JAMBONES_RECORDING_NO_ANSWER_TIMEOUT || 2) * 1000;
}
get service_provider_sid() {
@@ -186,7 +166,7 @@ class CallSession extends Emitter {
subscribeAnswer,
unsubscribe
} = engine;
const {client, createHash, retrieveHash} = this.srf.locals.realtimeDbHelpers;
const {createHash, retrieveHash} = this.srf.locals.realtimeDbHelpers;
this.offer = offer;
this.answer = answer;
this.del = del;
@@ -204,14 +184,15 @@ class CallSession extends Emitter {
this.rtpEngineOpts = makeRtpEngineOpts(this.req, false, this.useWss || teams, false, teams);
this.rtpEngineResource = {destroy: this.del.bind(null, this.rtpEngineOpts.common)};
let proxy, uris = [];
let proxy, uris;
const mapGateways = new Map();
let encryptedMedia = false;
try {
// determine where to send the call
debug(`connecting call: ${JSON.stringify(this.req.locals)}`);
const headers = {
let headers = {
'From': createBLegFromHeader(this.req, teams),
'Contact': createBLegFromHeader(this.req, teams),
'To': createBLegToHeader(this.req, teams),
Allow: 'INVITE, ACK, OPTIONS, CANCEL, BYE, NOTIFY, UPDATE, PRACK',
'X-Account-Sid': this.account_sid
@@ -235,7 +216,6 @@ class CallSession extends Emitter {
if (!contact.includes('transport=ws')) {
proxy = this.req.locals.registration.proxy;
}
this.logger.info(`sending call to registered user ${destUri}`);
}
else if (this.req.locals.target === 'forward') {
uris = [{
@@ -260,6 +240,10 @@ class CallSession extends Emitter {
private_network: false,
uri: `sip:${this.req.calledNumber}@sip.pstnhub.microsoft.com`
}];
headers = {
...headers,
Contact: `sip:${this.req.calledNumber}@${this.req.get('X-MS-Teams-Tenant-FQDN')}:5061;transport=tls`
};
}
else {
try {
@@ -294,13 +278,7 @@ class CallSession extends Emitter {
}
const vc = await this.lookupCarrierBySid(voip_carrier_sid);
const gateways = await this.lookupSipGatewaysByCarrier(voip_carrier_sid);
const goodGateways = [];
for (const g of gateways) {
if (!await isBlackListedSipGateway(client, this.logger, g.sip_gateway_sid)) {
goodGateways.push(g);
}
}
const gws = (goodGateways || [])
const gws = (gateways || [])
.filter((gw) => gw.outbound);
if (gws.length) {
uris = [];
@@ -309,18 +287,17 @@ class CallSession extends Emitter {
this.req.calledNumber.slice(1) :
this.req.calledNumber;
const prefix = vc.tech_prefix || '';
const transport = o.protocol?.startsWith('tls') ? 'tls' : (o.protocol || 'udp');
const protocol = o.protocol?.startsWith('tls') ? 'tls' : (o.protocol || 'udp');
const hostport = !o.port || 5060 === o.port ? o.ipv4 : `${o.ipv4}:${o.port}`;
const prependPlus = vc.e164_leading_plus && !this.req.calledNumber.startsWith('0') ? '+' : '';
const scheme = transport === 'tls' && !process.env.JAMBONES_USE_BEST_EFFORT_TLS && o.use_sips_scheme ?
'sips' : 'sip';
let u = `${scheme}:${prefix}${prependPlus}${calledNumber}@${hostport};transport=${transport}`;
const transport = `transport=${protocol}`;
const scheme = protocol === 'tls' ? 'sips' : 'sip';
const u = `${scheme}:${prefix}${prependPlus}${calledNumber}@${hostport};${transport}`;
const obj = {
name: vc.name,
diversion: vc.diversion,
hostport,
transport,
scheme,
protocol: o.protocol,
register_from_domain: vc.register_from_domain
};
if (vc.register_username && vc.register_password) {
@@ -329,14 +306,8 @@ class CallSession extends Emitter {
password: vc.register_password
};
}
if (vc.requires_register && vc.register_sip_realm?.length > 0) {
proxy = u;
u = `${scheme}:${prefix}${prependPlus}${calledNumber}@${vc.register_sip_realm};transport=${transport}`;
this.logger.debug({uri: u}, `using outbound proxy for this registered trunk: ${proxy}`);
}
mapGateways.set(u, obj);
uris.push(u);
this.logger.debug({gateway: o}, `pushed uri ${u}`);
if (o.protocol === 'tls/srtp') {
/** TODO: this is a bit of a hack in the sense that we are not
* supporting a scenario where you have a carrier with several outbound
@@ -346,10 +317,9 @@ class CallSession extends Emitter {
*/
this.logger.info({u}, `using SRTP for outbound call, pad crypto: ${o.pad_crypto ? 'yes' : 'no'}`);
this.rtpEngineOpts = makeRtpEngineOpts(this.req, false, true, o.pad_crypto, true);
encryptedMedia = true;
}
});
/* Check private network for each gw */
// Check private network for each gw
uris = await Promise.all(uris.map(async(u) => {
return {
private_network: await isPrivateVoipNetwork(u),
@@ -360,7 +330,6 @@ class CallSession extends Emitter {
}
else {
this.logger.info({voip_carrier_sid}, 'no outbound gateways found for requested carrier');
this.res.send(603);
}
} catch (err) {
debug(err);
@@ -371,12 +340,14 @@ class CallSession extends Emitter {
debug(`sending call to PSTN ${uris}`);
}
/* private_network should be called at last - try public first */
// private_network should be called at last
uris = uris.sort((a, b) => a.private_network - b.private_network);
const toPrivate = uris.some((u) => u.private_network === true);
const toPublic = uris.some((u) => u.private_network === false);
let isOfferUpdatedToPrivate = toPrivate && !toPublic;
// rtpengine 'offer'
const opts = updateRtpEngineFlags(this.req.body, {
...this.rtpEngineOpts.common,
...this.rtpEngineOpts.uac.mediaOpts,
@@ -385,6 +356,7 @@ class CallSession extends Emitter {
sdp: this.req.body
});
let response = await this.offer(opts);
debug(`response from rtpengine to offer ${JSON.stringify(response)}`);
this.logger.debug({offer: opts, response}, 'initial offer to rtpengine');
if ('ok' !== response.result) {
this.logger.error(`rtpengine offer failed with ${JSON.stringify(response)}`);
@@ -397,12 +369,10 @@ class CallSession extends Emitter {
// crank through the list of gateways until connected, exhausted or caller hangs up
let earlyMedia = false;
while (uris.length) {
let hdrs = { ...headers };
let hdrs = { ...headers};
const {private_network, uri} = uris.shift();
/* if we've exhausted attempts to public endpoints and are switching to trying private, we need new rtp */
if (private_network && !isOfferUpdatedToPrivate) {
this.logger.info('switching to attempt to deliver call via private network now..');
// Cannot make call to all public Uris, now come to talk with private network Uris
this.rtpEngineResource.destroy()
.catch((err) => this.logger.info({err}, 'Error destroying rtpe to re-connect to private network'));
response = await this.offer({
@@ -411,8 +381,8 @@ class CallSession extends Emitter {
});
isOfferUpdatedToPrivate = true;
}
/* on the second and subsequent attempts, use the same Call-ID and CSeq from the first attempt */
const gw = mapGateways.get(uri);
const passFailure = 0 === uris.length; // only a single target
if (0 === uris.length) {
try {
const key = makeInviteInProgressKey(this.req.get('Call-ID'));
@@ -428,76 +398,32 @@ class CallSession extends Emitter {
this.logger.info({err}, 'Error retrieving iip key');
}
}
/* INVITE request line and To header should be the same. */
// INVITE request line and To header should be the same.
hdrs = {...hdrs, 'To': uri};
/* only now can we set Contact & From header since they depend on transport and scheme of gw */
const gw = mapGateways.get(uri);
if (gw) {
const {scheme, transport} = gw;
this.logger.info({gw}, `sending INVITE to ${uri} via carrier ${gw.name}`);
hdrs = {
...hdrs,
From: gw.register_from_domain ?
createBLegFromHeader({
logger: this.logger,
req: this.req,
register_from_domain: gw.register_from_domain,
scheme,
transport,
...(private_network && {host: this.privateSipAddress})
}) :
createBLegFromHeader({
logger: this.logger,
req: this.req,
scheme,
transport,
...(private_network && {host: this.privateSipAddress})
}),
Contact: createBLegFromHeader({
logger: this.logger,
req: this.req,
...(gw.auth?.username && {fromUser: gw.auth?.username}),
scheme,
transport,
...(private_network && {host: this.privateSipAddress})
}),
...(gw.diversion && {
Diversion: gw.diversion.startsWith('+') ?
`<sip:${gw.diversion}@${gw.hostport}>;reason=unknown;counter=1;privacy=off` :
`<sip:+${gw.diversion}@${gw.hostport}>;reason=unknown;counter=1;privacy=off`
})
};
if (gw.diversion) {
let div = gw.diversion;
if (div.startsWith('+')) {
div = `<sip:${div}@${gw.hostport}>;reason=unknown;counter=1;privacy=off`;
}
else div = `<sip:+${div}@${gw.hostport}>;reason=unknown;counter=1;privacy=off`;
hdrs = {
...hdrs,
'Diversion': div
};
}
if (gw.register_from_domain) {
hdrs = {
...hdrs,
'From': createBLegFromHeader(this.req, teams, gw.register_from_domain)
};
}
}
else if (teams) {
hdrs = {
...hdrs,
'From': createBLegFromHeader({logger: this.logger, req: this.req, teams: true, transport: 'tls'}),
'Contact': `sip:${this.req.calledNumber}@${this.req.get('X-MS-Teams-Tenant-FQDN')}:5061;transport=tls`
};
}
else {
hdrs = {
...hdrs,
'From': createBLegFromHeader({
logger: this.logger,
req: this.req,
...(private_network && {host: this.privateSipAddress})
}),
'Contact': createBLegFromHeader({
logger: this.logger,
req: this.req,
...(private_network && {host: this.privateSipAddress})
})
};
const p = proxy ? ` via ${proxy}` : '';
this.logger.info({uri, p}, `sending INVITE to ${uri}${p}`);
}
/* now launch an outbound call attempt */
const passFailure = 0 === uris.length; // only propagate failure on last attempt
else this.logger.info(`sending INVITE to ${uri} via proxy ${proxy})`);
try {
const responseHeaders = this.privateSipAddress ? {Contact: `<sip:${this.privateSipAddress}>`} : {};
const {uas, uac} = await this.srf.createB2BUA(this.req, this.res, uri, {
proxy,
passFailure,
@@ -522,12 +448,8 @@ class CallSession extends Emitter {
'-Allow',
'-Session-Expires'
],
// Add X-CID header to feature server response.
// to allow calling/status hooks contains sbc_callid.
responseHeaders: (uacRes, headers) => {
headers['X-CID'] = uacRes.get('Call-ID');
},
headers: hdrs,
responseHeaders,
auth: gw ? gw.auth : undefined,
localSdpB: response.sdp,
localSdpA: async(sdp, res) => {
@@ -580,9 +502,6 @@ class CallSession extends Emitter {
} catch (err) {
this.logger.error({err}, 'Error saving Call-ID/CSeq');
}
this.contactHeader = inv.get('Contact');
this.logger.info(`outbound call attempt to ${uri} has contact header ${this.contactHeader}`);
},
cbProvisional: (response) => {
if (!earlyMedia && [180, 183].includes(response.status) && response.body) earlyMedia = true;
@@ -602,7 +521,6 @@ class CallSession extends Emitter {
// these are all final failure scenarios
if (uris.length === 0 || // exhausted all targets
earlyMedia || // failure after early media
encryptedMedia || // cant crank back when using encrypted media as keys have been exchanged
!(err instanceof SipError) || // unexpected error
err.status === 487) { // caller hung up
@@ -656,18 +574,13 @@ class CallSession extends Emitter {
this.uas = uas;
this.uac = uac;
[uas, uac].forEach((dlg) => {
dlg.on('destroy', async(bye) => {
dlg.on('destroy', async() => {
const other = dlg.other;
this.rtpEngineResource.destroy();
this.activeCallIds.delete(this.req.get('Call-ID'));
this.unsubscribeForDTMF();
//this.unsubscribeDTMF(this.logger, this.req.get('Call-ID'), this.rtpEngineOpts.uac.tag);
try {
const headers = {};
Object.keys(bye.headers).forEach((h) => {
if (!IMMUTABLE_HEADERS.includes(h)) headers[h] = bye.headers[h];
});
await other.destroy({headers});
await other.destroy();
} catch (err) {}
@@ -733,22 +646,6 @@ class CallSession extends Emitter {
forwardInDialogRequests(uac, ['notify', 'options', 'message']);
}
_startRecordingNoAnswerTimer(res) {
this._clearRecordingNoAnswerTimer();
this.recordingNoAnswerTimer = setTimeout(() => {
this.logger.info('No response from SipRec server, return error to feature server');
this.isRecordingNoAnswerResponded = true;
res.send(400);
}, this.recordingNoAnswerTimeout);
}
_clearRecordingNoAnswerTimer() {
if (this.recordingNoAnswerTimer) {
clearTimeout(this.recordingNoAnswerTimer);
this.recordingNoAnswerTimer = null;
}
}
_stopRecording() {
if (this.srsClients.length) {
this.srsClients.forEach((c) => c.stop());
@@ -855,21 +752,13 @@ Duration=${payload.duration} `
sdp
};
response = await this.answer(opts);
/* now remove asymmetric as B party (looking at you Genesys ring group) may need port re-learning on invites */
answerMedia.flags = answerMedia.flags.filter((f) => f !== 'asymmetric');
if ('ok' !== response.result) {
res.send(488);
throw new Error(`_onReinvite: rtpengine failed: ${JSON.stringify(response)}`);
}
this.logger.debug({opts, sdp: response.sdp}, 'CallSession:_onReinvite: (answer) sending back upstream');
res.send(200, {
body: response.sdp,
headers: {
'Contact': this.contactHeader
}
});
res.send(200, {body: response.sdp});
} catch (err) {
res.send(err.status || 500);
this.logger.error(err, 'Error handling reinvite');
}
}
@@ -897,12 +786,12 @@ Duration=${payload.duration} `
}
else if (reason.includes('CallRecording')) {
let succeeded = false;
const headers = contentType === 'application/json' && req.body ? JSON.parse(req.body) : {};
if (reason === 'startCallRecording') {
const from = this.req.getParsedHeader('From');
const to = this.req.getParsedHeader('To');
const aorFrom = from.uri;
const aorTo = to.uri;
const headers = contentType === 'application/json' && req.body ? JSON.parse(req.body) : {};
this.logger.info({to, from}, 'startCallRecording request for an outbound call');
const srsUrl = req.get('X-Srs-Url');
@@ -945,76 +834,49 @@ Duration=${payload.duration} `
headers
}));
try {
this._startRecordingNoAnswerTimer(res);
await Promise.any(this.srsClients.map((c) => c.start()));
succeeded = true;
succeeded = (await Promise.all(
this.srsClients.map((c) => c.start())
)).every((r) => r);
} catch (err) {
this.logger.error({err}, 'Error starting SipRec call recording');
succeeded = false;
}
}
else if (reason === 'stopCallRecording') {
if (!this.srsClients.length || !this.srsClients.some((c) => c.activated)) {
if (!this.srsClients.length) {
res.send(400);
this.logger.info('discarding stopCallRecording request because we are not recording');
return;
}
try {
this._startRecordingNoAnswerTimer(res);
await Promise.any(this.srsClients.map((c) => {
if (c.activated) {
c.stop();
}
}));
succeeded = true;
succeeded = (await Promise.all(
this.srsClients.map((c) => c.stop())
)).every((r) => r);
} catch (err) {
this.logger.error({err}, 'Error stopping SipRec call recording');
succeeded = false;
}
this.srsClients = [];
}
else if (reason === 'pauseCallRecording') {
if (!this.srsClients.length || !this.srsClients.some((c) => c.activated && !c.paused)) {
if (!this.srsClients.length || this.srsClients.every((c) => c.paused)) {
this.logger.info('discarding invalid pauseCallRecording request');
res.send(400);
return;
}
try {
this._startRecordingNoAnswerTimer(res);
await Promise.any(this.srsClients.map((c) => {
if (c.activated && !c.paused) {
c.pause({headers});
}
}));
succeeded = true;
} catch (err) {
this.logger.error({err}, 'Error pausing SipRec call recording');
succeeded = false;
}
succeeded = (await Promise.all(
this.srsClients.map((c) => c.pause())
)).every((r) => r);
}
else if (reason === 'resumeCallRecording') {
if (!this.srsClients.length || !this.srsClients.some((c) => c.activated && c.paused)) {
if (!this.srsClients.length || !this.srsClients.every((c) => c.paused)) {
res.send(400);
this.logger.info('discarding invalid resumeCallRecording request');
return;
}
try {
this._startRecordingNoAnswerTimer(res);
await Promise.any(this.srsClients.map((c) => {
if (c.activated && c.paused) {
c.resume({headers});
}
}));
succeeded = true;
} catch (err) {
this.logger.error({err}, 'Error resuming SipRec call recording');
succeeded = false;
}
}
if (!this.isRecordingNoAnswerResponded) {
this._clearRecordingNoAnswerTimer();
res.send(succeeded ? 200 : 503);
succeeded = (await Promise.all(
this.srsClients.map((c) => c.resume())
)).every((r) => r);
}
res.send(succeeded ? 200 : 503);
} else if (reason.includes('Dtmf')) {
const arr = /Signal=\s*([0-9#*])/.exec(req.body);
if (!arr) {
@@ -1072,9 +934,10 @@ Duration=${payload.duration} `
}
}
else {
const immutableHdrs = ['via', 'from', 'to', 'call-id', 'cseq', 'max-forwards', 'content-length'];
const headers = {};
Object.keys(req.headers).forEach((h) => {
if (!IMMUTABLE_HEADERS.includes(h)) headers[h] = req.headers[h];
if (!immutableHdrs.includes(h)) headers[h] = req.headers[h];
});
const response = await dlg.other.request({method: 'INFO', headers, body: req.body});
const responseHeaders = {};
@@ -1090,16 +953,6 @@ Duration=${payload.duration} `
async _onFeatureServerTransfer(dlg, req, res) {
try {
// the following properties are ignored in the REFER headers
// eslint-disable-next-line no-unused-vars
const { via, from, to, 'call-id': callid, cseq, 'max-forwards': maxforwards,
// eslint-disable-next-line no-unused-vars
'content-length': _contentlength, 'refer-to': _referto, 'referred-by': _referredby,
// eslint-disable-next-line no-unused-vars
'X-Refer-To-Leave-Untouched': _leave,
...customHeaders
} = req.headers;
const referTo = req.getParsedHeader('Refer-To');
const uri = parseUri(referTo.uri);
this.logger.info({uri, referTo}, 'received REFER from feature server');
@@ -1109,16 +962,15 @@ Duration=${payload.duration} `
const referredBy = req.getParsedHeader('Referred-By');
if (!referredBy) return res.send(400);
const u = parseUri(referredBy.uri);
/* delete contact if it was there from feature server */
delete customHeaders['contact'];
const farEnd = parseUri(this.connectedUri);
uri.host = farEnd.host;
uri.port = farEnd.port;
const response = await this.uac.request({
method: 'REFER',
headers: {
'Refer-To': `<${stringifyUri(uri)}>`,
'Referred-By': `<${stringifyUri(u)}>`,
...customHeaders
'Refer-To': stringifyUri(uri),
'Referred-By': stringifyUri(u)
}
});
return res.send(response.status);
+2 -9
View File
@@ -77,11 +77,7 @@ module.exports = (srf, logger, redisClient) => {
});
try {
const account = await lookupAccountBySid(req.locals.account_sid);
req.locals.account = account;
if (account.enable_debug_log) {
req.locals.logger.level = 'debug';
}
req.locals.account = await lookupAccountBySid(req.locals.account_sid);
req.locals.service_provider_sid = req.locals.account.service_provider_sid;
} catch (err) {
req.locals.logger.error({err}, `Error looking up account sid ${req.locals.account_sid}`);
@@ -227,10 +223,7 @@ module.exports = (srf, logger, redisClient) => {
/* redirect to the correct SBC where this user is connected */
const proxyAddress = selectHostPort(reg.sbcAddress, 'tcp');
const redirectUri = `<sip:${proxyAddress[1]}>`;
logger.info({
myHostPort: req.server.hostport,
registeredHostPort: reg.sbcAddress,
}, `redirecting call to SBC at ${redirectUri}`);
logger.info(`redirecting call to SBC at ${redirectUri}`);
return res.send(302, {headers: {Contact: redirectUri}});
}
req.locals.registration = reg;
+7 -24
View File
@@ -4,6 +4,9 @@ const debug = require('debug')('jambonz:sbc-outbound');
const CIDRMatcher = require('cidr-matcher');
const dns = require('dns');
const cidrMatcher = process.env.PRIVATE_VOIP_NETWORK_CIDR ?
new CIDRMatcher(process.env.PRIVATE_VOIP_NETWORK_CIDR.split(',')) : null;
function makeRtpEngineOpts(req, srcIsUsingSrtp, dstIsUsingSrtp, padCrypto, teams) {
const from = req.getParsedHeader('from');
const rtpCopy = JSON.parse(JSON.stringify(rtpCharacteristics));
@@ -188,12 +191,8 @@ const nudgeCallCounts = async(logger, sids, nudgeOperator, writers) => {
};
const isPrivateVoipNetwork = async(uri) => {
const {srf, logger} = require('..');
const {privateNetworkCidr} = srf.locals;
if (privateNetworkCidr) {
if (cidrMatcher) {
try {
const matcher = new CIDRMatcher(privateNetworkCidr.split(','));
const arr = /sips?:.*@(.*?)(:\d+)?(;.*)$/.exec(uri);
if (arr) {
const input = arr[1];
@@ -204,31 +203,16 @@ const isPrivateVoipNetwork = async(uri) => {
addresses = await dns.resolve4(input);
}
for (const ip of addresses) {
if (matcher.contains(ip)) {
if (cidrMatcher.contains(ip)) {
return true;
}
}
}
} catch (err) {
logger.info({err, privateNetworkCidr},
'Error checking private network CIDR, probably misconfigured must be a comma separated list of CIDRs');
}
} catch (err) {}
}
return false;
};
function makeBlacklistGatewayKey(key) {
return `blacklist-sip-gateway:${key}`;
}
async function isBlackListedSipGateway(client, logger, sip_gateway_sid) {
try {
return await client.exists(makeBlacklistGatewayKey(sip_gateway_sid)) === 1;
} catch (err) {
logger.error({err}, `isBlackListedSipGateway: error while checking blacklist for ${sip_gateway_sid}`);
}
}
module.exports = {
makeRtpEngineOpts,
selectHostPort,
@@ -239,6 +223,5 @@ module.exports = {
systemHealth,
createHealthCheckApp,
nudgeCallCounts,
isPrivateVoipNetwork,
isBlackListedSipGateway
isPrivateVoipNetwork
};
+4685 -1269
View File
File diff suppressed because it is too large Load Diff
+17 -17
View File
@@ -1,9 +1,9 @@
{
"name": "sbc-outbound",
"version": "0.9.2",
"version": "0.8.5",
"main": "app.js",
"engines": {
"node": ">= 18.0.0"
"node": ">= 12.0.0"
},
"keywords": [
"sip",
@@ -24,30 +24,30 @@
"start": "node app",
"test": "NODE_ENV=test HTTP_PORT=3050 JAMBONES_HOSTING=1 JAMBONES_NETWORK_CIDR=127.0.0.1/32 JAMBONES_MYSQL_HOST=127.0.0.1 JAMBONES_MYSQL_USER=jambones_test JAMBONES_MYSQL_PASSWORD=jambones_test JAMBONES_MYSQL_DATABASE=jambones_test JAMBONES_REDIS_HOST=localhost JAMBONES_REDIS_PORT=16379 JAMBONES_TIME_SERIES_HOST=127.0.0.1 JAMBONES_LOGLEVEL=error DRACHTIO_SECRET=cymru DRACHTIO_HOST=127.0.0.1 DRACHTIO_PORT=9060 JAMBONES_RTPENGINES=127.0.0.1:12222 node test/ ",
"coverage": "./node_modules/.bin/nyc --reporter html --report-dir ./coverage npm run test",
"jslint": "eslint app.js lib --fix"
"jslint": "eslint app.js lib"
},
"dependencies": {
"@jambonz/db-helpers": "^0.9.7",
"@jambonz/realtimedb-helpers": "^0.8.9",
"@jambonz/db-helpers": "^0.9.0",
"@jambonz/realtimedb-helpers": "^0.8.7",
"@jambonz/http-health-check": "^0.0.1",
"@jambonz/mw-registrar": "0.2.7",
"@jambonz/rtpengine-utils": "^0.4.4",
"@jambonz/siprec-client-utils": "^0.2.7",
"@jambonz/stats-collector": "^0.1.10",
"@jambonz/time-series": "^0.2.10",
"@jambonz/mw-registrar": "0.2.4",
"@jambonz/rtpengine-utils": "^0.4.3",
"@jambonz/siprec-client-utils": "^0.2.6",
"@jambonz/stats-collector": "^0.1.9",
"@jambonz/time-series": "^0.2.5",
"cidr-matcher": "^2.1.1",
"debug": "^4.3.4",
"drachtio-fn-b2b-sugar": "^0.1.0",
"drachtio-srf": "^4.5.39",
"express": "^4.19.2",
"pino": "^8.20.0",
"sdp-transform": "^2.14.2"
"drachtio-fn-b2b-sugar": "^0.0.12",
"drachtio-srf": "^4.5.31",
"express": "^4.18.1",
"pino": "^7.11.0",
"sdp-transform": "^2.14.1"
},
"devDependencies": {
"bent": "^7.3.12",
"eslint": "^7.32.0",
"eslint-plugin-promise": "^6.1.1",
"eslint-plugin-promise": "^5.2.0",
"nyc": "^15.1.0",
"tape": "^5.7.5"
"tape": "^5.5.3"
}
}
+6 -62
View File
@@ -1,5 +1,4 @@
/* SQLEditor (MySQL (2))*/
SET FOREIGN_KEY_CHECKS=0;
DROP TABLE IF EXISTS account_static_ips;
@@ -14,8 +13,6 @@ DROP TABLE IF EXISTS beta_invite_codes;
DROP TABLE IF EXISTS call_routes;
DROP TABLE IF EXISTS clients;
DROP TABLE IF EXISTS dns_records;
DROP TABLE IF EXISTS lcr;
@@ -54,8 +51,6 @@ DROP TABLE IF EXISTS signup_history;
DROP TABLE IF EXISTS smpp_addresses;
DROP TABLE IF EXISTS google_custom_voices;
DROP TABLE IF EXISTS speech_credentials;
DROP TABLE IF EXISTS system_information;
@@ -132,19 +127,6 @@ application_sid CHAR(36) NOT NULL,
PRIMARY KEY (call_route_sid)
) COMMENT='a regex-based pattern match for call routing';
CREATE TABLE clients
(
client_sid CHAR(36) NOT NULL UNIQUE ,
account_sid CHAR(36) NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT 1,
username VARCHAR(64),
password VARCHAR(1024),
allow_direct_app_calling BOOLEAN NOT NULL DEFAULT 1,
allow_direct_queue_calling BOOLEAN NOT NULL DEFAULT 1,
allow_direct_user_calling BOOLEAN NOT NULL DEFAULT 1,
PRIMARY KEY (client_sid)
);
CREATE TABLE dns_records
(
dns_record_sid CHAR(36) NOT NULL UNIQUE ,
@@ -340,27 +322,14 @@ last_tested DATETIME,
tts_tested_ok BOOLEAN,
stt_tested_ok BOOLEAN,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
label VARCHAR(64),
PRIMARY KEY (speech_credential_sid)
);
CREATE TABLE google_custom_voices
(
google_custom_voice_sid CHAR(36) NOT NULL UNIQUE ,
speech_credential_sid CHAR(36) NOT NULL,
model VARCHAR(512) NOT NULL,
reported_usage ENUM('REPORTED_USAGE_UNSPECIFIED','REALTIME','OFFLINE') DEFAULT 'REALTIME',
name VARCHAR(64) NOT NULL,
PRIMARY KEY (google_custom_voice_sid)
);
CREATE TABLE system_information
(
domain_name VARCHAR(255),
sip_domain_name VARCHAR(255),
monitoring_domain_name VARCHAR(255),
private_network_cidr VARCHAR(8192),
log_level ENUM('info', 'debug') NOT NULL DEFAULT 'info'
monitoring_domain_name VARCHAR(255)
);
CREATE TABLE users
@@ -442,7 +411,7 @@ PRIMARY KEY (smpp_gateway_sid)
CREATE TABLE phone_numbers
(
phone_number_sid CHAR(36) UNIQUE ,
number VARCHAR(132) NOT NULL,
number VARCHAR(132) NOT NULL UNIQUE ,
voip_carrier_sid CHAR(36),
account_sid CHAR(36),
application_sid CHAR(36),
@@ -455,14 +424,11 @@ CREATE TABLE sip_gateways
sip_gateway_sid CHAR(36),
ipv4 VARCHAR(128) NOT NULL COMMENT 'ip address or DNS name of the gateway. For gateways providing inbound calling service, ip address is required.',
netmask INTEGER NOT NULL DEFAULT 32,
port INTEGER COMMENT 'sip signaling port',
port INTEGER NOT NULL DEFAULT 5060 COMMENT 'sip signaling port',
inbound BOOLEAN NOT NULL COMMENT 'if true, whitelist this IP to allow inbound calls from the gateway',
outbound BOOLEAN NOT NULL COMMENT 'if true, include in least-cost routing when placing calls to the PSTN',
voip_carrier_sid CHAR(36) NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT 1,
send_options_ping BOOLEAN NOT NULL DEFAULT 0,
use_sips_scheme BOOLEAN NOT NULL DEFAULT 0,
pad_crypto BOOLEAN NOT NULL DEFAULT 0,
protocol ENUM('udp','tcp','tls', 'tls/srtp') DEFAULT 'udp' COMMENT 'Outbound call protocol',
PRIMARY KEY (sip_gateway_sid)
) COMMENT='A whitelisted sip gateway used for origination/termination';
@@ -499,21 +465,10 @@ messaging_hook_sid CHAR(36) COMMENT 'webhook to call for inbound SMS/MMS ',
app_json TEXT,
speech_synthesis_vendor VARCHAR(64) NOT NULL DEFAULT 'google',
speech_synthesis_language VARCHAR(12) NOT NULL DEFAULT 'en-US',
speech_synthesis_voice VARCHAR(256),
speech_synthesis_label VARCHAR(64),
speech_synthesis_voice VARCHAR(64),
speech_recognizer_vendor VARCHAR(64) NOT NULL DEFAULT 'google',
speech_recognizer_language VARCHAR(64) NOT NULL DEFAULT 'en-US',
speech_recognizer_label VARCHAR(64),
use_for_fallback_speech BOOLEAN DEFAULT false,
fallback_speech_synthesis_vendor VARCHAR(64),
fallback_speech_synthesis_language VARCHAR(12),
fallback_speech_synthesis_voice VARCHAR(256),
fallback_speech_synthesis_label VARCHAR(64),
fallback_speech_recognizer_vendor VARCHAR(64),
fallback_speech_recognizer_language VARCHAR(64),
fallback_speech_recognizer_label VARCHAR(64),
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
record_all_calls BOOLEAN NOT NULL DEFAULT false,
PRIMARY KEY (application_sid)
) COMMENT='A defined set of behaviors to be applied to phone calls ';
@@ -551,10 +506,6 @@ subspace_client_secret VARCHAR(255),
subspace_sip_teleport_id VARCHAR(255),
subspace_sip_teleport_destinations VARCHAR(255),
siprec_hook_sid CHAR(36),
record_all_calls BOOLEAN NOT NULL DEFAULT false,
record_format VARCHAR(16) NOT NULL DEFAULT 'mp3',
bucket_credential VARCHAR(8192) COMMENT 'credential used to authenticate with storage service',
enable_debug_log BOOLEAN NOT NULL DEFAULT false,
PRIMARY KEY (account_sid)
) COMMENT='An enterprise that uses the platform for comm services';
@@ -575,9 +526,6 @@ ALTER TABLE call_routes ADD FOREIGN KEY account_sid_idxfk_3 (account_sid) REFERE
ALTER TABLE call_routes ADD FOREIGN KEY application_sid_idxfk (application_sid) REFERENCES applications (application_sid);
CREATE INDEX client_sid_idx ON clients (client_sid);
ALTER TABLE clients ADD CONSTRAINT account_sid_idxfk_13 FOREIGN KEY account_sid_idxfk_13 (account_sid) REFERENCES accounts (account_sid);
CREATE INDEX dns_record_sid_idx ON dns_records (dns_record_sid);
ALTER TABLE dns_records ADD FOREIGN KEY account_sid_idxfk_4 (account_sid) REFERENCES accounts (account_sid);
@@ -642,6 +590,8 @@ CREATE INDEX smpp_address_sid_idx ON smpp_addresses (smpp_address_sid);
CREATE INDEX service_provider_sid_idx ON smpp_addresses (service_provider_sid);
ALTER TABLE smpp_addresses ADD FOREIGN KEY service_provider_sid_idxfk_4 (service_provider_sid) REFERENCES service_providers (service_provider_sid);
CREATE UNIQUE INDEX speech_credentials_idx_1 ON speech_credentials (vendor,account_sid);
CREATE INDEX speech_credential_sid_idx ON speech_credentials (speech_credential_sid);
CREATE INDEX service_provider_sid_idx ON speech_credentials (service_provider_sid);
ALTER TABLE speech_credentials ADD FOREIGN KEY service_provider_sid_idxfk_5 (service_provider_sid) REFERENCES service_providers (service_provider_sid);
@@ -649,10 +599,6 @@ ALTER TABLE speech_credentials ADD FOREIGN KEY service_provider_sid_idxfk_5 (ser
CREATE INDEX account_sid_idx ON speech_credentials (account_sid);
ALTER TABLE speech_credentials ADD FOREIGN KEY account_sid_idxfk_8 (account_sid) REFERENCES accounts (account_sid);
CREATE INDEX google_custom_voice_sid_idx ON google_custom_voices (google_custom_voice_sid);
CREATE INDEX speech_credential_sid_idx ON google_custom_voices (speech_credential_sid);
ALTER TABLE google_custom_voices ADD FOREIGN KEY speech_credential_sid_idxfk (speech_credential_sid) REFERENCES speech_credentials (speech_credential_sid) ON DELETE CASCADE;
CREATE INDEX user_sid_idx ON users (user_sid);
CREATE INDEX email_idx ON users (email);
CREATE INDEX phone_idx ON users (phone);
@@ -682,8 +628,6 @@ CREATE INDEX smpp_gateway_sid_idx ON smpp_gateways (smpp_gateway_sid);
CREATE INDEX voip_carrier_sid_idx ON smpp_gateways (voip_carrier_sid);
ALTER TABLE smpp_gateways ADD FOREIGN KEY voip_carrier_sid_idxfk (voip_carrier_sid) REFERENCES voip_carriers (voip_carrier_sid);
CREATE UNIQUE INDEX phone_numbers_unique_idx_voip_carrier_number ON phone_numbers (number,voip_carrier_sid);
CREATE INDEX phone_number_sid_idx ON phone_numbers (phone_number_sid);
CREATE INDEX number_idx ON phone_numbers (number);
CREATE INDEX voip_carrier_sid_idx ON phone_numbers (voip_carrier_sid);
@@ -1,59 +0,0 @@
<?xml version="1.0" encoding="ISO-8859-1" ?>
<!DOCTYPE scenario SYSTEM "sipp.dtd">
<scenario name="UAC with media">
<send retrans="500">
<![CDATA[
INVITE sip:16173333456@127.0.0.1 SIP/2.0
Via: SIP/2.0/[transport] [local_ip]:[local_port];branch=[branch]
From: sipp <sip:sipp@[local_ip]:[local_port]>;tag=[pid]SIPpTag09[call_number]
To: <sip:16173333456@127.0.0.1>
Call-ID: [call_id]
CSeq: 1 INVITE
Contact: sip:sipp@[local_ip]:[local_port]
Max-Forwards: 70
X-Account-Sid: ed649e33-e771-403a-8c99-1780eabbc803
X-Call-Sid: ff49e33-e771-403a-8c99-1780eabbc803
X-Jambonz-Routing: phone
Subject: uac-pcap-carrier-fail-limits
Content-Type: application/sdp
Content-Length: [len]
v=0
o=user1 53655765 2353687637 IN IP[local_ip_type] [local_ip]
s=-
c=IN IP[local_ip_type] [local_ip]
t=0 0
m=audio [auto_media_port] RTP/AVP 8 101
a=rtpmap:8 PCMA/8000
a=rtpmap:101 telephone-event/8000
a=fmtp:101 0-11,16
]]>
</send>
<recv response="100" optional="true">
</recv>
<recv response="603" rtd="true" crlf="true">
</recv>
<send>
<![CDATA[
ACK sip:sip:+16173333456@127.0.0.1 SIP/2.0
[last_Via]
From: sipp <sip:sipp@[local_ip]:[local_port]>;tag=[pid]SIPpTag09[call_number]
To: <sip:sip:+16173333456@127.0.0.1>[peer_tag_param]
Call-ID: [call_id]
CSeq: 1 ACK
Subject: uac-pcap-carrier-fail-blacklist
Content-Length: 0
]]>
</send>
</scenario>
+1 -12
View File
@@ -26,7 +26,6 @@ function connect(connectable) {
test('sbc-outbound tests', async(t) => {
const {srf} = require('../app');
const { queryCdrs } = srf.locals;
const redisClient = srf.locals.realtimeDbHelpers.client;
try {
await connect(srf);
@@ -96,17 +95,7 @@ test('sbc-outbound tests', async(t) => {
/* fails when session limit exceeded */
await sippUac('uac-pcap-carrier-fail-limits.xml');
t.pass('fails when max calls in progress');
// re-rack test data
execSync(`mysql -h 127.0.0.1 -u root --protocol=tcp -D jambones_test < ${__dirname}/db/jambones-sql.sql`);
execSync(`mysql -h 127.0.0.1 -u root --protocol=tcp -D jambones_test < ${__dirname}/db/populate-test-data.sql`);
// Black list good carrier for some seconds
await redisClient.setex('blacklist-sip-gateway:124a5339-c62c-4075-9e19-f4de70a96597', 3, '');
await sippUac('uac-pcap-carrier-fail-blacklist.xml');
t.pass('fails when carrier is blacklisted');
await redisClient.del('blacklist-sip-gateway:124a5339-c62c-4075-9e19-f4de70a96597');
await waitFor(25);
const res = await queryCdrs({account_sid: 'ed649e33-e771-403a-8c99-1780eabbc803'});