From 9dc8592266a3228bd2aa3f8633bb8f892b8a54b4 Mon Sep 17 00:00:00 2001 From: dnygate <64208008+dnygate@users.noreply.github.com> Date: Fri, 11 Sep 2026 07:45:56 +0100 Subject: [PATCH 1/2] Add a fixed egress SSRC per media A controlling agent feeding media into a WebRTC SFU needs to know the SSRC of the incoming stream before it can bind a receiver to it, and the SFU needs that SSRC to stay put for the life of the call. With `fixed egress SSRC` set, rtpengine picks an SSRC for each media at signalling time and returns the chosen values in the offer or answer response, one entry per m= section along with its index and media type. The value is recorded as the ingress entry's `ssrc_map_out`, which is already the mapping every consumer reads: the egress entry is keyed off it, RTCP rewriting uses it, and the kernel module is handed it as `ssrc_out`. Because one egress entry then stands in for every ingress SSRC, it outlives a change of source, so `seq_diff` carries across on its own. All that is left is re-basing it once at the changeover, taking the last sequence number from the counter shared with the kernel module so that it is still right while the stream is offloaded. The substitution itself goes in the plain passthrough handler rather than the SSRC passthrough one, so that it applies to builds without transcoding support as well. Those builds compile the kernel side regardless, so leaving the userspace half out would have meant the original SSRC being forwarded until the stream was offloaded and the fixed one afterwards. --- daemon/call.c | 5 +++++ daemon/call_flags.c | 4 ++++ daemon/call_interfaces.c | 15 +++++++++++++++ daemon/codec.c | 7 +++++++ daemon/media_socket.c | 31 ++++++++++++++++++++++++++++--- docs/ng_control_protocol.md | 16 ++++++++++++++++ include/call.h | 3 +++ include/call_flags.h | 4 +++- include/ssrc.h | 5 +++++ 9 files changed, 86 insertions(+), 4 deletions(-) diff --git a/daemon/call.c b/daemon/call.c index ee6e22043..9239d3da5 100644 --- a/daemon/call.c +++ b/daemon/call.c @@ -3165,6 +3165,11 @@ static void media_init_from_flags(struct call_media *media, sdp_ng_flags *flags) if (flags->recrypt) MEDIA_SET(media, RECRYPT); + + if (flags->fixed_egress_ssrc) { + while (!media->fixed_egress_ssrc) + media->fixed_egress_ssrc = ssl_random(); + } } __attribute__((nonnull(1, 2))) diff --git a/daemon/call_flags.c b/daemon/call_flags.c index 1bda1580d..75851bde0 100644 --- a/daemon/call_flags.c +++ b/daemon/call_flags.c @@ -973,6 +973,10 @@ const char *call_ng_flags_flags(str *s, unsigned int idx, helper_arg arg) { case CSH_LOOKUP("strict source"): out->strict_source = true; break; + case CSH_LOOKUP("fixed-egress-SSRC"): + case CSH_LOOKUP("fixed egress SSRC"): + out->fixed_egress_ssrc = true; + break; case CSH_LOOKUP("strip-extmap"): case CSH_LOOKUP("strip extmap"): return call_ng_flags_str_ht(STR_PTR("all"), 0, &out->rtpext_strip); diff --git a/daemon/call_interfaces.c b/daemon/call_interfaces.c index ab657b0f5..d1026eda0 100644 --- a/daemon/call_interfaces.c +++ b/daemon/call_interfaces.c @@ -689,6 +689,21 @@ static const char *call_offer_answer_ng(ng_command_ctx_t *ctx, const char *addr) /* place return output SDP */ ctx->ngbuf->sdp_out = sdp_out.s; ctx->parser_ctx.parser->dict_add_str(output, "sdp", &sdp_out); + + /* report the SSRCs picked for media sent towards the recipient of this SDP, + * one entry per m= section */ + if (flags.fixed_egress_ssrc && to_ml->medias) { + parser_arg ssrcs = parser->dict_add_list(output, "egress SSRC"); + for (unsigned int i = 0; i < to_ml->medias->len; i++) { + struct call_media *media = to_ml->medias->pdata[i]; + if (!media || !media->fixed_egress_ssrc) + continue; + parser_arg ent = parser->list_add_dict(ssrcs); + parser->dict_add_int(ent, "index", media->index); + parser->dict_add_str(ent, "type", &media->type); + parser->dict_add_int(ent, "SSRC", media->fixed_egress_ssrc); + } + } if (flags.supports_rollback) { parser_arg supported = parser->dict_add_list(output, "supported"); parser->list_add_string(supported, "rollback"); diff --git a/daemon/codec.c b/daemon/codec.c index 4c46bfa4a..ba3eb0318 100644 --- a/daemon/codec.c +++ b/daemon/codec.c @@ -2210,6 +2210,13 @@ static int handler_func_passthrough(struct codec_handler *h, struct media_packet ML_CLEAR(mp->media->monologue, DTMF_INJECTION_ACTIVE); + // substitute a fixed egress SSRC. done here rather than in the SSRC + // passthrough handler as that one is only built with transcoding support. + if (mp->rtp && mp->media_out->fixed_egress_ssrc && mp->ssrc_out) { + mp->rtp->ssrc = htonl(mp->ssrc_out->h.ssrc); + mp->rtp->seq_num = htons(ntohs(mp->rtp->seq_num) + mp->ssrc_out->seq_diff); + } + __buffer_delay_raw(h->delay_buffer, h, codec_add_raw_packet, mp, h->source_pt.clock_rate); return 0; diff --git a/daemon/media_socket.c b/daemon/media_socket.c index 89d3cd301..d7565fd12 100644 --- a/daemon/media_socket.c +++ b/daemon/media_socket.c @@ -1859,7 +1859,8 @@ static const char *kernelize_one(kernelize_state *s, } - if (MEDIA_ISSET(media, ECHO) || sink_handler->attrs.transcoding) + if (MEDIA_ISSET(media, ECHO) || sink_handler->attrs.transcoding + || sink->media->fixed_egress_ssrc) redi->output.ssrc_subst = 1; __re_address_translate_ep(&redi->output.dst_addr, &sink->endpoint); @@ -3049,15 +3050,39 @@ static void media_packet_rtp_out(struct packet_handler_ctx *phc, struct sink_han const char *unkern = NULL; + uint32_t fixed_ssrc = phc->mp.media_out->fixed_egress_ssrc; + + /* A fixed egress SSRC is just a mapping from the ingress SSRC, so record it + * where every consumer already looks for one. */ + if (fixed_ssrc && phc->mp.ssrc_in) + phc->mp.ssrc_in->ssrc_map_out = fixed_ssrc; + if (G_LIKELY(!phc->rtcp && phc->mp.rtp)) { unkern = __stream_ssrc_out(phc->out_srtp, phc->mp.rtp->ssrc, phc->mp.ssrc_in, &phc->mp.ssrc_out, &phc->mp.media_out->ssrc_hash_out, - sh->attrs.transcoding ? true : false); + sh->attrs.transcoding || fixed_ssrc); + + /* a fixed egress SSRC outlives the ingress SSRC, so carry the + * sequence numbering across a change of source */ + if (fixed_ssrc && phc->mp.ssrc_out) { + struct ssrc_entry_call *so = phc->mp.ssrc_out; + uint32_t in_ssrc = ntohl(phc->mp.rtp->ssrc); + uint16_t seq = ntohs(phc->mp.rtp->seq_num); + if (so->fixed_in_ssrc_set && so->fixed_in_ssrc != in_ssrc) { + /* the egress sequence counter lives in memory shared with + * the kernel module, so it stays current even while the + * stream is offloaded and userspace sees no packets */ + uint16_t last = atomic_get_na(&so->stats->ext_seq); + so->seq_diff = last + 1 - seq; + } + so->fixed_in_ssrc = in_ssrc; + so->fixed_in_ssrc_set = true; + } } else if (phc->rtcp && phc->mp.rtcp) { unkern = __stream_ssrc_out(phc->out_srtp, phc->mp.rtcp->ssrc, phc->mp.ssrc_in, &phc->mp.ssrc_out, &phc->mp.media_out->ssrc_hash_out, - sh->attrs.transcoding ? true : false); + sh->attrs.transcoding || fixed_ssrc); } if (unkern) diff --git a/docs/ng_control_protocol.md b/docs/ng_control_protocol.md index 470c9532f..7a7afbfc0 100644 --- a/docs/ng_control_protocol.md +++ b/docs/ng_control_protocol.md @@ -1376,6 +1376,18 @@ Spaces in each string may be replaced by hyphens. negotiated are removed from forwarded RTP. Once set, the flag remains in effect for the lifetime of the call. +* `fixed egress SSRC` + + Pick an SSRC for each media at signalling time and use it for all RTP sent + towards that media, instead of passing through the SSRC that the other side + happens to be sending. The chosen values are returned in the response, and + they stay in place for the lifetime of the call, so a source that changes + its own SSRC mid-call is still forwarded under the same one, with the + sequence numbering carried across the change. + + Intended for feeding media into a system that has to bind a receiver to an + SSRC up front, such as a WebRTC selective forwarding unit. + * `strict source` Normally, *rtpengine* attempts to learn the correct endpoint address for every stream during @@ -1821,6 +1833,10 @@ SDP body that the SIP proxy should insert into the SIP message. If `supports` requested a supported extension, the response can also contain a `supported` list. +With `fixed egress SSRC` set, the response also contains `egress SSRC`, a list with +one entry per `m=` section of the returned SDP, in the same order. Each entry gives +the `index` and `type` of the media along with the `SSRC` chosen for it. + Example response: { "result": "ok", "sdp": "v=0\r\no=..." } diff --git a/include/call.h b/include/call.h index b5861a4d6..4f792a03e 100644 --- a/include/call.h +++ b/include/call.h @@ -550,6 +550,9 @@ struct call_media { struct ssrc_hash ssrc_hash_in; struct ssrc_hash ssrc_hash_out; + /* fixed SSRC to use for RTP sent towards this media, 0 if unset */ + uint32_t fixed_egress_ssrc; + struct codec_store codecs; struct codec_store offered_codecs; sdp_attr_q generic_attributes; /* sdp_attr_new() */ diff --git a/include/call_flags.h b/include/call_flags.h index 09ab29d37..afbeae753 100644 --- a/include/call_flags.h +++ b/include/call_flags.h @@ -327,7 +327,9 @@ RTPE_NG_FLAGS_STR_CASE_HT_PARAMS /* prevents double MoH holds */ moh_double_hold:1, /* process RTP header extensions even if none were negotiated */ - force_strip_extmap:1; + force_strip_extmap:1, + /* pick a fixed egress SSRC per media and report it */ + fixed_egress_ssrc:1; }; diff --git a/include/ssrc.h b/include/ssrc.h index 1ad5876d0..66087ba1b 100644 --- a/include/ssrc.h +++ b/include/ssrc.h @@ -93,6 +93,11 @@ struct ssrc_entry_call { uint32_t jitter, transit; // output only uint16_t seq_diff; + + /* for a fixed egress SSRC: the last ingress SSRC seen, so a change of + * source can be spotted and the sequence numbering carried across it */ + uint32_t fixed_in_ssrc; + bool fixed_in_ssrc_set; }; struct ssrc_time_item { From 79e6c5a054a50b47ec9c315a92a1074ba4aa9a61 Mon Sep 17 00:00:00 2001 From: dnygate <64208008+dnygate@users.noreply.github.com> Date: Fri, 11 Sep 2026 07:45:56 +0100 Subject: [PATCH 2/2] Add tests for `fixed egress SSRC` --- perl/NGCP/Rtpengine/AutoTest.pm | 8 ++- t/Makefile | 7 ++- t/auto-daemon-tests-ssrc.pl | 97 +++++++++++++++++++++++++++++++++ 3 files changed, 109 insertions(+), 3 deletions(-) create mode 100755 t/auto-daemon-tests-ssrc.pl diff --git a/perl/NGCP/Rtpengine/AutoTest.pm b/perl/NGCP/Rtpengine/AutoTest.pm index 7a201ec76..5d0353280 100644 --- a/perl/NGCP/Rtpengine/AutoTest.pm +++ b/perl/NGCP/Rtpengine/AutoTest.pm @@ -26,10 +26,15 @@ BEGIN { our @EXPORT = qw(autotest_start new_call new_call_nc offer answer ft tt cid snd snd_no srtp_snd rtp rcv srtp_rcv rcv_no rcv_maybe srtp_dec escape rtpm rtpmre reverse_tags new_ft new_tt crlf sdp_split rtpe_req offer_answer autotest_init subscribe_request subscribe_answer publish create create_answer - use_json rtpe_raw_req); + use_json rtpe_raw_req last_resp); }; +my $last_response; +sub last_resp { + return $last_response; +} + my $rtpe_stdout; my $rtpe_stderr; my $rtpe_pid; @@ -183,6 +188,7 @@ sub offer_answer { $req->{'from-tag'} //= $ft; $req->{sdp} = $sdp_in; my $resp = rtpe_req($cmd, $name, $req); + $last_response = $resp; return sdp_match($cmd, $name, $resp->{sdp}, $exp_sdp_out); } sub offer { diff --git a/t/Makefile b/t/Makefile index f706673b8..d0b4268bd 100644 --- a/t/Makefile +++ b/t/Makefile @@ -86,7 +86,7 @@ include ../lib/common.Makefile daemon-tests-measure-rtp daemon-tests-mos-legacy daemon-tests-mos-fullband daemon-tests-config-file \ daemon-tests-templ-def daemon-tests-templ-def-offer daemon-tests-t38 daemon-tests-evs-dtx \ daemon-tests-transform daemon-tests-http daemon-tests-heuristic daemon-tests-asymmetric \ - daemon-tests-dtx-no-shift daemon-tests-rtcp daemon-tests-redis-subscribe daemon-tests-rtp-ext \ + daemon-tests-dtx-no-shift daemon-tests-rtcp daemon-tests-redis-subscribe daemon-tests-rtp-ext daemon-tests-ssrc \ daemon-tests-bundle daemon-tests-dtls daemon-tests-rollback \ daemon-tests-rollback-redis \ daemon-tests-recording daemon-tests-create daemon-tests-alias \ @@ -139,7 +139,7 @@ daemon-tests: daemon-tests-main daemon-tests-jb daemon-tests-pubsub daemon-tests daemon-tests-sdp-manipulations daemon-tests-sdes-manipulations \ daemon-tests-sdp-orig-replacements daemon-tests-moh daemon-tests-evs-dtx daemon-tests-transform \ daemon-tests-transcode-config daemon-tests-codec-prefs daemon-tests-http daemon-tests-heuristic \ - daemon-tests-asymmetric daemon-tests-rtcp daemon-tests-redis-subscribe daemon-tests-rtp-ext \ + daemon-tests-asymmetric daemon-tests-rtcp daemon-tests-redis-subscribe daemon-tests-rtp-ext daemon-tests-ssrc \ daemon-tests-bundle daemon-tests-dtls daemon-tests-rollback daemon-tests-rollback-redis \ daemon-tests-recording daemon-tests-create \ daemon-tests-dtx daemon-tests-dtx-cn daemon-tests-dtx-no-shift \ @@ -295,6 +295,9 @@ daemon-tests-rtcp: daemon-test-deps daemon-tests-rtp-ext: daemon-test-deps ./auto-test-helper "$@" perl -I../perl auto-daemon-tests-rtp-ext.pl +daemon-tests-ssrc: daemon-test-deps + ./auto-test-helper "$@" perl -I../perl auto-daemon-tests-ssrc.pl + daemon-tests-bundle: daemon-test-deps ./auto-test-helper "$@" perl -I../perl auto-daemon-tests-bundle.pl diff --git a/t/auto-daemon-tests-ssrc.pl b/t/auto-daemon-tests-ssrc.pl new file mode 100755 index 000000000..ec7d70a9e --- /dev/null +++ b/t/auto-daemon-tests-ssrc.pl @@ -0,0 +1,97 @@ +#!/usr/bin/perl + +use strict; +use warnings; +use NGCP::Rtpengine::Test; +use NGCP::Rtpengine::AutoTest; +use Test::More; +use POSIX; + + +autotest_start(qw(--config-file=none -t -1 -i 203.0.113.1 -n 2223 -f -L 7 -E --log-level-internals=7)) + or die; + + +my ($sock_a, $sock_ax, $sock_b, $sock_bx, $port_a, $port_ax, $port_b, $port_bx, $resp, $ssrcs); + + +# the chosen egress SSRC is reported per media in the offer response + +($sock_a, $sock_ax, $sock_b, $sock_bx) = new_call( + [qw(198.51.100.1 7000)], + [qw(198.51.100.1 7001)], + [qw(198.51.100.3 7002)], + [qw(198.51.100.3 7003)], +); + +($port_a, $port_ax) = offer('egress SSRC reported', { flags => ['fixed egress SSRC'] }, <{'egress SSRC'}; + +is ref($ssrcs), 'ARRAY', 'egress SSRC list present in offer response'; +is scalar(@{$ssrcs // []}), 1, 'one egress SSRC entry per m= section'; +is $ssrcs->[0]->{index}, 1, 'egress SSRC entry carries media index'; +is $ssrcs->[0]->{type}, 'audio', 'egress SSRC entry carries media type'; +ok(($ssrcs->[0]->{SSRC} // 0) > 0, 'egress SSRC entry carries a non-zero SSRC'); + + +my $egress = $ssrcs->[0]->{SSRC}; + +($port_b, $port_bx) = answer('egress SSRC reported', { }, <