Skip to content

Add SSRC option to force a fixed egress SSRC - #2166

Open
dnygate wants to merge 2 commits into
sipwise:masterfrom
dnygate:ng-ssrc-egress
Open

Add SSRC option to force a fixed egress SSRC#2166
dnygate wants to merge 2 commits into
sipwise:masterfrom
dnygate:ng-ssrc-egress

Conversation

@dnygate

@dnygate dnygate commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

This adds a new SSRC dictionary to the NG control protocol with two keys,
egress-to-offerer and egress-to-answerer. Each takes an SSRC value (an
integer, or a decimal or 0x-prefixed hex string). rtpengine then rewrites
the SSRC field of every RTP packet it sends towards that side of the call
to the given value.

Motivation: when rtpengine feeds media into a downstream system such as a
WebRTC selective forwarding unit (SFU), that system needs to know the SSRC
of the incoming stream up front in order to bind a receiver to it. Today
the controlling application can only learn rtpengine's outgoing SSRC after
the fact, from RTCP or from a query. With this option the control plane
picks the SSRC itself and tells both sides.

Implementation notes:

  • The forced SSRC is stored against the call leg it applies to as soon as
    the offer or answer carrying it is processed. It can be set in either
    message and stays in effect for the rest of the call.
  • The rewrite happens at the single point all outgoing RTP passes through
    just before SRTP encryption (media_packet_encrypt()). It therefore
    covers media that is relayed unchanged, media that is transcoded, and
    media generated by rtpengine itself, for plain RTP as well as SRTP, and
    the SRTP authentication tag is computed over the rewritten header. RTCP
    is left untouched.
  • Because the rewrite only exists in userspace, streams towards a party
    with a forced SSRC are excluded from kernel packet forwarding. Streams
    in the other direction are unaffected.
  • If the SSRCs in RTCP sender and receiver reports should match the forced
    value as well, I am happy to look into feeding it through the existing
    per-stream output SSRC mapping that RTCP generation already uses, which
    would also allow the kernel module to apply it.

Tests: t/auto-daemon-tests-ssrc.pl covers integer, hex string and decimal
string values, setting the value in the offer and in the answer, the flat
string flag syntax used by the SIP proxy modules, and that a value of 0
is ignored.

Example in string syntax: SSRC=[egress-to-answerer=0x12345678]

@rfuchs rfuchs left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The obvious question of course is: what if two ingress SSRCs appear? They would be rewritten to the same egress SSRC, which may have undesirable side effects for sequence and timestamp tracking, or SRTP contexts.

Also the a=ssrc attribute comes to mind - if we have a fixed egress SSRC, that would lend itself to their usage, no?

Comment thread daemon/media_socket.c Outdated
Comment on lines +2012 to +2015
// forced egress SSRC is only implemented in userspace
if (sh->sink->media && sh->sink->media->monologue
&& sh->sink->media->monologue->force_egress_ssrc)
goto no_kernel;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We do have support for SSRC substitution in the kernel module (see ssrc_out and ssrc_subst)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. ssrc_subst and ssrc_out[] are already wired up from media_socket.c:1863,1882
off ssrc_map_out, with the substitution itself at nft_rtpengine.c:6745, so once this
moves onto ssrc_map_out the bailout serves no purpose and forced-SSRC streams can stay in
kernel forwarding. Removing it.

Comment thread daemon/media_socket.c Outdated
Comment on lines +3129 to +3138
if (!mp->rtcp && out->media && out->media->monologue && out->media->monologue->force_egress_ssrc) {
uint32_t ssrc = htonl(out->media->monologue->force_egress_ssrc);
IQUEUE_FOREACH(&mp->packets_out, p) {
str payload;
struct rtp_header *rh = rtp_payload(&payload, &p->s, NULL);
if (rh)
rh->ssrc = ssrc;
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This whole thing should really be handled by the existing SSRC substitution mechanism (see handler_func_passthrough_ssrc in codec.c) instead of adding another loop over the already-processed output packets (and even parsing the RTP header again)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. I will rework this onto __stream_ssrc_out() and ssrc_map_out rather than adding a
second pass over the already-processed output packets, which also does away with parsing the
RTP header again.

Comment thread daemon/call_flags.c Outdated
Comment on lines +133 to +139
case CSH_LOOKUP("egress-to-offerer"):
case CSH_LOOKUP("egress to offerer"):
out->ssrc_force.egress_to_offerer = call_ng_parse_ssrc(parser, value);
break;
case CSH_LOOKUP("egress-to-answerer"):
case CSH_LOOKUP("egress to answerer"):
out->ssrc_force.egress_to_answerer = call_ng_parse_ssrc(parser, value);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This mechanism would also be relevant to methods which don't involve two separate parties for offerer and answerer (e.g. publish/subscribe), so a syntax that is less specific to offer/answer would be beneficial.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair point. source-tag and from-tags look like the established pattern, so keying the
value by tag rather than by offerer or answerer role would cover publish/subscribe alongside
offer/answer.

This has to move from the monologue to the media in any case to handle bundle, so the
addressing is going to change regardless of what the keys end up being called. Unless you
would prefer a different shape, I will use a tag-keyed dictionary and drop
egress-to-offerer and egress-to-answerer.

@dnygate

dnygate commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Two ingress SSRCs

The deployment this came from involves a sequential succession of SSRCs rather than
concurrent streams: a PSTN gateway that changes its SSRC mid-call, after a restart or a media
source switch, feeding an SFU that has already bound a receiver to the original value. At
present each new ingress SSRC gets a fresh entry with seq_diff at zero and a fresh random
ssrc_map_out (ssrc.c:38), and the direct egress path passes the ingress SSRC through
unchanged, so the far side sees the change.

Collapsing that succession onto one stable egress SSRC only works if seq_diff and the SRTP
index both carry across the changeover. Without that, the receiver is handed a stable SSRC
whose sequence numbers jump at the point of the change while RTCP still reports the old
value, and on an SRTP leg the per-SSRC replay window will begin discarding packets. All of
that comes for free from ssrc_map_out, which is the same conclusion as your other comment
and roughly what I offered at the end of the description.

For genuinely concurrent ingress SSRCs I do not think there is a defensible answer, so I
would rather restrict the option than invent one: force a single SSRC per media and pass any
concurrent additional ones through untouched. The two sources of concurrency I can find are
bundle, where each m= line demuxes to its own call_media (media_socket.c:2841) with its
own SSRC hashes, and injected media, where media_player.c:245 mints its own SSRC and calls
media_packet_encrypt() directly. Both are visible at the point the option would be applied,
so the restriction can be enforced rather than only documented.

That also means the value wants to be scoped per media rather than per monologue as it is at
the moment, which runs straight into your naming comment.

a=ssrc

It does follow from a fixed egress SSRC, but it is a larger change than it first appears.
There is no ATTR_SSRC in enum attr_id, and struct attribute_ssrc at sdp.c:178 is
unreferenced apart from its member in the union, so both the parsing and the printing side
would be new work. I would prefer to keep that as a separate PR rather than grow this one,
unless you consider a fixed egress SSRC incomplete without it, in which case I will fold it
in here.

@dnygate

dnygate commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Holding the rework here until you have had a chance to look at the two open points above,
since the addressing and whether a=ssrc comes in now both change the shape of it and I
would rather not build it twice. The parts you have already called unambiguously, dropping
the kernel bailout and moving onto ssrc_map_out, I can push separately in the meantime if
you would rather review those on their own.

@rfuchs

rfuchs commented Sep 7, 2026

Copy link
Copy Markdown
Member

No problem with keeping a=ssrc for a separate commit/PR.

Concurrent SSRCs for a single media is not something I would expect explicit support for. A single RTP source changing SSRCs over its lifetime is something you might encounter though.

And yes, per-media SSRCs is definitely preferred over per-monologue, as the current approach wouldn't work for audio/video calls.

On that note I was wondering if it's actually needed that the egress SSRC can be set explicitly to a specific value? Would it not be enough that rtpengine picks its own random egress SSRC (as it can already do), and then just returns the chosen values in the response to the offer/answer? That would alleviate the burden of knowing how many media sections in the SDP from the controlling agent.

@dnygate

dnygate commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

Thanks, that's a good idea. Explicit values aren't needed here: the fixed SSRC is on the leg
facing the SFU, and that leg stays up for the whole call, so a value you pick and hand back
works as well as one I supply. It also drops the naming question, since there's no dictionary
of values left to key by tag.

There's no chosen egress SSRC to read off today, though. In the relay path
__stream_ssrc_inout() overwrites ssrc_map_out with the ingress SSRC, so egress ends up
equal to ingress, and only the media player and the echo path mint a fresh one.

Pinning per media does most of the work by itself. seq_diff and the SRTP index both live on
struct ssrc_entry_call, so keying the egress entry off the fixed value instead of the
ingress SSRC carries both across an SSRC change on their own. That leaves two new bits:
bridging seq_diff when the source changes, and picking the value at signalling time rather
than on the first packet, since the out entries don't exist yet when the reply is written.

Where do you want the values returned? One entry per m= section in the offer and answer
replies would do it, something like:

{ "result": "ok", "sdp": "...", "egress SSRC": [ 305419896, 1985229328 ] }

And what should the flag be called, or should this just be the default whenever a fixed
egress SSRC is possible?

@rfuchs

rfuchs commented Sep 7, 2026

Copy link
Copy Markdown
Member

One entry per m= section, sure. I don't know what your requirements are, if just having a list or SSRCs is enough, or if more information for each m= section is needed (e.g. the type of media, audio/video etc).

No need for an extra flag to have this information added to the response dict.

@dnygate

dnygate commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

The use case is PSTN into a WebRTC SFU. The control plane has to tell the SFU which SSRC to
expect on each media before any media flows, so each entry needs to identify its media. This
works, using the index and type that ng_stats_media() already emits:

"egress SSRC": [
  { "index": 1, "type": "audio", "SSRC": 305419896 },
  { "index": 2, "type": "video", "SSRC": 1985229328 }
]

The pinning needs a flag. Pin on every call and the egress SSRC changes everywhere, and the
existing tests fail because they assert the SSRC they sent is the one that arrives. So the
flag gates the pinning, and the field appears whenever it's set.

@dnygate

dnygate commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

Same on this one, sorry - going manual now :)

Each m= section needs its own entry with the index, the media type and the SSRC, so the control plane knows which SSRC goes with which stream. It does need a flag though.

Without one every call would get a new egress SSRC, and most of the existing tests would fail.

@rfuchs

rfuchs commented Sep 7, 2026

Copy link
Copy Markdown
Member

Yes, sorry, I didn't mean that no flag is needed to change the egress SSRC to some fixed value, but rather that no additional flag is needed to add the information about the SSRC to the response messages. So just one flag to enable it.

@dnygate

dnygate commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

Pushed the redesign. One flag, fixed egress SSRC, and the values come back as egress SSRC
in the offer and answer response.

ssrc_subst was only on for media echo and transcoding, so once a stream was kernelised the
kernel went back to the original ingress SSRC.

After the source changes SSRC the kernel table shows the fixed value in both slots with the
sequence carrying on unbroken:

SSRC in:  4321 [seq 5039/0], 1234 [seq 1039/0]
SSRC out: c80ce8f1 [seq 1079+61576/0], c80ce8f1 [seq 1079+61576/0]

Without transcoding the flag is ignored.

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, uses it for all RTP sent towards that media, and returns
the chosen values in the offer or answer response, one entry per m= section
along with its index and media type.

The egress SSRC outlives the ingress SSRC, so the outgoing sequence
numbering is carried across a change of source rather than jumping to
wherever the replacement source happens to start. The counter it resumes
from is the one in memory shared with the kernel module, so it stays right
while the stream is offloaded and userspace sees no packets.

`ssrc_subst` is switched on for these streams as well, so the substitution
survives kernelisation. One egress entry stands in for every ingress SSRC,
so the same value and sequence offset go into each slot the kernel matches
on rather than only the first.

The rewrite lives in the codec handler, which only exists in a build with
transcoding support. Without it the flag is ignored and no SSRC is
reported, so a controlling agent can tell from the response whether it
took effect rather than being handed a value that never appears on the
wire.
Covers the values reported in the offer response, the SSRC used on
forwarded RTP, and the sequence numbering continuing across a change of
ingress SSRC.

`offer_answer()` kept the NG response to itself, so add `last_resp()` to
let a test read its fields alongside the SDP.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants