Compare commits

...
19 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
7 changed files with 699 additions and 596 deletions
+1
View File
@@ -34,6 +34,7 @@ build/Release
node_modules
.DS_Store
.vscode
examples/*
CLAUDE.md
+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
@@ -4,7 +4,7 @@
"ICE": "force",
"SDES": "off",
"flags": ["generate mid", "SDES-no", "port latching"],
"rtcp-mux": ["require"]
"rtcp-mux": ["offer"]
},
"teams": {
"transport-protocol": "RTP/SAVP",
+64 -32
View File
@@ -1,5 +1,4 @@
const Emitter = require('events');
const sdpTransform = require('sdp-transform');
const SrsClient = require('@jambonz/siprec-client-utils');
const {
makeRtpEngineOpts,
@@ -10,7 +9,6 @@ const {
makePartnerFullMediaReleaseKey,
isValidDomainOrIP,
removeVideoSdp,
determineAnswerCodec
} = require('./utils');
const { MediaPath } = require('./constants.json');
const {forwardInDialogRequests} = require('drachtio-fn-b2b-sugar');
@@ -96,19 +94,6 @@ const initCdr = (req, invite) => {
};
};
const updateRtpEngineFlags = (sdp, opts) => {
try {
const parsed = sdpTransform.parse(sdp);
const codec = parsed.media[0].rtp[0].codec;
// Only add telephone-event support, don't restrict to specific G.711 codec yet
// This allows far end to choose between PCMU/PCMA freely
if (['PCMU', 'PCMA'].includes(codec)) {
opts.flags.push('codec-accept-telephone-event');
}
} catch {}
return opts;
};
class CallSession extends Emitter {
constructor(logger, req, res) {
super();
@@ -125,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,
@@ -251,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
@@ -366,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;
}
});
@@ -398,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) {
@@ -516,7 +521,7 @@ class CallSession extends Emitter {
})
};
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 */
@@ -539,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: [
@@ -557,15 +563,16 @@ class CallSession extends Emitter {
localSdpA: async(sdp, res) => {
this.rtpEngineOpts.uac.tag = res.getParsedHeader('To').params.tag;
// Determine which codec to use based on far end negotiation
const {codec} = determineAnswerCodec(sdp, this.req.body, this.logger);
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', `codec-accept-${codec}`, 'codec-accept-telephone-event'],
flags: [
'single codec',
'inject DTMF',
'reuse codecs',
],
sdp
};
const response = await this.answer(opts);
@@ -672,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();
@@ -737,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',
@@ -896,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,
@@ -1222,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';
@@ -1231,7 +1260,7 @@ 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'));
@@ -1240,10 +1269,13 @@ Duration=${payload.duration} `
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: 'caller hungup',
+27 -54
View File
@@ -5,7 +5,8 @@ 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));
@@ -16,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) {
@@ -41,6 +50,20 @@ function makeRtpEngineOpts(req, srcIsUsingSrtp, dstIsUsingSrtp, padCrypto, teams
'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: {
@@ -51,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,
}
}
};
@@ -350,54 +372,6 @@ const removeVideoSdp = (sdp) => {
return sdpTransform.write(parsedSdp);
};
const determineAnswerCodec = (farEndSdp, featureServerSdp, logger) => {
try {
// Parse both SDPs
const farEndParsed = sdpTransform.parse(farEndSdp);
const fsParsed = sdpTransform.parse(featureServerSdp);
// Get negotiated codec from far end (first codec in answer)
const negotiatedCodec = farEndParsed.media[0].rtp[0].codec;
// Get all codecs offered by feature server
const fsCodecs = fsParsed.media[0].rtp.map((r) => r.codec);
logger.debug({negotiatedCodec, fsCodecs}, 'determineAnswerCodec: analyzing codec negotiation');
// If far end negotiated G.711 (PCMU/PCMA) AND it was in the FS offer, pass it through
if (['PCMU', 'PCMA'].includes(negotiatedCodec) && fsCodecs.includes(negotiatedCodec)) {
logger.info({negotiatedCodec}, 'G.711 codec passthrough - no transcoding needed');
return {
codec: negotiatedCodec,
needsTranscoding: false
};
}
// Otherwise, we need to transcode to first G.711 codec in FS offer
const firstG711 = fsCodecs.find((c) => ['PCMU', 'PCMA'].includes(c));
if (firstG711) {
logger.info({negotiatedCodec, transcodeTarget: firstG711}, 'Transcoding required to G.711');
return {
codec: firstG711,
needsTranscoding: true
};
}
// Fallback: use PCMU
logger.info({negotiatedCodec}, 'No G.711 in FS offer, defaulting to PCMU');
return {
codec: 'PCMU',
needsTranscoding: true
};
} catch (err) {
logger.error({err}, 'Error determining answer codec, defaulting to PCMU');
return {
codec: 'PCMU',
needsTranscoding: true
};
}
};
module.exports = {
makeRtpEngineOpts,
selectHostPort,
@@ -414,5 +388,4 @@ module.exports = {
makePartnerFullMediaReleaseKey,
isValidDomainOrIP,
removeVideoSdp,
determineAnswerCodec
};
+590 -503
View File
File diff suppressed because it is too large Load Diff
+6 -6
View File
@@ -1,9 +1,9 @@
{
"name": "sbc-outbound",
"version": "0.9.5",
"version": "0.9.9",
"main": "app.js",
"engines": {
"node": ">= 18.0.0"
"node": ">= 20.0.0"
},
"keywords": [
"sip",
@@ -31,7 +31,7 @@
"@jambonz/db-helpers": "^0.9.18",
"@jambonz/http-health-check": "^0.0.1",
"@jambonz/mw-registrar": "0.2.7",
"@jambonz/realtimedb-helpers": "^0.8.13",
"@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",
@@ -39,7 +39,7 @@
"cidr-matcher": "^2.1.1",
"debug": "^4.4.3",
"drachtio-fn-b2b-sugar": "^0.2.1",
"drachtio-srf": "^5.0.12",
"drachtio-srf": "^5.0.21",
"express": "^4.21.2",
"pino": "^10.1.0",
"sdp-transform": "^2.15.0"
@@ -48,7 +48,7 @@
"bent": "^7.3.12",
"eslint": "^9.17.0",
"eslint-plugin-promise": "^7.2.1",
"nyc": "^15.1.0",
"tape": "^5.7.5"
"nyc": "^17.1.0",
"tape": "^5.9.0"
}
}