Compare commits

...
Author SHA1 Message Date
Dave HortonandClaude Opus 5.5 37686f680c fix: use sips scheme in From/Contact when forwarding to a sips Request-URI
RFC 3261 8.1.1.8 requires a SIPS Contact when the Request-URI is SIPS.
The forward path (dial verb to a sip target) always defaulted to sip:.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 23:58:47 -04:00
Dave Horton 7099671e69 0.9.11 2026-09-15 12:39:32 -04:00
Dave Horton 11764e40b0 0.9.10 2026-09-15 12:39:12 -04:00
Hoan Luu HuuandClaude Opus 5 b9e9fe305a Fix/siprec survives fs transfer (#232)
* fix: keep siprec recording alive across a feature server transfer

A cross-feature-server move (enqueue/dequeue or conference) re-negotiates the
feature-server leg in _onFeatureServerTransfer, but nothing rebuilt the rtpengine
subscription the SIPREC recording forks from, so the recorder went silent from the
moment the call moved. The fresh destroy handler installed on the new leg also
dropped the _stopRecording() call the original handlers have, so the SIPREC dialog
was never BYEd and the recorder had to wait out its media timeout.

Rebuild the subscription after the transfer re-negotiates media, and stop the
recording when the transferred leg ends. The resubscribe call is guarded so this
is safe to deploy before @jambonz/siprec-client-utils is bumped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: drop the siprec re-subscribe, keep the missing teardown

The cross-feature-server move was tested on a real two-feature-server cluster
(inbound path): the rtpengine subscription survives the REFER on its own, so
resubscribe() was fixing a fault that does not exist. rtpengine keeps
non-offer-answer subscriptions across an answer.

What does fail, and what this branch still fixes, is the teardown: the destroy
handler installed on the transferred leg never stopped the recording, so the
recorder was left without a BYE. Verified on the inbound path; the outbound
transfer path has the identical handler and the same omission.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 18:42:47 +01:00
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 1406 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
}
}
];
+100 -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,28 @@ class CallSession extends Emitter {
};
}
else {
const uri = parseUri(this.req.uri);
/* RFC 3261 8.1.1.8: a sips Request-URI requires a sips Contact */
const scheme = uri.scheme === 'sips' ? 'sips' : 'sip';
hdrs = {
...hdrs,
'From': createBLegFromHeader({
logger: this.logger,
req: this.req,
scheme,
transport: uri.params.transport,
...(private_network && {host: this.privateSipAddress})
}),
'Contact': createBLegFromHeader({
logger: this.logger,
req: this.req,
scheme,
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 +548,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 +566,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 +659,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 +683,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 +740,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 +762,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 +924,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 +982,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 +1149,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 +1256,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 +1264,32 @@ 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();
this._stopRecording();
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.11",
"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"
}
}