Compare commits

...
36 Commits
Author SHA1 Message Date
Dave Horton fec25d5d15 0.9.9 2026-08-20 09:16:55 -04:00
Dave Horton 5c470b9630 0.9.8 2026-07-20 08:43:34 -04:00
Hoan Luu HuuandClaude Opus 4.8 606792825e feat: honor X-Jambonz-SRTP header for forwarded sip URI calls (#230)
The feature-server sets X-Jambonz-SRTP (from the dial verb's
srtpEncryption option) to request encrypted media on a per-call basis.
Previously SRTP on a forwarded sip URI could only be enabled globally
via JAMBONES_SIPS_FORWARD_SRTP + a sips: scheme; now an application can
opt in per call. The env var remains as a global fallback. The internal
header is stripped before the INVITE is sent to the target.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 07:24:14 -04:00
Dave Horton ffec713d62 bump version 2026-07-09 07:31:05 -04:00
Hoan Luu Huu 6921c1cf31 update rtcp-mux to default for srtp (#229) 2026-07-09 07:10:13 -04:00
Sam Machin 37e4dea705 decrement count on abandoned call (#228)
fix typo so correct error message is logged
2026-07-03 11:11:46 -04:00
Hoan Luu Huu 4fb5acaf40 update realtimedb-helper (#227) 2026-06-15 20:43:07 -04:00
Sam MachinandDave Horton 865068f158 add new CODEC_TRANSCODE option (#225)
* add new CODEC_TRANSCODE option

allows RTP engine to add additional codecs to the outgoing offer that wern't in the invite from Freeswitch so RTP engine will transcode outbound calls.

* update README for new env var descriptions

---------

Co-authored-by: Dave Horton <daveh@beachdognet.com>
2026-05-01 08:43:13 -04:00
Hoan Luu Huu 4f65b4b585 support srtp for sips sipuri outbound call (#224)
* support srtp for sips sipuri outbound call

* wip

* wip

* add env variable for disable the srtp for sipURI
2026-04-22 08:00:50 -04:00
Hoan Luu Huu 8ccd02aa1f update drachtio srf 5.0.21 (#221) 2026-04-13 21:24:42 -04:00
Dave Horton 0911032146 bump version 2026-03-31 07:47:05 -04:00
Dave Horton 121ae75b39 update deps 2026-03-30 21:07:19 -04:00
Dave Horton 57bc073ea3 update to drachtio-srf latest (#218) 2026-03-13 09:02:05 -04:00
Dave Horton 84bce56766 update drachtio-srf 2026-02-09 10:25:47 -05:00
Sam Machin d053b94e71 remove ice and dtls off (#214)
* remove ice and dtls off if set in db

* lint

* more lint
2026-01-29 13:43:30 -05:00
Dave Horton a50ed56b36 update devDependencies (#215) 2026-01-22 11:47:02 -05:00
Hoan Luu Huu 8fdcc8cbc4 update drachtio srf version 5.0.17 (#213) 2026-01-22 08:12:50 -05:00
Sam Machin b882c0de2d Recording update (#212)
* if call hasRecording then add url to cdr

* handle missing hasRecording value in redis

* typo

* lint

* use nullish coalescing for null response

* typo
2026-01-02 10:29:51 -05:00
Dave Horton b3fee43c7f include codec-accept on answer to rtpengine during reinvites (#209)
* include codec-accept on answer to rtpengine during reinvites

* attempt to simplify

* fixes from testing
2025-12-02 07:37:18 -05:00
Dave Horton 5fab8a7515 when far end answers with only pcma, passthrough instead of transcoding to pcmu (#208) 2025-11-24 11:23:16 -06:00
Anton Voylenko 178105acd7 chore: bump node version (#205) 2025-11-04 18:02:31 -05:00
Sam Machin 70a09c10b3 Fix/200 (#202)
* Update call-session.js

* Update call-session.js
2025-10-28 16:58:54 -04:00
Dave Horton 49bc11bbb6 update pino and eslint (#201) 2025-10-21 07:52:29 -04:00
Sam Machin 2ebe4d3d78 bump db_helpers for cache change (#199) 2025-10-15 11:40:12 -04:00
Sam Machin cd0d360561 Redirect outbound user calls to private IP of other SBC (#197)
* redirect client calls to private address of other SBC

* remove unused util

* use address not port
2025-10-04 20:08:57 -04:00
Sam Machin 32d82ed67d Fix/193 (#195)
* pass sip failure reason back to FS

* Update call-session.js

* update drachtio-srf dep
2025-09-12 09:21:13 -04:00
Dave Horton c17f27ab2c fix prev commit (#192) 2025-09-04 07:57:38 -04:00
Dave Horton 2fc570f731 when sending to retell with user starting with call_ dont prepend plus (#191) 2025-09-03 23:35:56 -04:00
Sam Machin c87f831868 Fix/transport in contact (#190)
* use the req transport param in the From and Contact headers if set.

* Update call-session.js
2025-09-03 13:53:51 -04:00
Sam Machin 616228bf09 update isPrivateVoipNetwork function (#189) 2025-09-02 08:02:59 -04:00
Dave Horton 55fef10f0e revert change (for now) that caused audio issues when reinviting to partial media (#187) 2025-08-18 12:45:48 -04:00
Sam Machin 9357920f76 set strict source (#185)
* set strict source

RTPBleed

* change to env var for strict source

* Update srtp-transcoding.json

* lint

* lint

* reverse the logic

* and argghhh

* clarification

* change
2025-08-03 19:44:31 -04:00
Dave Horton 6771cbdefc bump version 2025-07-15 11:46:40 -04:00
Vinod Dharashive e422c2ed9c increase dtmf volume (#184)
https://github.com/jambonz/jambonz-feature-server/issues/1272
2025-07-09 08:22:53 -04:00
rammohan-y fd5e2f1a6c Remove video sdp incase of reinvite (#183)
https://github.com/jambonz/sbc-outbound/issues/182
2025-07-08 09:17:16 -04:00
sathish kumar pasham 5cc37265a9 Fix vulnerabilities by adding @babel/helpers@7.26.10 to dependencies (#181) 2025-06-27 08:04:03 -04:00
13 changed files with 1401 additions and 1229 deletions
-1
View File
@@ -1 +0,0 @@
test/*
-126
View File
@@ -1,126 +0,0 @@
{
"env": {
"node": true,
"es6": true
},
"parserOptions": {
"ecmaFeatures": {
"jsx": false,
"modules": false
},
"ecmaVersion": 2020
},
"plugins": ["promise"],
"rules": {
"promise/always-return": "error",
"promise/no-return-wrap": "error",
"promise/param-names": "error",
"promise/catch-or-return": "error",
"promise/no-native": "off",
"promise/no-nesting": "warn",
"promise/no-promise-in-callback": "warn",
"promise/no-callback-in-promise": "warn",
"promise/no-return-in-finally": "warn",
// Possible Errors
// http://eslint.org/docs/rules/#possible-errors
"comma-dangle": [2, "only-multiline"],
"no-control-regex": 2,
"no-debugger": 2,
"no-dupe-args": 2,
"no-dupe-keys": 2,
"no-duplicate-case": 2,
"no-empty-character-class": 2,
"no-ex-assign": 2,
"no-extra-boolean-cast" : 2,
"no-extra-parens": [2, "functions"],
"no-extra-semi": 2,
"no-func-assign": 2,
"no-invalid-regexp": 2,
"no-irregular-whitespace": 2,
"no-negated-in-lhs": 2,
"no-obj-calls": 2,
"no-proto": 2,
"no-unexpected-multiline": 2,
"no-unreachable": 2,
"use-isnan": 2,
"valid-typeof": 2,
// Best Practices
// http://eslint.org/docs/rules/#best-practices
"no-fallthrough": 2,
"no-octal": 2,
"no-redeclare": 2,
"no-self-assign": 2,
"no-unused-labels": 2,
// Strict Mode
// http://eslint.org/docs/rules/#strict-mode
"strict": [2, "never"],
// Variables
// http://eslint.org/docs/rules/#variables
"no-delete-var": 2,
"no-undef": 2,
"no-unused-vars": [2, {"args": "none"}],
// Node.js and CommonJS
// http://eslint.org/docs/rules/#nodejs-and-commonjs
"no-mixed-requires": 2,
"no-new-require": 2,
"no-path-concat": 2,
"no-restricted-modules": [2, "sys", "_linklist"],
// Stylistic Issues
// http://eslint.org/docs/rules/#stylistic-issues
"comma-spacing": 2,
"eol-last": 2,
"indent": [2, 2, {"SwitchCase": 1}],
"keyword-spacing": 2,
"max-len": [2, 120, 2],
"new-parens": 2,
"no-mixed-spaces-and-tabs": 2,
"no-multiple-empty-lines": [2, {"max": 2}],
"no-trailing-spaces": [2, {"skipBlankLines": false }],
"quotes": [2, "single", "avoid-escape"],
"semi": 2,
"space-before-blocks": [2, "always"],
"space-before-function-paren": [2, "never"],
"space-in-parens": [2, "never"],
"space-infix-ops": 2,
"space-unary-ops": 2,
// ECMAScript 6
// http://eslint.org/docs/rules/#ecmascript-6
"arrow-parens": [2, "always"],
"arrow-spacing": [2, {"before": true, "after": true}],
"constructor-super": 2,
"no-class-assign": 2,
"no-confusing-arrow": 2,
"no-const-assign": 2,
"no-dupe-class-members": 2,
"no-new-symbol": 2,
"no-this-before-super": 2,
"prefer-const": 2
},
"globals": {
"DTRACE_HTTP_CLIENT_REQUEST" : false,
"LTTNG_HTTP_CLIENT_REQUEST" : false,
"COUNTER_HTTP_CLIENT_REQUEST" : false,
"DTRACE_HTTP_CLIENT_RESPONSE" : false,
"LTTNG_HTTP_CLIENT_RESPONSE" : false,
"COUNTER_HTTP_CLIENT_RESPONSE" : false,
"DTRACE_HTTP_SERVER_REQUEST" : false,
"LTTNG_HTTP_SERVER_REQUEST" : false,
"COUNTER_HTTP_SERVER_REQUEST" : false,
"DTRACE_HTTP_SERVER_RESPONSE" : false,
"LTTNG_HTTP_SERVER_RESPONSE" : false,
"COUNTER_HTTP_SERVER_RESPONSE" : false,
"DTRACE_NET_STREAM_END" : false,
"LTTNG_NET_STREAM_END" : false,
"COUNTER_NET_SERVER_CONNECTION_CLOSE" : false,
"DTRACE_NET_SERVER_CONNECTION" : false,
"LTTNG_NET_SERVER_CONNECTION" : false,
"COUNTER_NET_SERVER_CONNECTION" : false
}
}
+2
View File
@@ -34,5 +34,7 @@ build/Release
node_modules
.DS_Store
.vscode
examples/*
CLAUDE.md
+3 -3
View File
@@ -1,10 +1,10 @@
FROM --platform=linux/amd64 node:18.15-alpine3.16 as base
FROM --platform=linux/amd64 node:24-alpine AS base
RUN apk --update --no-cache add --virtual .builds-deps build-base python3
WORKDIR /opt/app/
FROM base as build
FROM base AS build
COPY package.json package-lock.json ./
@@ -18,6 +18,6 @@ COPY --from=build /opt/app /opt/app/
ARG NODE_ENV
ENV NODE_ENV $NODE_ENV
ENV NODE_ENV=$NODE_ENV
CMD [ "node", "app.js" ]
+10
View File
@@ -29,9 +29,19 @@ Configuration is provided via environment variables:
|JAMBONES_RTPENGINES| commans-separated list of ip:ng-port for rtpengines (e.g. '172.31.32.10:22222')|yes|
|JAMBONES_TIME_SERIES_HOST| influxdb host |yes|
|JAMBONES_RECORD_ALL_CALLS| enable auto record calls |no|
|JAMBONES_CODEC_OFFER_WITH_ORDER| comma-separated codec list to use as the outbound offer toward the carrier; the original codecs from the feature server are stripped first (e.g. `'opus,PCMU,PCMA,telephone-event'`) |no|
|JAMBONES_CODEC_TRANSCODE| comma-separated codec list that rtpengine should add to the outbound offer and transcode on the fly when the carrier selects them — used to support codecs the feature server does not speak (e.g. `'AMR-WB/16000'`). Requires an rtpengine build that includes the corresponding codec module. |no|
|JAMBONES_ACCEPT_AND_TRANSCODE| comma-separated codec list to accept on the outbound leg and transcode to PCMU/PCMA toward the feature server |no|
|JAMBONES_ACCEPT_G729| if set, accept G.729 on the outbound leg and transcode to PCMU/PCMA (shorthand for `JAMBONES_ACCEPT_AND_TRANSCODE=g729`) |no|
|K8S| service running as kubernetes service |no|
|K8S_RTPENGINE_SERVICE_NAME| rtpengine service name(required for K8S) |no|
#### Codec transcoding notes
`JAMBONES_CODEC_OFFER_WITH_ORDER` and `JAMBONES_CODEC_TRANSCODE` can be combined. When both are set, rtpengine first strips the original codecs from the SDP, then adds the codecs listed in `JAMBONES_CODEC_OFFER_WITH_ORDER` to the offer, and finally adds the codecs from `JAMBONES_CODEC_TRANSCODE` with transcoding enabled. When only `JAMBONES_CODEC_TRANSCODE` is set, the original codecs from the feature server are kept in the offer and the transcode codecs are appended.
Codecs that require licensed or optional rtpengine modules (AMR, AMR-WB, G.729, etc.) only work if rtpengine was compiled with support for them. Carrier-specific fmtp parameters (for example, `octet-align=1` for AMR-WB) can be appended to the codec name as needed (e.g. `AMR-WB/16000;octet-align=1`).
### running under pm2
Typically, this application runs under [pm2](https://pm2.io) using an [ecosystem.config.js](https://pm2.keymetrics.io/docs/usage/application-declaration/) file similar to this:
```js
+1 -1
View File
@@ -3,6 +3,6 @@
"DTLS": "off",
"SDES": "off",
"ICE": "remove",
"flags": ["media handover", "port latching"],
"flags": ["port latching"],
"rtcp-mux": ["demux"]
}
+3 -3
View File
@@ -3,13 +3,13 @@
"transport-protocol": "UDP/TLS/RTP/SAVPF",
"ICE": "force",
"SDES": "off",
"flags": ["generate mid", "SDES-no", "media handover", "port latching"],
"rtcp-mux": ["require"]
"flags": ["generate mid", "SDES-no", "port latching"],
"rtcp-mux": ["offer"]
},
"teams": {
"transport-protocol": "RTP/SAVP",
"ICE": "force",
"flags": ["generate mid", "media handover",
"flags": ["generate mid",
"SDES-no-AES_CM_128_HMAC_SHA1_32",
"SDES-no-F8_128_HMAC_SHA1_80",
"SDES-no-F8_128_HMAC_SHA1_32",
+137
View File
@@ -0,0 +1,137 @@
const promisePlugin = require('eslint-plugin-promise');
module.exports = [
{
ignores: ['test/*']
},
{
files: ['**/*.js'],
languageOptions: {
ecmaVersion: 2020,
sourceType: 'commonjs',
globals: {
// Node.js globals
console: 'readonly',
process: 'readonly',
Buffer: 'readonly',
__dirname: 'readonly',
__filename: 'readonly',
module: 'readonly',
require: 'readonly',
exports: 'readonly',
setTimeout: 'readonly',
clearTimeout: 'readonly',
setInterval: 'readonly',
clearInterval: 'readonly',
setImmediate: 'readonly',
clearImmediate: 'readonly',
// DTrace/LTTNG globals
DTRACE_HTTP_CLIENT_REQUEST: false,
LTTNG_HTTP_CLIENT_REQUEST: false,
COUNTER_HTTP_CLIENT_REQUEST: false,
DTRACE_HTTP_CLIENT_RESPONSE: false,
LTTNG_HTTP_CLIENT_RESPONSE: false,
COUNTER_HTTP_CLIENT_RESPONSE: false,
DTRACE_HTTP_SERVER_REQUEST: false,
LTTNG_HTTP_SERVER_REQUEST: false,
COUNTER_HTTP_SERVER_REQUEST: false,
DTRACE_HTTP_SERVER_RESPONSE: false,
LTTNG_HTTP_SERVER_RESPONSE: false,
COUNTER_HTTP_SERVER_RESPONSE: false,
DTRACE_NET_STREAM_END: false,
LTTNG_NET_STREAM_END: false,
COUNTER_NET_SERVER_CONNECTION_CLOSE: false,
DTRACE_NET_SERVER_CONNECTION: false,
LTTNG_NET_SERVER_CONNECTION: false,
COUNTER_NET_SERVER_CONNECTION: false
}
},
plugins: {
promise: promisePlugin
},
rules: {
// Promise plugin rules
'promise/always-return': 'error',
'promise/no-return-wrap': 'error',
'promise/param-names': 'error',
'promise/catch-or-return': 'error',
'promise/no-native': 'off',
'promise/no-nesting': 'warn',
'promise/no-promise-in-callback': 'warn',
'promise/no-callback-in-promise': 'warn',
'promise/no-return-in-finally': 'warn',
// Possible Errors
'comma-dangle': [2, 'only-multiline'],
'no-control-regex': 2,
'no-debugger': 2,
'no-dupe-args': 2,
'no-dupe-keys': 2,
'no-duplicate-case': 2,
'no-empty-character-class': 2,
'no-ex-assign': 2,
'no-extra-boolean-cast': 2,
'no-extra-parens': [2, 'functions'],
'no-extra-semi': 2,
'no-func-assign': 2,
'no-invalid-regexp': 2,
'no-irregular-whitespace': 2,
'no-obj-calls': 2,
'no-proto': 2,
'no-unexpected-multiline': 2,
'no-unreachable': 2,
'use-isnan': 2,
'valid-typeof': 2,
// Best Practices
'no-fallthrough': 2,
'no-octal': 2,
'no-redeclare': 2,
'no-self-assign': 2,
'no-unused-labels': 2,
// Strict Mode
'strict': [2, 'never'],
// Variables
'no-delete-var': 2,
'no-undef': 2,
'no-unused-vars': [2, {args: 'none'}],
// Node.js and CommonJS
'no-mixed-requires': 2,
'no-new-require': 2,
'no-path-concat': 2,
// Stylistic Issues
'comma-spacing': 2,
'eol-last': 2,
'indent': [2, 2, {SwitchCase: 1}],
'keyword-spacing': 2,
'max-len': [2, 120, 2],
'new-parens': 2,
'no-mixed-spaces-and-tabs': 2,
'no-multiple-empty-lines': [2, {max: 2}],
'no-trailing-spaces': [2, {skipBlankLines: false}],
'quotes': [2, 'single', 'avoid-escape'],
'semi': 2,
'space-before-blocks': [2, 'always'],
'space-before-function-paren': [2, 'never'],
'space-in-parens': [2, 'never'],
'space-infix-ops': 2,
'space-unary-ops': 2,
// ECMAScript 6
'arrow-parens': [2, 'always'],
'arrow-spacing': [2, {before: true, after: true}],
'constructor-super': 2,
'no-class-assign': 2,
'no-confusing-arrow': 2,
'no-const-assign': 2,
'no-dupe-class-members': 2,
'no-new-symbol': 2,
'no-this-before-super': 2,
'prefer-const': 2
}
}
];
+95 -31
View File
@@ -1,5 +1,4 @@
const Emitter = require('events');
const sdpTransform = require('sdp-transform');
const SrsClient = require('@jambonz/siprec-client-utils');
const {
makeRtpEngineOpts,
@@ -8,7 +7,8 @@ const {
isBlackListedSipGateway,
makeFullMediaReleaseKey,
makePartnerFullMediaReleaseKey,
isValidDomainOrIP
isValidDomainOrIP,
removeVideoSdp,
} = require('./utils');
const { MediaPath } = require('./constants.json');
const {forwardInDialogRequests} = require('drachtio-fn-b2b-sugar');
@@ -94,18 +94,6 @@ const initCdr = (req, invite) => {
};
};
const updateRtpEngineFlags = (sdp, opts) => {
try {
const parsed = sdpTransform.parse(sdp);
const codec = parsed.media[0].rtp[0].codec;
if (['PCMU', 'PCMA'].includes(codec)) {
opts.flags.push(`codec-accept-${codec}`);
opts.flags.push('codec-accept-telephone-event');
}
} catch (err) {}
return opts;
};
class CallSession extends Emitter {
constructor(logger, req, res) {
super();
@@ -122,6 +110,7 @@ class CallSession extends Emitter {
this.decrKey = req.srf.locals.realtimeDbHelpers.decrKey;
this.addKey = req.srf.locals.realtimeDbHelpers.addKey;
this.retrieveKey = req.srf.locals.realtimeDbHelpers.retrieveKey;
this.retrieveHash = req.srf.locals.realtimeDbHelpers.retrieveHash;
const {
lookupOutboundCarrierForAccount,
@@ -248,6 +237,24 @@ class CallSession extends Emitter {
this.logger.info(`sending call to registered user ${destUri}`);
}
else if (this.req.locals.target === 'forward') {
/* the feature-server sets X-Jambonz-SRTP (from the dial verb's srtpEncryption
option) to request encrypted media on a per-call basis */
const srtpMode = this.req.get('X-Jambonz-SRTP');
if (srtpMode) {
/* SDES (RTP/SAVP): pass teams=true to select the SDES srtp profile, matching
the proven carrier tls/srtp path above. This offers a=crypto to the target
(what SIP endpoints such as LiveKit expect), not DTLS-SRTP. */
this.logger.info({uri: this.req.uri, srtpMode},
'using SRTP (SDES) for forwarded call per X-Jambonz-SRTP');
this.rtpEngineOpts = makeRtpEngineOpts(this.req, false, true, false, true);
encryptedMedia = true;
}
else if (process.env.JAMBONES_SIPS_FORWARD_SRTP && this.req.uri.startsWith('sips:')) {
/* legacy global opt-in for sips: forwards (uses the DTLS srtp profile) */
this.logger.info({uri: this.req.uri}, 'using SRTP/TLS for forwarded sips: call');
this.rtpEngineOpts = makeRtpEngineOpts(this.req, false, true, false, false);
encryptedMedia = true;
}
uris = [{
private_network: await isPrivateVoipNetwork(this.req.uri),
uri: this.req.uri
@@ -321,7 +328,7 @@ class CallSession extends Emitter {
const prefix = vc.tech_prefix || '';
const transport = 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 prependPlus = vc.e164_leading_plus && /^[1-9]/.test(calledNumber) ? '+' : '';
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}`;
@@ -363,7 +370,8 @@ class CallSession extends Emitter {
* like a rare use case -- encryption is usually an all or nothing requirement.
*/
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);
// eslint-disable-next-line max-len
this.rtpEngineOpts = makeRtpEngineOpts(this.req, false, true, o.pad_crypto, true, o.remove_ice, o.dtls_off);
encryptedMedia = true;
}
});
@@ -395,13 +403,13 @@ class CallSession extends Emitter {
const toPublic = uris.some((u) => u.private_network === false);
let isOfferUpdatedToPrivate = toPrivate && !toPublic;
const opts = updateRtpEngineFlags(this.req.body, {
const opts = {
...this.rtpEngineOpts.common,
...this.rtpEngineOpts.uac.mediaOpts,
'from-tag': this.rtpEngineOpts.uas.tag,
direction: ['private', toPublic ? 'public' : 'private'],
sdp: this.req.body
});
};
let response = await this.offer(opts);
this.logger.debug({offer: opts, response}, 'initial offer to rtpengine');
if ('ok' !== response.result) {
@@ -496,21 +504,24 @@ class CallSession extends Emitter {
};
}
else {
const uri = parseUri(this.req.uri);
hdrs = {
...hdrs,
'From': createBLegFromHeader({
logger: this.logger,
req: this.req,
transport: uri.params.transport,
...(private_network && {host: this.privateSipAddress})
}),
'Contact': createBLegFromHeader({
logger: this.logger,
req: this.req,
transport: uri.params.transport,
...(private_network && {host: this.privateSipAddress})
})
};
const p = proxy ? ` via ${proxy}` : '';
this.logger.info({uri, p}, `sending INVITE to ${uri}${p}`);
this.logger.info({uri, p}, `sending INVITE${p}`);
}
/* now launch an outbound call attempt */
@@ -533,6 +544,7 @@ class CallSession extends Emitter {
'-X-Preferred-From-Host',
'-X-Jambonz-FS-UUID',
'-X-Voip-Carrier-Sid',
'-X-Jambonz-SRTP',
'-X-SIP-Proxy'
],
proxyResponseHeaders: [
@@ -550,12 +562,17 @@ class CallSession extends Emitter {
localSdpB: response.sdp,
localSdpA: async(sdp, res) => {
this.rtpEngineOpts.uac.tag = res.getParsedHeader('To').params.tag;
const opts = {
...this.rtpEngineOpts.common,
...this.rtpEngineOpts.uas.mediaOpts,
'from-tag': this.rtpEngineOpts.uas.tag,
'to-tag': this.rtpEngineOpts.uac.tag,
flags: ['single codec', 'inject DTMF'],
flags: [
'single codec',
'inject DTMF',
'reuse codecs',
],
sdp
};
const response = await this.answer(opts);
@@ -638,7 +655,7 @@ class CallSession extends Emitter {
}, this.decrKey, {writeCallCountSP, writeCallCount, writeCallCountApp})
.catch((err) => this.logger.error(err, 'Error decrementing call counts'));
}
debug(`got final outdial error: ${err}`);
this.logger.debug(`got final outdial error: ${err}`);
if (!passFailure) this.res.send(status);
this.emit('failed');
this.rtpEngineResource.destroy()
@@ -662,7 +679,21 @@ class CallSession extends Emitter {
}
}
} catch (err) {
if ('abandonded' !== err.message) this.logger.error({err}, `Error setting up outbound call to: ${uris}`);
if ('abandoned' !== err.message) this.logger.error({err}, `Error setting up outbound call to: ${uris}`);
/* the call count was incremented on 'init' but we bailed out before connecting (e.g. caller
hung up before the B leg answered), so we must decrement it here - no failure response is
sent on this path, so the res.once('end') safety net in middleware would not fire.
nudgeCallCounts is a no-op if the count was already decremented elsewhere. */
const {writeCallCount, writeCallCountSP, writeCallCountApp} = this.req.srf.locals;
nudgeCallCounts(this.req, 'failure', {
service_provider_sid: this.service_provider_sid,
account_sid: this.account_sid,
application_sid: this.application_sid,
callId: this.req.locals.callId
}, this.decrKey, {writeCallCountSP, writeCallCount, writeCallCountApp})
.catch((err) => this.logger.error(err, 'Error decrementing call counts'));
this.emit('failed');
this.srf.endSession(this.req);
this.rtpEngineResource.destroy();
@@ -705,7 +736,7 @@ class CallSession extends Emitter {
if (!IMMUTABLE_HEADERS.includes(h)) headers[h] = bye.headers[h];
});
await other.destroy({headers});
} catch (err) {}
} catch {}
const trackingOn = process.env.JAMBONES_TRACK_ACCOUNT_CALLS ||
process.env.JAMBONES_TRACK_SP_CALLS ||
@@ -727,10 +758,13 @@ class CallSession extends Emitter {
if (this.req.locals.cdr) {
const now = Date.now();
const day = new Date();
const recordAllCalls = this.req.locals.record_all_calls;
// eslint-disable-next-line max-len
const {hasRecording = false} = await this.retrieveHash(`call:${this.account_sid}:${this.req.locals.cdr.call_sid}`) ?? {};
const recordAllCalls = this.req.locals.record_all_calls || hasRecording;
const record_format = this.req.locals.account.record_format || 'mp3';
let recording_url = `/Accounts/${this.account_sid}/RecentCalls/${this.req.locals.cdr.call_sid}/record`;
recording_url += `/${day.getFullYear()}/${(day.getMonth() + 1).toString().padStart(2, '0')}`;
recording_url += `/${day.getDate().toString().padStart(2, '0')}/${recordAllCalls}`;
recording_url += `/${day.getDate().toString().padStart(2, '0')}/${record_format}`;
this.writeCdrs({...this.req.locals.cdr,
terminated_at: now,
termination_reason: dlg.type === 'uas' ? 'caller hungup' : 'called party hungup',
@@ -886,8 +920,14 @@ Duration=${payload.duration} `
process.env.JAMBONES_ACCEPT_G729 ? 'g729' : '';
offerMedia = { ...offerMedia, ...(acceptCodecs && dlg.type === 'uac' &&
{ codec: { mask: acceptCodecs, transcode: 'pcmu,pcma' } })};
answerMedia = { ...answerMedia, ...(process.env.JAMBONES_CODEC_OFFER_WITH_ORDER && dlg.type === 'uac' &&
{ codec: {offer: process.env.JAMBONES_CODEC_OFFER_WITH_ORDER.split(','), strip: 'all' }})};
answerMedia = { ...answerMedia, ...((process.env.JAMBONES_CODEC_OFFER_WITH_ORDER ||
process.env.JAMBONES_CODEC_TRANSCODE) && dlg.type === 'uac' &&
{ codec: {
...(process.env.JAMBONES_CODEC_OFFER_WITH_ORDER &&
{ offer: process.env.JAMBONES_CODEC_OFFER_WITH_ORDER.split(','), strip: 'all' }),
...(process.env.JAMBONES_CODEC_TRANSCODE &&
{ transcode: process.env.JAMBONES_CODEC_TRANSCODE.split(',') })
}})};
let opts = {
...this.rtpEngineOpts.common,
...offerMedia,
@@ -938,8 +978,12 @@ Duration=${payload.duration} `
throw new Error(`_onReinvite: rtpengine failed: ${JSON.stringify(response)}`);
}
this.logger.debug({opts, sdp: response.sdp}, 'CallSession:_onReinvite: (answer) sending back upstream');
let responseSdp = response.sdp;
if (process.env.JAMBONES_VIDEO_CALLS_ENABLED_IN_FS) {
responseSdp = removeVideoSdp(responseSdp);
}
res.send(200, {
body: response.sdp,
body: responseSdp,
headers: {
'Contact': this.contactHeader
}
@@ -1101,11 +1145,13 @@ Duration=${payload.duration} `
const code = arr[1];
const arr2 = /Duration=\s*(\d+)/.exec(req.body);
const duration = arr2 ? arr2[1] : 250;
const volume = 13;
const dtmfOpts = {
...this.rtpEngineOpts.common,
'from-tag': this.rtpEngineOpts.uas.tag,
code,
duration
duration,
volume
};
const response = await this.playDTMF(dtmfOpts);
if ('ok' !== response.result) {
@@ -1206,7 +1252,6 @@ Duration=${payload.duration} `
...(req.has('X-Retain-Call-Sid') && {'X-Retain-Call-Sid': req.get('X-Retain-Call-Sid')}),
...(req.has('X-Account-Sid') && {'X-Account-Sid': req.get('X-Account-Sid')})
};
const uac = await this.srf.createUAC(referTo.uri, {localSdp: dlg.local.sdp, headers});
this.uas = uac;
uac.type = 'uas';
@@ -1215,12 +1260,31 @@ Duration=${payload.duration} `
uac.on('info', this._onInfo.bind(this, uac));
uac.on('modify', this._onReinvite.bind(this, uac));
uac.on('refer', this._onFeatureServerTransfer.bind(this, uac));
uac.on('destroy', () => {
uac.on('destroy', async() => {
this.logger.info('call ended with normal termination');
this.rtpEngineResource.destroy();
this.activeCallIds.delete(this.req.get('Call-ID'));
if (this.activeCallIds.size === 0) this.idleEmitter.emit('idle');
uac.other.destroy();
if (this.req.locals.cdr) {
const now = Date.now();
const day = new Date();
// eslint-disable-next-line max-len
const {hasRecording = false} = await this.retrieveHash(`call:${this.account_sid}:${this.req.locals.cdr.call_sid}`) ?? {} ;
const recordAllCalls = this.req.locals.record_all_calls || hasRecording;
const record_format = this.req.locals.account.record_format || 'mp3';
let recording_url = `/Accounts/${this.account_sid}/RecentCalls/${this.req.locals.cdr.call_sid}/record`;
recording_url += `/${day.getFullYear()}/${(day.getMonth() + 1).toString().padStart(2, '0')}`;
recording_url += `/${day.getDate().toString().padStart(2, '0')}/${record_format}`;
this.writeCdrs({...this.req.locals.cdr,
terminated_at: now,
termination_reason: 'caller hungup',
sip_status: 200,
answered: true,
duration: Math.floor((now - this.req.locals.cdr.answered_at) / 1000),
...(recordAllCalls && {recording_url})
}).catch((err) => this.logger.error({err}, 'Error writing cdr for completed call'));
}
this.srf.endSession(this.req);
});
+4 -4
View File
@@ -1,7 +1,7 @@
const debug = require('debug')('jambonz:sbc-outbound');
const parseUri = require('drachtio-srf').parseUri;
const Registrar = require('@jambonz/mw-registrar');
const {selectHostPort, nudgeCallCounts} = require('./utils');
const { nudgeCallCounts} = require('./utils');
const FS_UUID_SET_NAME = 'fsUUIDs';
module.exports = (srf, logger, redisClient) => {
@@ -62,7 +62,7 @@ module.exports = (srf, logger, redisClient) => {
});
return req.srf.endSession(req);
}
} catch (err) {
} catch {
res.send(500);
return req.srf.endSession(req);
}
@@ -234,8 +234,8 @@ module.exports = (srf, logger, redisClient) => {
logger.info({details: reg}, `sending call to registered user ${aor}`);
if (req.server.hostport !== reg.sbcAddress) {
/* redirect to the correct SBC where this user is connected */
const proxyAddress = selectHostPort(reg.sbcAddress, 'tcp');
const redirectUri = `<sip:${proxyAddress[1]}>`;
const proxyAddress = reg.privateSbcAddress.split(':');
const redirectUri = `<sip:${proxyAddress[0]}>`;
logger.info({
myHostPort: req.server.hostport,
registeredHostPort: reg.sbcAddress,
+45 -7
View File
@@ -3,8 +3,10 @@ const srtpCharacteristics = require('../data/srtp-transcoding');
const debug = require('debug')('jambonz:sbc-outbound');
const CIDRMatcher = require('cidr-matcher');
const dns = require('dns');
const sdpTransform = require('sdp-transform');
function makeRtpEngineOpts(req, srcIsUsingSrtp, dstIsUsingSrtp, padCrypto, teams) {
// eslint-disable-next-line max-len
function makeRtpEngineOpts(req, srcIsUsingSrtp, dstIsUsingSrtp, padCrypto, teams, remove_ice = false, dtls_off = false) {
const from = req.getParsedHeader('from');
const rtpCopy = JSON.parse(JSON.stringify(rtpCharacteristics));
const srtpCopy = JSON.parse(JSON.stringify(srtpCharacteristics));
@@ -15,8 +17,16 @@ function makeRtpEngineOpts(req, srcIsUsingSrtp, dstIsUsingSrtp, padCrypto, teams
}
const srtpOpts = teams ? srtpCopy['teams'] : srtpCopy['default'];
const dstOpts = dstIsUsingSrtp ? srtpOpts : rtpCopy;
const srcOpts = srcIsUsingSrtp ? srtpOpts : rtpCopy;
if (remove_ice) {
srtpOpts.ICE = 'remove';
}
if (dtls_off) {
srtpOpts.DTLS = 'off';
}
const dstOpts = JSON.parse(JSON.stringify(dstIsUsingSrtp ? srtpOpts : rtpCopy));
const srcOpts = JSON.parse(JSON.stringify(srcIsUsingSrtp ? srtpOpts : rtpCopy));
/** Allow feature server to send DTMF to the call excepts call from/to teams */
if (!teams) {
@@ -27,11 +37,33 @@ function makeRtpEngineOpts(req, srcIsUsingSrtp, dstIsUsingSrtp, padCrypto, teams
srcOpts.flags.push('inject DTMF');
}
}
/** By default, and for backwards compatibility, use media handover
* set env var to true to use strict source instead (needed for rtpbleed vulnerability)
*/
const enableStrictSource = !!process.env.RTPENGINE_ENABLE_STRICT_SOURCE;
dstOpts.flags.push(enableStrictSource ? 'strict source' : 'media handover');
srcOpts.flags.push(enableStrictSource ? 'strict source' : 'media handover');
const common = {
'call-id': req.get('Call-ID'),
'replace': ['origin', 'session-connection'],
'record call': process.env.JAMBONES_RECORD_ALL_CALLS ? 'yes' : 'no'
};
const codec = {
accept: ['PCMU', 'PCMA', 'telephone-event'],
...(process.env.JAMBONES_CODEC_OFFER_WITH_ORDER &&
{
offer: process.env.JAMBONES_CODEC_OFFER_WITH_ORDER.split(','),
strip: 'all'
}),
...(process.env.JAMBONES_CODEC_TRANSCODE &&
{
transcode: process.env.JAMBONES_CODEC_TRANSCODE.split(',')
}),
};
return {
common,
uas: {
@@ -42,8 +74,7 @@ function makeRtpEngineOpts(req, srcIsUsingSrtp, dstIsUsingSrtp, padCrypto, teams
tag: null,
mediaOpts: {
...dstOpts,
...(process.env.JAMBONES_CODEC_OFFER_WITH_ORDER &&
{ codec: { offer: process.env.JAMBONES_CODEC_OFFER_WITH_ORDER.split(','), strip: 'all' } }),
codec,
}
}
};
@@ -281,7 +312,7 @@ const isPrivateVoipNetwork = async(uri) => {
if (privateNetworkCidr) {
try {
const matcher = new CIDRMatcher(privateNetworkCidr.split(','));
const arr = /sips?:.*@(.*?)(:\d+)?(;.*)$/.exec(uri);
const arr = /sips?:.*@(.*?)(:\d+)?(;.*)?$/.exec(uri);
if (arr) {
const input = arr[1];
let addresses;
@@ -334,6 +365,12 @@ function isValidDomainOrIP(input) {
return false; // Invalid input
}
const removeVideoSdp = (sdp) => {
const parsedSdp = sdpTransform.parse(sdp);
// Filter out video media sections, keeping only non-video media
parsedSdp.media = parsedSdp.media.filter((media) => media.type !== 'video');
return sdpTransform.write(parsedSdp);
};
module.exports = {
makeRtpEngineOpts,
@@ -349,5 +386,6 @@ module.exports = {
isBlackListedSipGateway,
makeFullMediaReleaseKey,
makePartnerFullMediaReleaseKey,
isValidDomainOrIP
isValidDomainOrIP,
removeVideoSdp,
};
+1087 -1040
View File
File diff suppressed because it is too large Load Diff
+14 -13
View File
@@ -1,9 +1,9 @@
{
"name": "sbc-outbound",
"version": "0.9.4",
"version": "0.9.9",
"main": "app.js",
"engines": {
"node": ">= 18.0.0"
"node": ">= 20.0.0"
},
"keywords": [
"sip",
@@ -24,30 +24,31 @@
"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 --fix"
},
"dependencies": {
"@jambonz/db-helpers": "^0.9.12",
"@jambonz/realtimedb-helpers": "^0.8.13",
"@babel/helpers": "^7.26.10",
"@jambonz/db-helpers": "^0.9.18",
"@jambonz/http-health-check": "^0.0.1",
"@jambonz/mw-registrar": "0.2.7",
"@jambonz/realtimedb-helpers": "^0.8.21",
"@jambonz/rtpengine-utils": "^0.4.4",
"@jambonz/siprec-client-utils": "^0.2.10",
"@jambonz/stats-collector": "^0.1.10",
"@jambonz/time-series": "^0.2.10",
"cidr-matcher": "^2.1.1",
"debug": "^4.3.4",
"debug": "^4.4.3",
"drachtio-fn-b2b-sugar": "^0.2.1",
"drachtio-srf": "^5.0.5",
"express": "^4.19.2",
"pino": "^8.20.0",
"drachtio-srf": "^5.0.21",
"express": "^4.21.2",
"pino": "^10.1.0",
"sdp-transform": "^2.15.0"
},
"devDependencies": {
"bent": "^7.3.12",
"eslint": "^7.32.0",
"eslint-plugin-promise": "^6.1.1",
"nyc": "^15.1.0",
"tape": "^5.7.5"
"eslint": "^9.17.0",
"eslint-plugin-promise": "^7.2.1",
"nyc": "^17.1.0",
"tape": "^5.9.0"
}
}