mirror of
https://github.com/jambonz/sbc-inbound.git
synced 2026-10-04 02:04:22 +00:00
Compare commits
35
Commits
v0.7.0
...
v0.7.5-rc6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7dd4d4a045 | ||
|
|
7382bf6bd6 | ||
|
|
abfce38150 | ||
|
|
d8035c978e | ||
|
|
91cd677ad8 | ||
|
|
17b53438e2 | ||
|
|
f1e2f192b7 | ||
|
|
0af8bcc348 | ||
|
|
e067125974 | ||
|
|
2dadde64f4 | ||
|
|
d5a1337811 | ||
|
|
a6571134cd | ||
|
|
e0e5c75496 | ||
|
|
c08e35c261 | ||
|
|
883c63723c | ||
|
|
13f78bf8d9 | ||
|
|
55056c1771 | ||
|
|
87ec5f8e09 | ||
|
|
317280befc | ||
|
|
7b97a0a137 | ||
|
|
75d8381ebb | ||
|
|
7750fdc3f2 | ||
|
|
fcf90916e1 | ||
|
|
4a0aa79456 | ||
|
|
1cdaae7773 | ||
|
|
82ac86389d | ||
|
|
e9184ad208 | ||
|
|
804bb890b5 | ||
|
|
a892a87eb5 | ||
|
|
56205dc852 | ||
|
|
927b7be637 | ||
|
|
63b482e562 | ||
|
|
a7d047a7e8 | ||
|
|
ea4f5ea0a8 | ||
|
|
13d8ee8c3c |
+1
-1
@@ -8,7 +8,7 @@
|
||||
"jsx": false,
|
||||
"modules": false
|
||||
},
|
||||
"ecmaVersion": 2018
|
||||
"ecmaVersion": 2020
|
||||
},
|
||||
"plugins": ["promise"],
|
||||
"rules": {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
name: Docker
|
||||
|
||||
on:
|
||||
push:
|
||||
# Publish `master` as Docker `latest` image.
|
||||
branches:
|
||||
- main
|
||||
|
||||
# Publish `v1.2.3` tags as releases.
|
||||
tags:
|
||||
- v*
|
||||
|
||||
env:
|
||||
IMAGE_NAME: sbc-inbound
|
||||
|
||||
jobs:
|
||||
push:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'push'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Build image
|
||||
run: docker build . --file Dockerfile --tag $IMAGE_NAME
|
||||
|
||||
- name: Log into registry
|
||||
run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
|
||||
|
||||
- name: Push image
|
||||
run: |
|
||||
IMAGE_ID=ghcr.io/${{ github.repository_owner }}/$IMAGE_NAME
|
||||
|
||||
# Change all uppercase to lowercase
|
||||
IMAGE_ID=$(echo $IMAGE_ID | tr '[A-Z]' '[a-z]')
|
||||
|
||||
# Strip git ref prefix from version
|
||||
VERSION=$(echo "${{ github.ref }}" | sed -e 's,.*/\(.*\),\1,')
|
||||
|
||||
# Strip "v" prefix from tag name
|
||||
[[ "${{ github.ref }}" == "refs/tags/"* ]] && VERSION=$(echo $VERSION | sed -e 's/^v//')
|
||||
|
||||
# Use Docker `latest` tag convention
|
||||
[ "$VERSION" == "main" ] && VERSION=latest
|
||||
|
||||
echo IMAGE_ID=$IMAGE_ID
|
||||
echo VERSION=$VERSION
|
||||
|
||||
docker tag $IMAGE_NAME $IMAGE_ID:$VERSION
|
||||
docker push $IMAGE_ID:$VERSION
|
||||
@@ -11,8 +11,8 @@ jobs:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 12
|
||||
- run: npm install
|
||||
node-version: 14.x
|
||||
- run: npm ci
|
||||
- run: npm run jslint
|
||||
- run: npm test
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
.vscode/
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
npm run jslint
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
FROM node:16
|
||||
FROM node:slim
|
||||
WORKDIR /opt/app/
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
RUN npm prune
|
||||
COPY . /opt/app
|
||||
ARG NODE_ENV
|
||||
|
||||
@@ -6,27 +6,28 @@ assert.ok(process.env.JAMBONES_MYSQL_HOST &&
|
||||
assert.ok(process.env.DRACHTIO_PORT || process.env.DRACHTIO_HOST, 'missing DRACHTIO_PORT env var');
|
||||
assert.ok(process.env.DRACHTIO_SECRET, 'missing DRACHTIO_SECRET env var');
|
||||
assert.ok(process.env.JAMBONES_TIME_SERIES_HOST, 'missing JAMBONES_TIME_SERIES_HOST env var');
|
||||
assert.ok(process.env.JAMBONES_NETWORK_CIDR, 'missing JAMBONES_NETWORK_CIDR env var');
|
||||
assert.ok(process.env.JAMBONES_NETWORK_CIDR || process.env.K8S, 'missing JAMBONES_NETWORK_CIDR env var');
|
||||
const Srf = require('drachtio-srf');
|
||||
const srf = new Srf('sbc-inbound');
|
||||
const CIDRMatcher = require('cidr-matcher');
|
||||
const matcher = new CIDRMatcher([process.env.JAMBONES_NETWORK_CIDR]);
|
||||
const opts = Object.assign({
|
||||
timestamp: () => {return `, "time": "${new Date().toISOString()}"`;}
|
||||
}, {level: process.env.JAMBONES_LOGLEVEL || 'info'});
|
||||
const logger = require('pino')(opts);
|
||||
const {
|
||||
writeCallCount,
|
||||
queryCdrs,
|
||||
writeCdrs,
|
||||
writeAlerts,
|
||||
AlertType
|
||||
} = require('@jambonz/time-series')(logger, {
|
||||
host: process.env.JAMBONES_TIME_SERIES_HOST,
|
||||
port: process.env.JAMBONES_TIME_SERIES_PORT || 8086,
|
||||
commitSize: 50,
|
||||
commitInterval: 'test' === process.env.NODE_ENV ? 7 : 20
|
||||
});
|
||||
const StatsCollector = require('@jambonz/stats-collector');
|
||||
const stats = new StatsCollector(logger);
|
||||
const {equalsIgnoreOrder, createHealthCheckApp, systemHealth} = require('./lib/utils');
|
||||
const {LifeCycleEvents} = require('./lib/constants');
|
||||
const setNameRtp = `${(process.env.JAMBONES_CLUSTER_ID || 'default')}:active-rtp`;
|
||||
const rtpServers = [];
|
||||
@@ -34,6 +35,7 @@ const setName = `${(process.env.JAMBONES_CLUSTER_ID || 'default')}:active-sip`;
|
||||
|
||||
const {
|
||||
pool,
|
||||
ping,
|
||||
lookupAuthHook,
|
||||
lookupSipGatewayBySignalingAddress,
|
||||
addSbcAddress,
|
||||
@@ -49,17 +51,26 @@ const {
|
||||
database: process.env.JAMBONES_MYSQL_DATABASE,
|
||||
connectionLimit: process.env.JAMBONES_MYSQL_CONNECTION_LIMIT || 10
|
||||
}, logger);
|
||||
const {createSet, retrieveSet, addToSet, removeFromSet, incrKey, decrKey} = require('@jambonz/realtimedb-helpers')({
|
||||
const {
|
||||
client: redisClient,
|
||||
createSet,
|
||||
retrieveSet,
|
||||
addToSet,
|
||||
removeFromSet,
|
||||
incrKey,
|
||||
decrKey} = require('@jambonz/realtimedb-helpers')({
|
||||
host: process.env.JAMBONES_REDIS_HOST || 'localhost',
|
||||
port: process.env.JAMBONES_REDIS_PORT || 6379
|
||||
}, logger);
|
||||
|
||||
const {getRtpEngine, setRtpEngines} = require('@jambonz/rtpengine-utils')([], logger, {
|
||||
emitter: stats,
|
||||
dtmfListenPort: process.env.DTMF_LISTEN_PORT || 22224
|
||||
dtmfListenPort: process.env.DTMF_LISTEN_PORT || 22224,
|
||||
protocol: 'udp'
|
||||
});
|
||||
srf.locals = {...srf.locals,
|
||||
stats,
|
||||
writeCallCount,
|
||||
queryCdrs,
|
||||
writeCdrs,
|
||||
writeAlerts,
|
||||
@@ -68,6 +79,7 @@ srf.locals = {...srf.locals,
|
||||
getRtpEngine,
|
||||
dbHelpers: {
|
||||
pool,
|
||||
ping,
|
||||
lookupAuthHook,
|
||||
lookupSipGatewayBySignalingAddress,
|
||||
lookupAccountByPhoneNumber,
|
||||
@@ -105,7 +117,13 @@ const {
|
||||
} = require('./lib/middleware')(srf, logger);
|
||||
const CallSession = require('./lib/call-session');
|
||||
|
||||
if (process.env.DRACHTIO_HOST) {
|
||||
if (process.env.DRACHTIO_HOST && !process.env.K8S) {
|
||||
const CIDRMatcher = require('cidr-matcher');
|
||||
const cidrs = process.env.JAMBONES_NETWORK_CIDR
|
||||
.split(',')
|
||||
.map((s) => s.trim());
|
||||
const matcher = new CIDRMatcher(cidrs);
|
||||
|
||||
srf.connect({host: process.env.DRACHTIO_HOST, port: process.env.DRACHTIO_PORT, secret: process.env.DRACHTIO_SECRET });
|
||||
srf.on('connect', (err, hp) => {
|
||||
if (err) return this.logger.error({err}, 'Error connecting to drachtio server');
|
||||
@@ -131,6 +149,9 @@ if (process.env.DRACHTIO_HOST) {
|
||||
});
|
||||
}
|
||||
else {
|
||||
srf.on('listening', () => {
|
||||
logger.info(`listening in outbound mode on port ${process.env.DRACHTIO_PORT}`);
|
||||
});
|
||||
srf.listen({port: process.env.DRACHTIO_PORT, secret: process.env.DRACHTIO_SECRET});
|
||||
}
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
@@ -163,33 +184,78 @@ srf.use((req, res, next, err) => {
|
||||
res.send(500);
|
||||
});
|
||||
|
||||
/* update call stats periodically */
|
||||
setInterval(() => {
|
||||
stats.gauge('sbc.sip.calls.count', activeCallIds.size, ['direction:inbound']);
|
||||
}, 20000);
|
||||
if (process.env.K8S || process.env.HTTP_PORT) {
|
||||
const PORT = process.env.HTTP_PORT || 3000;
|
||||
const healthCheck = require('@jambonz/http-health-check');
|
||||
|
||||
const arrayCompare = (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 getCount = () => srf.locals.activeCallIds.size;
|
||||
|
||||
createHealthCheckApp(PORT, logger)
|
||||
.then((app) => {
|
||||
healthCheck({
|
||||
app,
|
||||
logger,
|
||||
path: '/',
|
||||
fn: getCount
|
||||
});
|
||||
healthCheck({
|
||||
app,
|
||||
logger,
|
||||
path: '/system-health',
|
||||
fn: systemHealth.bind(null, redisClient, ping, getCount)
|
||||
});
|
||||
return;
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error({err}, 'Error creating health check server');
|
||||
});
|
||||
}
|
||||
if ('test' !== process.env.NODE_ENV) {
|
||||
/* update call stats periodically */
|
||||
setInterval(() => {
|
||||
stats.gauge('sbc.sip.calls.count', activeCallIds.size, ['direction:inbound']);
|
||||
}, 20000);
|
||||
}
|
||||
|
||||
const lookupRtpServiceEndpoints = (lookup, serviceName) => {
|
||||
logger.debug(`dns lookup for ${serviceName}..`);
|
||||
lookup(serviceName, {family: 4, all: true}, (err, addresses) => {
|
||||
if (err) {
|
||||
logger.error({err}, `Error looking up ${serviceName}`);
|
||||
return;
|
||||
}
|
||||
logger.debug({addresses, rtpServers}, `dns lookup for ${serviceName} returned`);
|
||||
const addrs = addresses.map((a) => a.address);
|
||||
if (!equalsIgnoreOrder(addrs, rtpServers)) {
|
||||
rtpServers.length = 0;
|
||||
Array.prototype.push.apply(rtpServers, addrs);
|
||||
logger.info({rtpServers}, 'rtpserver endpoints have been updated');
|
||||
setRtpEngines(rtpServers.map((a) => `${a}:${process.env.RTPENGINE_PORT || 22222}`));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/* update rtpengines periodically */
|
||||
if (process.env.JAMBONES_RTPENGINES) {
|
||||
if (process.env.K8S_RTPENGINE_SERVICE_NAME) {
|
||||
/* poll dns for endpoints every so often */
|
||||
const arr = /^(.*):(\d+)$/.exec(process.env.K8S_RTPENGINE_SERVICE_NAME);
|
||||
const svc = arr[1];
|
||||
logger.info(`rtpengine(s) will be found at dns name: ${svc}`);
|
||||
const {lookup} = require('dns');
|
||||
lookupRtpServiceEndpoints(lookup, svc);
|
||||
setInterval(lookupRtpServiceEndpoints.bind(null, lookup, svc), process.env.RTPENGINE_DNS_POLL_INTERVAL || 10000);
|
||||
}
|
||||
else if (process.env.JAMBONES_RTPENGINES) {
|
||||
/* static list of rtpengines */
|
||||
setRtpEngines([process.env.JAMBONES_RTPENGINES]);
|
||||
}
|
||||
else {
|
||||
/* poll redis periodically for rtpengines that have registered via OPTIONS ping */
|
||||
const getActiveRtpServers = async() => {
|
||||
try {
|
||||
const set = await retrieveSet(setNameRtp);
|
||||
const newArray = Array.from(set);
|
||||
logger.debug({newArray, rtpServers}, 'getActiveRtpServers');
|
||||
if (!arrayCompare(newArray, rtpServers)) {
|
||||
if (!equalsIgnoreOrder(newArray, rtpServers)) {
|
||||
logger.info({newArray}, 'resetting active rtpengines');
|
||||
setRtpEngines(newArray.map((a) => `${a}:${process.env.RTPENGINE_PORT || 22222}`));
|
||||
rtpServers.length = 0;
|
||||
@@ -199,11 +265,11 @@ else {
|
||||
logger.error({err}, 'Error setting new rtpengines');
|
||||
}
|
||||
};
|
||||
|
||||
setInterval(() => {
|
||||
getActiveRtpServers();
|
||||
}, 30000);
|
||||
getActiveRtpServers();
|
||||
|
||||
}
|
||||
|
||||
const {lifecycleEmitter} = require('./lib/autoscale-manager')(logger);
|
||||
@@ -218,4 +284,12 @@ setInterval(async() => {
|
||||
}
|
||||
}, 20000);
|
||||
|
||||
process.on('SIGUSR2', handle.bind(null, removeFromSet, setName));
|
||||
process.on('SIGTERM', handle.bind(null, removeFromSet, setName));
|
||||
|
||||
function handle(removeFromSet, setName, signal) {
|
||||
logger.info(`got signal ${signal}, removing ${srf.locals.privateSipAddress} from set ${setName}`);
|
||||
removeFromSet(setName, srf.locals.privateSipAddress);
|
||||
}
|
||||
|
||||
module.exports = {srf, logger};
|
||||
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env node
|
||||
const bent = require('bent');
|
||||
const getJSON = bent('json');
|
||||
const PORT = process.env.HTTP_PORT || 3000;
|
||||
|
||||
const sleep = (ms) => {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
};
|
||||
|
||||
(async function() {
|
||||
|
||||
try {
|
||||
do {
|
||||
const obj = await getJSON(`http://127.0.0.1:${PORT}/`);
|
||||
const {calls} = obj;
|
||||
if (calls === 0) {
|
||||
console.log('no calls on the system, we can exit');
|
||||
process.exit(0);
|
||||
}
|
||||
else {
|
||||
console.log(`waiting for ${calls} to exit..`);
|
||||
}
|
||||
await sleep(10000);
|
||||
} while (1);
|
||||
} catch (err) {
|
||||
console.error(err, 'Error querying health endpoint');
|
||||
process.exit(-1);
|
||||
}
|
||||
})();
|
||||
+54
-18
@@ -128,7 +128,12 @@ class CallSession extends Emitter {
|
||||
if (this.privateSipAddress) headers = {...headers, Contact: `<sip:${this.privateSipAddress}>`};
|
||||
|
||||
const responseHeaders = {};
|
||||
if (this.req.locals.carrier) Object.assign(headers, {'X-Originating-Carrier': this.req.locals.carrier});
|
||||
if (this.req.locals.carrier) {
|
||||
Object.assign(headers, {
|
||||
'X-Originating-Carrier': this.req.locals.carrier,
|
||||
'X-Voip-Carrier-Sid': this.req.locals.voip_carrier_sid
|
||||
});
|
||||
}
|
||||
if (this.req.locals.msTeamsTenantFqdn) {
|
||||
Object.assign(headers, {'X-MS-Teams-Tenant-FQDN': this.req.locals.msTeamsTenantFqdn});
|
||||
|
||||
@@ -166,7 +171,7 @@ class CallSession extends Emitter {
|
||||
'-Session-Expires',
|
||||
'-X-Subspace-Forwarded-For'
|
||||
],
|
||||
proxyResponseHeaders: ['all'],
|
||||
proxyResponseHeaders: ['all', '-X-Trace-ID'],
|
||||
localSdpB: response.sdp,
|
||||
localSdpA: async(sdp, res) => {
|
||||
this.rtpEngineOpts.uac.tag = res.getParsedHeader('To').params.tag;
|
||||
@@ -192,18 +197,19 @@ class CallSession extends Emitter {
|
||||
this._setHandlers({uas, uac});
|
||||
return;
|
||||
} catch (err) {
|
||||
this.rtpEngineResource.destroy();
|
||||
this.rtpEngineResource.destroy().catch((err) => this.logger.info({err}, 'Error destroying rtpe after failure'));
|
||||
this.activeCallIds.delete(this.req.get('Call-ID'));
|
||||
this.stats.gauge('sbc.sip.calls.count', this.activeCallIds.size);
|
||||
if (err instanceof SipError) {
|
||||
const tags = ['accepted:no', `sipStatus:${err.status}`, `originator:${this.req.locals.originator}`];
|
||||
this.stats.increment('sbc.terminations', tags);
|
||||
this.logger.info(`call failed to connect to feature server with ${err.status}`);
|
||||
return this.emit('failed');
|
||||
this.emit('failed');
|
||||
}
|
||||
else if (err.message !== 'call canceled') {
|
||||
this.logger.error(err, 'unexpected error routing inbound call');
|
||||
}
|
||||
this.srf.endSession(this.req);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,6 +224,7 @@ class CallSession extends Emitter {
|
||||
this.rtpEngineResource.destroy().catch((err) => {});
|
||||
this.activeCallIds.delete(callId);
|
||||
if (dlg.other && dlg.other.connected) dlg.other.destroy().catch((e) => {});
|
||||
this.srf.endSession(this.req);
|
||||
});
|
||||
|
||||
//re-invite
|
||||
@@ -234,22 +241,30 @@ class CallSession extends Emitter {
|
||||
this.req.locals.cdr = {
|
||||
...this.req.locals.cdr,
|
||||
answered: true,
|
||||
answered_at: callStart
|
||||
answered_at: callStart,
|
||||
trace_id: uac.res?.get('X-Trace-ID') || '00000000000000000000000000000000'
|
||||
};
|
||||
}
|
||||
this.uas = uas;
|
||||
this.uac = uac;
|
||||
[uas, uac].forEach((dlg) => {
|
||||
dlg.on('destroy', () => {
|
||||
this.logger.info('call ended with normal termination');
|
||||
dlg.on('destroy', async() => {
|
||||
const other = dlg.other;
|
||||
this.rtpEngineResource.destroy().catch((err) => {});
|
||||
this.activeCallIds.delete(this.req.get('Call-ID'));
|
||||
dlg.other.destroy().catch((e) => {});
|
||||
try {
|
||||
await other.destroy();
|
||||
} catch (err) {}
|
||||
this.unsubscribeDTMF(this.logger, this.req.get('Call-ID'), this.rtpEngineOpts.uas.tag);
|
||||
if (process.env.JAMBONES_HOSTING) {
|
||||
if (process.env.JAMBONES_HOSTING || process.env.JAMBONES_TRACK_ACCOUNT_CALLS) {
|
||||
const {account_sid} = this.req.locals;
|
||||
this.decrKey(this.callCountKey)
|
||||
.then((count) => this.logger.debug({key: this.callCountKey},
|
||||
`after hangup there are ${count} active calls for this account`))
|
||||
.then((count) => {
|
||||
this.logger.info(
|
||||
{key: this.callCountKey},
|
||||
`after hangup there are ${count} active calls for this account`);
|
||||
return this.req.srf.locals.writeCallCount({account_sid, calls_in_progress: count});
|
||||
})
|
||||
.catch((err) => this.logger.error({err}, 'Error decrementing call count'));
|
||||
}
|
||||
|
||||
@@ -267,6 +282,14 @@ class CallSession extends Emitter {
|
||||
trunk
|
||||
}).catch((err) => this.logger.error({err}, 'Error writing cdr for completed call'));
|
||||
}
|
||||
/* de-link the 2 Dialogs for GC */
|
||||
dlg.removeAllListeners();
|
||||
other.removeAllListeners();
|
||||
dlg.other = null;
|
||||
other.other = null;
|
||||
|
||||
this.logger.info(`call ended with normal termination, there are ${this.activeCallIds.size} active`);
|
||||
this.srf.endSession(this.req);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -321,7 +344,7 @@ Duration=${payload.duration} `
|
||||
*/
|
||||
async replaces(req, res) {
|
||||
try {
|
||||
let opts = Object.assign(this.rtpEngineOpts.offer, {sdp: req.body});
|
||||
let opts = Object.assign({}, this.rtpEngineOpts.uas.mediaOpts, {sdp: req.body});
|
||||
let response = await this.offer(opts);
|
||||
if ('ok' !== response.result) {
|
||||
res.send(488);
|
||||
@@ -329,8 +352,8 @@ Duration=${payload.duration} `
|
||||
}
|
||||
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});
|
||||
Object.assign(this.rtpEngineOpts.offer, {'to-tag': this.toTag});
|
||||
opts = Object.assign({}, this.rtpEngineOpts.uac.mediaOpts, {sdp, 'to-tag': this.toTag});
|
||||
Object.assign(this.rtpEngineOpts.uas.mediaOpts, {'to-tag': this.toTag});
|
||||
response = await this.answer(opts);
|
||||
if ('ok' !== response.result) {
|
||||
res.send(488);
|
||||
@@ -390,7 +413,7 @@ Duration=${payload.duration} `
|
||||
direction,
|
||||
sdp: req.body,
|
||||
};
|
||||
if (reason) opts.flags.push('reset');
|
||||
//if (reason && opts.flags && !opts.flags.includes('reset')) opts.flags.push('reset');
|
||||
|
||||
let response = await this.offer(opts);
|
||||
if ('ok' !== response.result) {
|
||||
@@ -401,7 +424,7 @@ Duration=${payload.duration} `
|
||||
/* if this is a re-invite from the FS to change media anchoring, avoid sending the reinvite out */
|
||||
let sdp;
|
||||
if (reason && dlg.type === 'uac' && ['release-media', 'anchor-media'].includes(reason)) {
|
||||
this.logger.info(`got a reinvite from FS to ${reason}`);
|
||||
this.logger.info({response}, `got a reinvite from FS to ${reason}`);
|
||||
sdp = dlg.other.remote.sdp;
|
||||
}
|
||||
else {
|
||||
@@ -524,6 +547,7 @@ Duration=${payload.duration} `
|
||||
this.rtpEngineResource.destroy();
|
||||
this.activeCallIds.delete(this.req.get('Call-ID'));
|
||||
uac.other.destroy();
|
||||
this.srf.endSession(this.req);
|
||||
});
|
||||
// now we can destroy the old dialog
|
||||
dlg.destroy().catch(() => {});
|
||||
@@ -609,8 +633,20 @@ Duration=${payload.duration} `
|
||||
}
|
||||
}
|
||||
else {
|
||||
// TODO: forward on to feature server
|
||||
res.send(501);
|
||||
/* REFER coming in from a sip device, forward to feature server */
|
||||
try {
|
||||
const response = await dlg.other.request({
|
||||
method: 'REFER',
|
||||
headers: {
|
||||
'Refer-To': req.get('Refer-To'),
|
||||
'Referred-By': req.get('Referred-By'),
|
||||
'User-Agent': req.get('User-Agent')
|
||||
}
|
||||
});
|
||||
res.send(response.status, response.reason);
|
||||
} catch (err) {
|
||||
this.logger.error({err}, 'CallSession:_onRefer: error handling incoming REFER');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+34
-4
@@ -11,6 +11,15 @@ WHERE acc.sip_realm = ?
|
||||
AND vc.account_sid = acc.account_sid
|
||||
AND sg.voip_carrier_sid = vc.voip_carrier_sid`;
|
||||
|
||||
const sqlSelectAllCarriersForSPByRealm =
|
||||
`SELECT sg.sip_gateway_sid, sg.voip_carrier_sid, vc.name, vc.account_sid,
|
||||
vc.application_sid, sg.inbound, sg.outbound, sg.is_active, sg.ipv4, sg.netmask
|
||||
FROM sip_gateways sg, voip_carriers vc, accounts acc
|
||||
WHERE acc.sip_realm = ?
|
||||
AND vc.service_provider_sid = acc.service_provider_sid
|
||||
AND vc.account_sid IS NULL
|
||||
AND sg.voip_carrier_sid = vc.voip_carrier_sid`;
|
||||
|
||||
const sqlSelectAllGatewaysForSP =
|
||||
`SELECT sg.sip_gateway_sid, sg.voip_carrier_sid, vc.name, vc.service_provider_sid,
|
||||
vc.application_sid, sg.inbound, sg.outbound, sg.is_active, sg.ipv4, sg.netmask
|
||||
@@ -93,7 +102,11 @@ module.exports = (srf, logger) => {
|
||||
const [r] = await pp.query(sqlCarriersForAccountBySid,
|
||||
[process.env.SBC_ACCOUNT_SID, req.source_address, req.source_port]);
|
||||
if (0 === r.length) return failure;
|
||||
return {fromCarrier: true, gateway: r[0], account_sid: process.env.SBC_ACCOUNT_SID};
|
||||
return {
|
||||
fromCarrier: true,
|
||||
gateway: r[0],
|
||||
account_sid: process.env.SBC_ACCOUNT_SID
|
||||
};
|
||||
}
|
||||
else {
|
||||
/* we may have a carrier at the service provider level */
|
||||
@@ -108,8 +121,23 @@ module.exports = (srf, logger) => {
|
||||
|
||||
const [r] = await pp.query(sql);
|
||||
if (0 === r.length) {
|
||||
/* came from a carrier, but number is not provisioned */
|
||||
return {fromCarrier: true};
|
||||
/* came from a carrier, but number is not provisioned..
|
||||
check if we only have a single account, otherwise we have no
|
||||
way of knowing which account this is for
|
||||
*/
|
||||
const [r] = await pp.query('SELECT count(*) as count from accounts where service_provider_sid = ?',
|
||||
matches[0].service_provider_sid);
|
||||
if (r[0].count === 0) return {fromCarrier: true};
|
||||
else {
|
||||
const [accounts] = await pp.query('SELECT * from accounts where service_provider_sid = ?',
|
||||
matches[0].service_provider_sid);
|
||||
return {
|
||||
fromCarrier: true,
|
||||
gateway: matches[0],
|
||||
account_sid: accounts[0].account_sid,
|
||||
account: accounts[0]
|
||||
};
|
||||
}
|
||||
}
|
||||
const gateway = matches.find((m) => m.voip_carrier_sid === r[0].voip_carrier_sid);
|
||||
const [accounts] = await pp.query(sqlAccountBySid, r[0].account_sid);
|
||||
@@ -127,7 +155,9 @@ module.exports = (srf, logger) => {
|
||||
}
|
||||
|
||||
/* get all the carriers and gateways for the account owning this sip realm */
|
||||
const [gw] = await pp.query(sqlSelectAllCarriersForAccountByRealm, uri.host);
|
||||
const [gwAcc] = await pp.query(sqlSelectAllCarriersForAccountByRealm, uri.host);
|
||||
const [gwSP] = gwAcc.length ? [[]] : await pp.query(sqlSelectAllCarriersForSPByRealm, uri.host);
|
||||
const gw = gwAcc.concat(gwSP);
|
||||
const selected = gw.find(gatewayMatchesSourceAddress.bind(null, req.source_address));
|
||||
if (selected) {
|
||||
const [a] = await pp.query(sqlAccountByRealm, uri.host);
|
||||
|
||||
+15
-6
@@ -1,4 +1,8 @@
|
||||
const setName = `${(process.env.JAMBONES_CLUSTER_ID || 'default')}:active-fs`;
|
||||
const assert = require('assert');
|
||||
|
||||
assert.ok(!process.env.K8S || process.env.K8S_FEATURE_SERVER_SERVICE_NAME,
|
||||
'when running in Kubernetes, an env var K8S_FEATURE_SERVER_SERVICE_NAME is required');
|
||||
|
||||
module.exports = (srf, logger) => {
|
||||
const {retrieveSet, createSet} = srf.locals.realtimeDbHelpers;
|
||||
@@ -10,13 +14,18 @@ module.exports = (srf, logger) => {
|
||||
|
||||
return async() => {
|
||||
try {
|
||||
const fs = await retrieveSet(setName);
|
||||
if (0 === fs.length) {
|
||||
logger.info('No available feature servers to handle incoming call');
|
||||
return;
|
||||
if (process.env.K8S) {
|
||||
return process.env.K8S_FEATURE_SERVER_SERVICE_NAME;
|
||||
}
|
||||
else {
|
||||
const fs = await retrieveSet(setName);
|
||||
if (0 === fs.length) {
|
||||
logger.info('No available feature servers to handle incoming call');
|
||||
return;
|
||||
}
|
||||
logger.debug({fs}, `retrieved ${setName}`);
|
||||
return fs[idx++ % fs.length];
|
||||
}
|
||||
logger.debug({fs}, `retrieved ${setName}`);
|
||||
return fs[idx++ % fs.length];
|
||||
} catch (err) {
|
||||
logger.error({err}, `Error retrieving ${setName}`);
|
||||
}
|
||||
|
||||
+27
-9
@@ -112,7 +112,13 @@ module.exports = function(srf, logger) {
|
||||
const identifyAccount = async(req, res, next) => {
|
||||
try {
|
||||
const {wasOriginatedFromCarrier, getApplicationForDidAndCarrier} = req.srf.locals;
|
||||
const {fromCarrier, gateway, account_sid, application_sid, account} = await wasOriginatedFromCarrier(req);
|
||||
const {
|
||||
fromCarrier,
|
||||
gateway,
|
||||
account_sid,
|
||||
application_sid,
|
||||
account
|
||||
} = await wasOriginatedFromCarrier(req);
|
||||
/**
|
||||
* calls come from 3 sources:
|
||||
* (1) A carrier
|
||||
@@ -132,6 +138,7 @@ module.exports = function(srf, logger) {
|
||||
originator: 'trunk',
|
||||
carrier: gateway.name,
|
||||
gateway,
|
||||
voip_carrier_sid: gateway.voip_carrier_sid,
|
||||
application_sid: sid || gateway.application_sid,
|
||||
account_sid,
|
||||
account,
|
||||
@@ -145,7 +152,8 @@ module.exports = function(srf, logger) {
|
||||
const app = await lookupAppByTeamsTenant(uri.host);
|
||||
if (!app) {
|
||||
stats.increment('sbc.terminations', ['sipStatus:404']);
|
||||
return res.send(404, {headers: {'X-Reason': 'no configured application'}});
|
||||
res.send(404, {headers: {'X-Reason': 'no configured application'}});
|
||||
return req.srf.endSession(req);
|
||||
}
|
||||
|
||||
req.locals = {
|
||||
@@ -164,7 +172,8 @@ module.exports = function(srf, logger) {
|
||||
const account = await lookupAccountBySipRealm(uri.host);
|
||||
if (!account) {
|
||||
stats.increment('sbc.terminations', ['sipStatus:404']);
|
||||
return res.send(404);
|
||||
res.send(404);
|
||||
return req.srf.endSession(req);
|
||||
}
|
||||
|
||||
/* if this is a dedicated SBC (static IP) only take calls for that account's sip realm */
|
||||
@@ -173,7 +182,8 @@ module.exports = function(srf, logger) {
|
||||
`identifyAccount: static IP for ${process.env.SBC_ACCOUNT_SID} but call for ${account.account_sid}`);
|
||||
stats.increment('sbc.terminations', ['sipStatus:404']);
|
||||
delete req.locals.cdr;
|
||||
return res.send(404);
|
||||
res.send(404);
|
||||
return req.srf.endSession(req);
|
||||
}
|
||||
req.locals = {
|
||||
account_sid: account.account_sid,
|
||||
@@ -210,11 +220,11 @@ module.exports = function(srf, logger) {
|
||||
};
|
||||
|
||||
const checkLimits = async(req, res, next) => {
|
||||
if (!process.env.JAMBONES_HOSTING) return next(); // skip
|
||||
if (!process.env.JAMBONES_HOSTING && !process.env.JAMBONES_TRACK_ACCOUNT_CALLS) return next(); // skip
|
||||
|
||||
const {incrKey, decrKey} = req.srf.locals.realtimeDbHelpers;
|
||||
const {logger, account_sid} = req.locals;
|
||||
const {writeAlerts, AlertType} = req.srf.locals;
|
||||
const {writeCallCount, writeAlerts, AlertType} = req.srf.locals;
|
||||
assert(account_sid);
|
||||
const key = makeCallCountKey(account_sid);
|
||||
|
||||
@@ -223,10 +233,11 @@ module.exports = function(srf, logger) {
|
||||
if (status > 200) {
|
||||
decrKey(key)
|
||||
.then((count) => {
|
||||
logger.debug({key}, `after rejection there are ${count} active calls for this account`);
|
||||
logger.info({key}, `after rejection there are ${count} active calls for this account`);
|
||||
debug({key}, `after rejection there are ${count} active calls for this account`);
|
||||
return;
|
||||
return count;
|
||||
})
|
||||
.then((count) => writeCallCount({account_sid, calls_in_progress: count}))
|
||||
.catch((err) => logger.error({err}, 'checkLimits: decrKey err'));
|
||||
}
|
||||
});
|
||||
@@ -234,6 +245,10 @@ module.exports = function(srf, logger) {
|
||||
try {
|
||||
/* increment the call count */
|
||||
const calls = await incrKey(key);
|
||||
writeCallCount({account_sid, calls_in_progress: calls})
|
||||
.then(() => logger.info(`checkLimits: after incrementing there are ${calls} active calls for this account`))
|
||||
.catch((err) => logger.error({err}, 'checkLimits: error writing call count'));
|
||||
if (!process.env.JAMBONES_HOSTING) return next();
|
||||
|
||||
/* compare to account's limit, though avoid db hit when call count is low */
|
||||
const minLimit = process.env.MIN_CALL_LIMIT ?
|
||||
@@ -254,13 +269,15 @@ module.exports = function(srf, logger) {
|
||||
account_sid,
|
||||
count: limit_sessions
|
||||
}).catch((err) => logger.info({err}, 'checkLimits: error writing alert'));
|
||||
return res.send(503, 'Maximum Calls In Progress');
|
||||
res.send(503, 'Maximum Calls In Progress');
|
||||
return req.srf.endSession(req);
|
||||
}
|
||||
next();
|
||||
} catch (err) {
|
||||
stats.increment('sbc.terminations', ['sipStatus:500']);
|
||||
logger.error({err}, 'error checking limits error for inbound call');
|
||||
res.send(500);
|
||||
req.srf.endSession(req);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -273,6 +290,7 @@ module.exports = function(srf, logger) {
|
||||
stats.increment('sbc.terminations', ['sipStatus:500']);
|
||||
logger.error(err, `${req.get('Call-ID')} Error looking up related info for inbound call`);
|
||||
res.send(500);
|
||||
req.srf.endSession(req);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+35
-1
@@ -45,11 +45,45 @@ const normalizeDID = (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 createHealthCheckApp = (port, logger) => {
|
||||
const express = require('express');
|
||||
const app = express();
|
||||
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(express.json());
|
||||
|
||||
return new Promise((resolve) => {
|
||||
app.listen(port, () => {
|
||||
logger.info(`Health check server started at http://localhost:${port}`);
|
||||
resolve(app);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
isWSS,
|
||||
SdpWantsSrtp,
|
||||
getAppserver,
|
||||
makeRtpEngineOpts,
|
||||
makeCallCountKey,
|
||||
normalizeDID
|
||||
normalizeDID,
|
||||
equalsIgnoreOrder,
|
||||
systemHealth,
|
||||
createHealthCheckApp
|
||||
};
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
#!/bin/sh
|
||||
|
||||
TCP_SERVER_PORT="${DRACHTIO_PORT:-4000}"
|
||||
nc -v -z localhost $TCP_SERVER_PORT
|
||||
|
||||
# if last command exited with non zero
|
||||
if [ $? != 0 ]
|
||||
then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
HTTP_SERVER_PORT="${HTTP_PORT:-3000}"
|
||||
|
||||
printf 'GET /system-health HTTP/1.1\r\nHost: localhost\r\n\r\n' | nc -v localhost 3000 | grep calls
|
||||
|
||||
# grep will automatically exit with 1 if string is not matched, however, will leave that call there in case
|
||||
# we pivot to pipe to dev/null
|
||||
|
||||
if [ $? != 0 ]
|
||||
then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exit 0
|
||||
Generated
+3092
-725
File diff suppressed because it is too large
Load Diff
+19
-17
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"name": "sbc-inbound",
|
||||
"version": "v0.6.7-rc8",
|
||||
"version": "v0.7.5",
|
||||
"main": "app.js",
|
||||
"engines": {
|
||||
"node": ">= 10.16.0"
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"keywords": [
|
||||
"sip",
|
||||
@@ -20,33 +20,35 @@
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node app",
|
||||
"test": "NODE_ENV=test JAMBONES_NETWORK_CIDR='127.0.0.1/32' JAMBONES_HOSTING=1 SBC_ACCOUNT_SID=ed649e33-e771-403a-8c99-1780eabbc803 JAMBONES_TIME_SERIES_HOST=127.0.0.1 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_LOGLEVEL=error DRACHTIO_SECRET=cymru DRACHTIO_HOST=127.0.0.1 DRACHTIO_PORT=9060 JAMBONES_RTPENGINES=127.0.0.1:12222 JAMBONES_FEATURE_SERVERS=172.38.0.11 node test/ ",
|
||||
"test": "NODE_ENV=test HTTP_PORT=3050 JAMBONES_NETWORK_CIDR='127.0.0.1/32' JAMBONES_HOSTING=1 SBC_ACCOUNT_SID=ed649e33-e771-403a-8c99-1780eabbc803 JAMBONES_TIME_SERIES_HOST=127.0.0.1 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_LOGLEVEL=info DRACHTIO_SECRET=cymru DRACHTIO_HOST=127.0.0.1 DRACHTIO_PORT=9060 JAMBONES_RTPENGINES=127.0.0.1:12222 JAMBONES_FEATURE_SERVERS=172.38.0.11 node test/ ",
|
||||
"coverage": "./node_modules/.bin/nyc --reporter html --report-dir ./coverage npm run test",
|
||||
"jslint": "eslint app.js lib"
|
||||
},
|
||||
"dependencies": {
|
||||
"@jambonz/db-helpers": "^0.6.12",
|
||||
"@jambonz/db-helpers": "^0.6.18",
|
||||
"@jambonz/http-authenticator": "^0.2.0",
|
||||
"@jambonz/realtimedb-helpers": "^0.4.8",
|
||||
"@jambonz/rtpengine-utils": "^0.1.17",
|
||||
"@jambonz/stats-collector": "^0.1.5",
|
||||
"@jambonz/time-series": "^0.1.5",
|
||||
"aws-sdk": "^2.848.0",
|
||||
"@jambonz/http-health-check": "^0.0.1",
|
||||
"@jambonz/realtimedb-helpers": "^0.4.24",
|
||||
"@jambonz/rtpengine-utils": "^0.3.1",
|
||||
"@jambonz/stats-collector": "^0.1.6",
|
||||
"@jambonz/time-series": "^0.1.9",
|
||||
"aws-sdk": "^2.1036.0",
|
||||
"bent": "^7.3.12",
|
||||
"express": "^4.17.1",
|
||||
"verify-aws-sns-signature": "^0.0.6",
|
||||
"xml2js": "^0.4.23",
|
||||
"cidr-matcher": "^2.1.1",
|
||||
"debug": "^4.3.1",
|
||||
"debug": "^4.3.3",
|
||||
"drachtio-fn-b2b-sugar": "0.0.12",
|
||||
"drachtio-srf": "^4.4.59",
|
||||
"pino": "^6.8.0",
|
||||
"rtpengine-client": "^0.2.0"
|
||||
"express": "^4.17.3",
|
||||
"husky": "^7.0.4",
|
||||
"pino": "^7.4.1",
|
||||
"rtpengine-client": "^0.2.0",
|
||||
"verify-aws-sns-signature": "^0.0.6",
|
||||
"xml2js": "^0.4.23"
|
||||
},
|
||||
"devDependencies": {
|
||||
"clear-module": "^4.1.1",
|
||||
"eslint": "^7.15.0",
|
||||
"eslint-plugin-promise": "^4.2.1",
|
||||
"eslint": "^7.32.0",
|
||||
"eslint-plugin-promise": "^4.3.1",
|
||||
"nyc": "^15.1.0",
|
||||
"tape": "^4.13.3"
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ networks:
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:5.7
|
||||
platform: linux/x86_64
|
||||
ports:
|
||||
- "3306:3306"
|
||||
environment:
|
||||
@@ -71,7 +72,7 @@ services:
|
||||
ipv4_address: 172.38.0.14
|
||||
|
||||
influxdb:
|
||||
image: influxdb:1.8-alpine
|
||||
image: influxdb:1.8
|
||||
ports:
|
||||
- "8086:8086"
|
||||
networks:
|
||||
|
||||
+9
-4
@@ -1,8 +1,7 @@
|
||||
const test = require('tape');
|
||||
const { output, sippUac } = require('./sipp')('test_sbc-inbound');
|
||||
const debug = require('debug')('drachtio:sbc-inbound');
|
||||
const clearModule = require('clear-module');
|
||||
const consoleLogger = {error: console.error, info: console.log, debug: console.log};
|
||||
const { sippUac } = require('./sipp')('test_sbc-inbound');
|
||||
const bent = require('bent');
|
||||
const getJSON = bent('json');
|
||||
|
||||
process.on('unhandledRejection', (reason, p) => {
|
||||
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
|
||||
@@ -28,6 +27,12 @@ test('incoming call tests', async(t) => {
|
||||
|
||||
try {
|
||||
await connect(srf);
|
||||
|
||||
let obj = await getJSON('http://127.0.0.1:3050/');
|
||||
t.ok(obj.calls === 0, 'HTTP GET / works (current call count)')
|
||||
obj = await getJSON('http://127.0.0.1:3050/system-health');
|
||||
t.ok(obj.calls === 0, 'HTTP GET /system-health works (health check)')
|
||||
|
||||
await sippUac('uac-pcap-carrier-success.xml', '172.38.0.20');
|
||||
t.pass('incoming call from carrier completed successfully');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user