mirror of
https://github.com/jambonz/sbc-outbound.git
synced 2025-12-19 04:27:45 +00:00
Fix/tls transport (#141)
* fix scheme * add missing initialization of scheme * delete contact header explicitly * wip * wip * wip * fix bug with cseq * wip * deps * wip * wip --------- Co-authored-by: Markus Frindt <m.frindt@cognigy.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
. "$(dirname "$0")/_/husky.sh"
|
. "$(dirname "$0")/_/husky.sh"
|
||||||
|
|
||||||
npm run jslint
|
npm run jslint
|
||||||
@@ -8,29 +8,45 @@ const debug = require('debug')('jambonz:sbc-outbound');
|
|||||||
|
|
||||||
const makeInviteInProgressKey = (callid) => `sbc-out-iip${callid}`;
|
const makeInviteInProgressKey = (callid) => `sbc-out-iip${callid}`;
|
||||||
const IMMUTABLE_HEADERS = ['via', 'from', 'to', 'call-id', 'cseq', 'max-forwards', 'content-length'];
|
const IMMUTABLE_HEADERS = ['via', 'from', 'to', 'call-id', 'cseq', 'max-forwards', 'content-length'];
|
||||||
/**
|
|
||||||
* this is to make sure the outgoing From has the number in the incoming From
|
const createBLegFromHeader = ({
|
||||||
* and not the incoming PAI
|
logger,
|
||||||
*/
|
req,
|
||||||
const createBLegFromHeader = (req, teams, register_from_domain = null) => {
|
host,
|
||||||
|
register_from_domain,
|
||||||
|
transport,
|
||||||
|
teams = false,
|
||||||
|
scheme = 'sip'
|
||||||
|
}) => {
|
||||||
const from = req.getParsedHeader('From');
|
const from = req.getParsedHeader('From');
|
||||||
const uri = parseUri(from.uri);
|
const uri = parseUri(from.uri);
|
||||||
let user = uri.user || 'anonymous';
|
const transportParam = transport ? `;transport=${transport}` : '';
|
||||||
let host = 'localhost';
|
|
||||||
if (teams) {
|
logger.debug({from, uri, host, scheme, transport, teams}, 'createBLegFromHeader');
|
||||||
host = req.get('X-MS-Teams-Tenant-FQDN');
|
/* user */
|
||||||
}
|
const user = req.get('X-Preferred-From-User') || uri.user || 'anonymous';
|
||||||
else if (req.has('X-Preferred-From-User') || req.has('X-Preferred-From-Host')) {
|
|
||||||
user = req.get('X-Preferred-From-User') || user;
|
/* host */
|
||||||
host = req.get('X-Preferred-From-Host') || host;
|
if (!host) {
|
||||||
} else if (register_from_domain) {
|
if (teams) {
|
||||||
host = register_from_domain;
|
host = req.get('X-MS-Teams-Tenant-FQDN');
|
||||||
|
}
|
||||||
|
else if (req.has('X-Preferred-From-User')) {
|
||||||
|
host = req.get('X-Preferred-From-Host');
|
||||||
|
} else if (register_from_domain) {
|
||||||
|
host = register_from_domain;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
host = 'localhost';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (from.name) {
|
if (from.name) {
|
||||||
return `${from.name} <${this.scheme}:${user}@${host}>`;
|
return `${from.name} <${scheme}:${user}@${host}${transportParam}>`;
|
||||||
}
|
}
|
||||||
return `${this.scheme}:${user}@${host}`;
|
return `<${scheme}:${user}@${host}${transportParam}>`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const createBLegToHeader = (req, teams) => {
|
const createBLegToHeader = (req, teams) => {
|
||||||
const to = req.getParsedHeader('To');
|
const to = req.getParsedHeader('To');
|
||||||
const host = teams ? req.get('X-MS-Teams-Tenant-FQDN') : 'localhost';
|
const host = teams ? req.get('X-MS-Teams-Tenant-FQDN') : 'localhost';
|
||||||
@@ -191,12 +207,9 @@ class CallSession extends Emitter {
|
|||||||
let encryptedMedia = false;
|
let encryptedMedia = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.contactHeader = createBLegFromHeader(this.req, teams);
|
|
||||||
// determine where to send the call
|
// determine where to send the call
|
||||||
debug(`connecting call: ${JSON.stringify(this.req.locals)}`);
|
debug(`connecting call: ${JSON.stringify(this.req.locals)}`);
|
||||||
let headers = {
|
const headers = {
|
||||||
'From': createBLegFromHeader(this.req, teams),
|
|
||||||
'Contact': this.contactHeader,
|
|
||||||
'To': createBLegToHeader(this.req, teams),
|
'To': createBLegToHeader(this.req, teams),
|
||||||
Allow: 'INVITE, ACK, OPTIONS, CANCEL, BYE, NOTIFY, UPDATE, PRACK',
|
Allow: 'INVITE, ACK, OPTIONS, CANCEL, BYE, NOTIFY, UPDATE, PRACK',
|
||||||
'X-Account-Sid': this.account_sid
|
'X-Account-Sid': this.account_sid
|
||||||
@@ -220,6 +233,7 @@ class CallSession extends Emitter {
|
|||||||
if (!contact.includes('transport=ws')) {
|
if (!contact.includes('transport=ws')) {
|
||||||
proxy = this.req.locals.registration.proxy;
|
proxy = this.req.locals.registration.proxy;
|
||||||
}
|
}
|
||||||
|
this.logger.info(`sending call to registered user ${destUri}`);
|
||||||
}
|
}
|
||||||
else if (this.req.locals.target === 'forward') {
|
else if (this.req.locals.target === 'forward') {
|
||||||
uris = [{
|
uris = [{
|
||||||
@@ -244,10 +258,6 @@ class CallSession extends Emitter {
|
|||||||
private_network: false,
|
private_network: false,
|
||||||
uri: `sip:${this.req.calledNumber}@sip.pstnhub.microsoft.com`
|
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 {
|
else {
|
||||||
try {
|
try {
|
||||||
@@ -297,23 +307,18 @@ class CallSession extends Emitter {
|
|||||||
this.req.calledNumber.slice(1) :
|
this.req.calledNumber.slice(1) :
|
||||||
this.req.calledNumber;
|
this.req.calledNumber;
|
||||||
const prefix = vc.tech_prefix || '';
|
const prefix = vc.tech_prefix || '';
|
||||||
const protocol = o.protocol?.startsWith('tls') ? 'tls' : (o.protocol || 'udp');
|
const transport = o.protocol?.startsWith('tls') ? 'tls' : (o.protocol || 'udp');
|
||||||
const hostport = !o.port || 5060 === o.port ? o.ipv4 : `${o.ipv4}:${o.port}`;
|
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 prependPlus = vc.e164_leading_plus && !this.req.calledNumber.startsWith('0') ? '+' : '';
|
||||||
this.transport = `transport=${protocol}`;
|
const scheme = transport === 'tls' && !process.env.JAMBONES_USE_BEST_EFFORT_TLS && o.use_sips_scheme ?
|
||||||
const useSipsScheme = protocol === 'tls' &&
|
'sips' : 'sip';
|
||||||
!process.env.JAMBONES_USE_BEST_EFFORT_TLS &&
|
const u = `${scheme}:${prefix}${prependPlus}${calledNumber}@${hostport};transport=${transport}`;
|
||||||
o.use_sips_scheme;
|
|
||||||
|
|
||||||
if (useSipsScheme) {
|
|
||||||
this.scheme = 'sips';
|
|
||||||
}
|
|
||||||
const u = `${this.scheme}:${prefix}${prependPlus}${calledNumber}@${hostport};${this.transport}`;
|
|
||||||
const obj = {
|
const obj = {
|
||||||
name: vc.name,
|
name: vc.name,
|
||||||
diversion: vc.diversion,
|
diversion: vc.diversion,
|
||||||
hostport,
|
hostport,
|
||||||
protocol: o.protocol,
|
transport,
|
||||||
|
scheme,
|
||||||
register_from_domain: vc.register_from_domain
|
register_from_domain: vc.register_from_domain
|
||||||
};
|
};
|
||||||
if (vc.register_username && vc.register_password) {
|
if (vc.register_username && vc.register_password) {
|
||||||
@@ -324,6 +329,7 @@ class CallSession extends Emitter {
|
|||||||
}
|
}
|
||||||
mapGateways.set(u, obj);
|
mapGateways.set(u, obj);
|
||||||
uris.push(u);
|
uris.push(u);
|
||||||
|
this.logger.debug({gateway: o}, `pushed uri ${u}`);
|
||||||
if (o.protocol === 'tls/srtp') {
|
if (o.protocol === 'tls/srtp') {
|
||||||
/** TODO: this is a bit of a hack in the sense that we are not
|
/** 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
|
* supporting a scenario where you have a carrier with several outbound
|
||||||
@@ -336,7 +342,7 @@ class CallSession extends Emitter {
|
|||||||
encryptedMedia = true;
|
encryptedMedia = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
// Check private network for each gw
|
/* Check private network for each gw */
|
||||||
uris = await Promise.all(uris.map(async(u) => {
|
uris = await Promise.all(uris.map(async(u) => {
|
||||||
return {
|
return {
|
||||||
private_network: await isPrivateVoipNetwork(u),
|
private_network: await isPrivateVoipNetwork(u),
|
||||||
@@ -358,14 +364,12 @@ class CallSession extends Emitter {
|
|||||||
debug(`sending call to PSTN ${uris}`);
|
debug(`sending call to PSTN ${uris}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// private_network should be called at last
|
/* private_network should be called at last - try public first */
|
||||||
uris = uris.sort((a, b) => a.private_network - b.private_network);
|
uris = uris.sort((a, b) => a.private_network - b.private_network);
|
||||||
const toPrivate = uris.some((u) => u.private_network === true);
|
const toPrivate = uris.some((u) => u.private_network === true);
|
||||||
const toPublic = uris.some((u) => u.private_network === false);
|
const toPublic = uris.some((u) => u.private_network === false);
|
||||||
let isOfferUpdatedToPrivate = toPrivate && !toPublic;
|
let isOfferUpdatedToPrivate = toPrivate && !toPublic;
|
||||||
|
|
||||||
|
|
||||||
// rtpengine 'offer'
|
|
||||||
const opts = updateRtpEngineFlags(this.req.body, {
|
const opts = updateRtpEngineFlags(this.req.body, {
|
||||||
...this.rtpEngineOpts.common,
|
...this.rtpEngineOpts.common,
|
||||||
...this.rtpEngineOpts.uac.mediaOpts,
|
...this.rtpEngineOpts.uac.mediaOpts,
|
||||||
@@ -374,7 +378,6 @@ class CallSession extends Emitter {
|
|||||||
sdp: this.req.body
|
sdp: this.req.body
|
||||||
});
|
});
|
||||||
let response = await this.offer(opts);
|
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');
|
this.logger.debug({offer: opts, response}, 'initial offer to rtpengine');
|
||||||
if ('ok' !== response.result) {
|
if ('ok' !== response.result) {
|
||||||
this.logger.error(`rtpengine offer failed with ${JSON.stringify(response)}`);
|
this.logger.error(`rtpengine offer failed with ${JSON.stringify(response)}`);
|
||||||
@@ -387,10 +390,12 @@ class CallSession extends Emitter {
|
|||||||
// crank through the list of gateways until connected, exhausted or caller hangs up
|
// crank through the list of gateways until connected, exhausted or caller hangs up
|
||||||
let earlyMedia = false;
|
let earlyMedia = false;
|
||||||
while (uris.length) {
|
while (uris.length) {
|
||||||
let hdrs = { ...headers};
|
let hdrs = { ...headers };
|
||||||
const {private_network, uri} = uris.shift();
|
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) {
|
if (private_network && !isOfferUpdatedToPrivate) {
|
||||||
// Cannot make call to all public Uris, now come to talk with private network Uris
|
this.logger.info('switching to attempt to deliver call via private network now..');
|
||||||
this.rtpEngineResource.destroy()
|
this.rtpEngineResource.destroy()
|
||||||
.catch((err) => this.logger.info({err}, 'Error destroying rtpe to re-connect to private network'));
|
.catch((err) => this.logger.info({err}, 'Error destroying rtpe to re-connect to private network'));
|
||||||
response = await this.offer({
|
response = await this.offer({
|
||||||
@@ -399,8 +404,8 @@ class CallSession extends Emitter {
|
|||||||
});
|
});
|
||||||
isOfferUpdatedToPrivate = true;
|
isOfferUpdatedToPrivate = true;
|
||||||
}
|
}
|
||||||
const gw = mapGateways.get(uri);
|
|
||||||
const passFailure = 0 === uris.length; // only a single target
|
/* on the second and subsequent attempts, use the same Call-ID and CSeq from the first attempt */
|
||||||
if (0 === uris.length) {
|
if (0 === uris.length) {
|
||||||
try {
|
try {
|
||||||
const key = makeInviteInProgressKey(this.req.get('Call-ID'));
|
const key = makeInviteInProgressKey(this.req.get('Call-ID'));
|
||||||
@@ -416,35 +421,75 @@ class CallSession extends Emitter {
|
|||||||
this.logger.info({err}, 'Error retrieving iip key');
|
this.logger.info({err}, 'Error retrieving iip key');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// INVITE request line and To header should be the same.
|
|
||||||
hdrs = {...hdrs, 'To': uri};
|
|
||||||
if (gw) {
|
|
||||||
this.logger.info({gw}, `sending INVITE to ${uri} via carrier ${gw.name}`);
|
|
||||||
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 this.logger.info(`sending INVITE to ${uri} via proxy ${proxy})`);
|
|
||||||
try {
|
|
||||||
if (this.privateSipAddress) {
|
|
||||||
this.contactHeader = `<${this.scheme}:${this.privateSipAddress}>`;
|
|
||||||
}
|
|
||||||
const responseHeaders = this.privateSipAddress ? {Contact: this.contactHeader} : {};
|
|
||||||
|
|
||||||
|
/* 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,
|
||||||
|
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`
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
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
|
||||||
|
try {
|
||||||
const {uas, uac} = await this.srf.createB2BUA(this.req, this.res, uri, {
|
const {uas, uac} = await this.srf.createB2BUA(this.req, this.res, uri, {
|
||||||
proxy,
|
proxy,
|
||||||
passFailure,
|
passFailure,
|
||||||
@@ -470,7 +515,6 @@ class CallSession extends Emitter {
|
|||||||
'-Session-Expires'
|
'-Session-Expires'
|
||||||
],
|
],
|
||||||
headers: hdrs,
|
headers: hdrs,
|
||||||
responseHeaders,
|
|
||||||
auth: gw ? gw.auth : undefined,
|
auth: gw ? gw.auth : undefined,
|
||||||
localSdpB: response.sdp,
|
localSdpB: response.sdp,
|
||||||
localSdpA: async(sdp, res) => {
|
localSdpA: async(sdp, res) => {
|
||||||
@@ -523,6 +567,9 @@ class CallSession extends Emitter {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error({err}, 'Error saving Call-ID/CSeq');
|
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) => {
|
cbProvisional: (response) => {
|
||||||
if (!earlyMedia && [180, 183].includes(response.status) && response.body) earlyMedia = true;
|
if (!earlyMedia && [180, 183].includes(response.status) && response.body) earlyMedia = true;
|
||||||
@@ -795,7 +842,7 @@ Duration=${payload.duration} `
|
|||||||
sdp
|
sdp
|
||||||
};
|
};
|
||||||
response = await this.answer(opts);
|
response = await this.answer(opts);
|
||||||
/* now remove asymeetric as B party (looking at you Genesys ring group) may need port re-learning on invites */
|
/* 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');
|
answerMedia.flags = answerMedia.flags.filter((f) => f !== 'asymmetric');
|
||||||
if ('ok' !== response.result) {
|
if ('ok' !== response.result) {
|
||||||
res.send(488);
|
res.send(488);
|
||||||
@@ -1049,25 +1096,15 @@ Duration=${payload.duration} `
|
|||||||
const referredBy = req.getParsedHeader('Referred-By');
|
const referredBy = req.getParsedHeader('Referred-By');
|
||||||
if (!referredBy) return res.send(400);
|
if (!referredBy) return res.send(400);
|
||||||
const u = parseUri(referredBy.uri);
|
const u = parseUri(referredBy.uri);
|
||||||
const farEnd = parseUri(this.connectedUri);
|
|
||||||
uri.host = farEnd.host;
|
|
||||||
uri.port = farEnd.port;
|
|
||||||
this.scheme = farEnd.scheme;
|
|
||||||
|
|
||||||
if (farEnd.params?.transport) {
|
/* delete contact if it was there from feature server */
|
||||||
this.transport = `transport=${farEnd.params.transport}`;
|
|
||||||
}
|
|
||||||
/* TODO: we receive a lowercase 'contact' from feature-server */
|
|
||||||
delete customHeaders['contact'];
|
delete customHeaders['contact'];
|
||||||
|
|
||||||
this.referContactHeader = `<${this.scheme}:${uri.user}@${uri.host}:${uri.port}>;${this.transport}`;
|
|
||||||
const response = await this.uac.request({
|
const response = await this.uac.request({
|
||||||
method: 'REFER',
|
method: 'REFER',
|
||||||
headers: {
|
headers: {
|
||||||
// Make sure the uri is protected by <> if uri is complex form
|
|
||||||
'Refer-To': `<${stringifyUri(uri)}>`,
|
'Refer-To': `<${stringifyUri(uri)}>`,
|
||||||
'Referred-By': `<${stringifyUri(u)}>`,
|
'Referred-By': `<${stringifyUri(u)}>`,
|
||||||
'Contact': this.referContactHeader,
|
|
||||||
...customHeaders
|
...customHeaders
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
4995
package-lock.json
generated
4995
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -27,8 +27,8 @@
|
|||||||
"jslint": "eslint app.js lib --fix"
|
"jslint": "eslint app.js lib --fix"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jambonz/db-helpers": "^0.9.3",
|
"@jambonz/db-helpers": "^0.9.6",
|
||||||
"@jambonz/realtimedb-helpers": "^0.8.8",
|
"@jambonz/realtimedb-helpers": "^0.8.9",
|
||||||
"@jambonz/http-health-check": "^0.0.1",
|
"@jambonz/http-health-check": "^0.0.1",
|
||||||
"@jambonz/mw-registrar": "0.2.7",
|
"@jambonz/mw-registrar": "0.2.7",
|
||||||
"@jambonz/rtpengine-utils": "^0.4.4",
|
"@jambonz/rtpengine-utils": "^0.4.4",
|
||||||
|
|||||||
Reference in New Issue
Block a user