Docs The rtpengine Control Plane

The rtpengine Control Plane

Naming media captured on a standalone relay: the ng wire format, why the Call-ID arrives over HEP, and how a paired capture proves it.

On this page

How sipnab names media it captures on a standalone relay, why this delivery path won, and which decisions here are load-bearing.

Read Attribute media on an rtpengine relay first if you want the operator’s view. This page is about the code.

The problem this solves

A media relay carries no SIP. sipnab installed on one sees two sockets of RTP per call and nothing that names the call, so RtpStream::orphaned is true for every stream in the capture. The output is accurate and useless — the same shape as the NAT case the codebase already names: an operator can see that media exists, how much, and how badly it performs, and cannot say which call any of it belongs to.

The name is on the box. rtpengine’s ng control protocol carries the Call-ID and the ports the relay allocated, and that pair is the join key.

Wire format

An ng message is a cookie, one space, and a bencoded dictionary:

14661e9e...  d7:command5:offer7:call-id18:km-670bd208@sipnab8:from-tag5:ftag13:sdp122:v=0...e

Three properties matter to the parser, all confirmed against rtpengine 12.5.1 rather than taken from documentation:

Dictionary keys arrive unsorted. The bencode specification requires lexicographic order and rtpengine does not produce it — a live offer arrives as command, call-id, from-tag, sdp. A decoder that enforced the rule, or that binary-searched keys, would fail on every real message. bencode::Value::Dict therefore keeps arrival order and looks up by scan.

Replies carry no call-id. An offer reply’s entire body is d3:sdp136:v=0...e. This is the single most consequential fact about the protocol for our purposes, because the reply is the half that carries the relay’s OWN allocated ports.

The cookie is a retransmission key, and rtpengine deduplicates on it. A repeated cookie gets a cached reply instead of reprocessing. Anything that SENDS ng must generate a fresh cookie per transaction. Reusing one returns port allocations belonging to a call rtpengine may already have deleted. This cost one wrong measurement during development, until rtpengine’s own log — Detected command from 127.0.0.1 as a duplicate — explained it.

Why HEP, and not a sniffer on the ng socket

Sniffing ng off its own socket needs no configuration anywhere, which is a real advantage. It also needs, because of the reply asymmetry above, a cookie-to-call transaction map to tie a reply back to the request that named the call. Around that it needs UDP, TCP, HTTP and WebSocket handling, plus a UNIX socket it cannot capture at all, plus fragment reassembly for an offer carrying ICE candidates over an MTU-sized path.

Over HEP every one of those disappears. rtpengine’s --homer-enable-ng mirrors the exact wire bytes of every message in both directions and puts the Call-ID in the HEP correlation-id chunk — on requests and replies alike. The tie arrives with the message, so there is no transaction state to keep, one transport instead of four, and no fragment problem.

Decoded from the committed fixture, every packet carries it:

HEP #1 capture-proto=0x3d correlation-id='km-670bd208@sipnab'  d7:command5:offer...
HEP #2 capture-proto=0x3d correlation-id='km-670bd208@sipnab'  d3:sdp136:v=0...   <- reply

The passive sniffer stays on the roadmap as a second delivery path behind the same decoder, not as a separate feature.

Why the wire, and not our own listener

sipnab could receive on --hep-listen and have rtpengine send to it. That works today with no new parsing at all, and it is the wrong default.

--homer is a single G_OPTION_ARG_STRING, not a repeatable option, so rtpengine has exactly ONE Homer destination. Pointing it at sipnab takes it away from the collector it already feeds. Reading the copy already on the wire needs no configuration change and leaves that pipeline untouched — and if sipnab stops, nothing downstream notices. A diagnostic tool does not belong in a production data path.

Relay mode (--hep-listen plus --hep-send) stays documented as a fallback with that cost stated.

Module layout

FileHolds
src/rtpengine/bencode.rsBencode decoder. Borrowed values, depth-limited, hostile-input facing
src/rtpengine/ng.rsCookie/body split, command classification, field extraction
src/rtpengine/mod.rssdp_links_from_ng, the bridge into the existing SDP linking

The decoder is hostile-input facing on both delivery paths, so every length is bounds-checked before use, bencode::MAX_DEPTH bounds the recursion, and anything malformed is an error rather than a partial value. A truncated dictionary is not a dictionary with fewer keys. The decoder rejects duplicate keys rather than resolving them, because last-wins and first-wins are both silent and a message with two call-id keys names no call with confidence.

fuzz/fuzz_targets/rtpengine_ng.rs drives the decoder, the message layer and sdp_links_from_ng, the last with the correlation-id both present and absent.

Where it joins the existing machinery

sdp_links_from_ng returns the same (IpAddr, u16, String, SdpMedia) tuples extract_sdp_links returns for a SIP body, so no new linking machinery exists. The call-id comes from the message body when it has one and from the correlation-id otherwise — preferring the body means a passive wire capture, which has no correlation-id, still works for the request half.

PacketAction::RelayControl

Classification returns a new variant rather than reusing Sip. That is deliberate: sipnab observed no SIP message, and synthesizing one to reuse the variant would put signaling into the dialog store that nobody sent, in a tool whose whole value is saying what it actually saw.

The variant also pays for itself structurally. This codebase’s most-named defect class is a change that reaches some packet appliers and not the others, and adding an enum variant makes the compiler name every one of them. It found six: the live router, the --cores shard, the batch path, the TUI’s file-open, and two test harnesses that mirror them (tests/rtp_quality_provenance_test.rs, tests/corpus_lint_test.rs). The behavior itself lives once, in pipeline::apply_relay_control_links, so the six call sites cannot drift apart.

EndpointAssertion, and why it is not a fourth InputOrigin

An ng endpoint is the relay asserting a port it allocated itself. That is not the same claim as an endpoint parsed from an SDP body on the wire, and the difference has to survive into provenance.

It is a field on SdpProvenance rather than a fourth InputOrigin variant because the two are independent axes. InputOrigin is a TRANSPORT fact — the bytes arrived on the wire, over HEP, or out of a process. EndpointAssertion is a fact about the CLAIM. They cross freely: a relay’s assertion arrives over HEP today and would arrive off the wire through the same decoder tomorrow, and ordinary signaling arrives over every origin there is. A fourth InputOrigin would have forced every match on transport to handle a value that is not about transport.

RtpStream::dialog_assertion carries it onward, written in the same breath as dialog_origin so the two can never describe different bindings.

The report surface, and a bug worth remembering

The report gains a section naming calls a relay attributed whose SIP is not in the capture. It has to exist: once attributed, those streams correctly leave the orphan table, and without the new section their Call-ID would then appear nowhere at all — a worse answer than the orphan row it replaced.

The first version keyed on “this stream has a Call-ID that no dialog in this report matches”. That is wrong, and the way it is wrong is instructive: it is also exactly what --limit 1 produces — one dialog kept, and streams still pointing at the calls the run dropped. Ordinary SIP signaling named those streams, so reporting them as relay-named gave a confident wrong answer about where the name came from.

It now keys on RtpStream::dialog_bound_from_relay, which reads recorded provenance. limit_caps_tracked_dialogs caught the original within one gate run, and a_dialog_dropped_by_limit_is_not_reported_as_relay_named keeps it caught.

Proving the claim

tests/fixtures/rtpengine-ng-hep.pcap is a live capture, not a construction: rtpengine 12.5.1 with --homer-enable-ng, six HEP packets covering offer/answer/delete with their replies, and forty relayed RTP packets on the four sockets those commands allocated.

Two properties make it discriminating rather than merely convenient, and tests/rtpengine_ng_test.rs asserts both instead of describing them:

  • It contains no SIP whatsoever. If these streams end up attributed, the attribution can only have come from the control plane, because there is no other source of a Call-ID in the file.
  • The HEP targets a third party. The relay reports to a collector at another address and sipnab is a bystander, which is the RE6 deployment.

tests/fixtures/rtpengine-media-only.pcap is the same capture with the six HEP packets removed and nothing else changed. It is the control case: strip the control plane and every stream returns to being an orphan with no codec and no call. Without a paired negative, “sipnab attributed the streams” stays consistent with sipnab attributing them for some unrelated reason.

The pair, not just the relay

Both fixtures above carry an ng exchange this project generated itself. That proves the decoder and proves nothing about whether a real proxy and a real relay, talking to each other, produce something sipnab can use.

rtpengine-opensips-ng.pcap closes that. It is a SIPp call driven through OpenSIPS and rtpengine in the harness, with --homer-enable-ng set, filtered to what a SEPARATE relay host would see: media and the relay’s own control plane, no SIP. The Call-ID it recovers is OpenSIPS’s, so the name travels proxy to rtpengine to HEP to sipnab and arrives on a host that captured no signaling at all. rtpengine-opensips-media-only.pcap is the same capture with the sixteen control-plane packets removed.

Two things came out of building it that the synthetic test could not reach.

The report lied when signaling WAS present. The relay-named section keyed on provenance alone, so a co-resident relay – where the proxy anchors media through an rtpengine on the same box – produced rows under a heading reading “no SIP for them in this capture” about calls whose signaling was three lines above. The predicate now requires both halves: relay-named AND unrepresented here. Neither test is sufficient alone, and the file says why.

rtpengine refuses to mirror into the void. It CONNECTS its Homer socket, so an unreachable destination answers with ICMP port-unreachable, rtpengine logs Connection error from Homer ... Connection refused and stops tracing. Aiming --homer at an address nobody answers therefore yields no control plane at all, which looks exactly like a broken feature. The harness ships hep-sink to stand in for Homer for that reason.

What the pair also shows is where this feature does NOT help. Measured on the unfiltered capture, a co-resident relay needs none of it: the proxy’s own rewritten SDP already names both sockets, and stripping the control plane changes nothing. The filtered view is the honest one to assert against because it is the only topology where the control plane adds anything.

Every gate here was mutation-tested and none survived:

MutationCaught by
Classifier claim removedtests/rtpengine_ng_test.rs
Relay links stored with signaled provenancepipeline::relay_control_tests
Correlation-id fallback removedrtpengine::ng tests
Media-creating commands attributed as legsrtpengine::ng tests
Duplicate-key check removedrtpengine::bencode tests
Depth limit removedrtpengine::bencode tests
Trailing-byte check removedrtpengine::bencode tests
Decoder made to require sorted keysrtpengine::bencode tests

Kernel forwarding, measured

rtpengine forwards either in userspace or, with table = N and the xt_RTPENGINE module, inside netfilter. If kernel-forwarded media were invisible to AF_PACKET, attributing it would be pointless, so this run measured it rather than reasoning about it.

Against rtpengine 12.5.1 with the module confirmed active and accounting the packets itself:

/proc/rtpengine/0/list   local 10.0.0.40:34232   250 packets  [kernel-forwarded]
                         output → 10.0.0.60:40001             250 packets
capture on the relay     ingress 500/500          egress 500/500

Both directions are fully visible in both modes, and nothing in this subsystem needs to know which mode a relay is in. The reason is structural: AF_PACKET’s receive tap runs before netfilter, and the module re-injects through the normal transmit path, which passes the transmit tap.

Note the module is still xt_RTPENGINE at 12.5.1, not nft_rtpengine.

What is deliberately not here

  • subscribe / publish / start recording and friends. They carry a join key and decoding them is nearly free. The risk is misattribution, not complexity: a recording subscription creates a stream that belongs to the call without being one of its two legs, and attributed as an ordinary leg a two-party call shows three streams — after which the media analysis that judges one-way audio and asymmetry answers a question nobody asked. They are counted (rtpengine::media_creating_commands_seen) so a run can say what it did not attribute.
  • HEP carrying SIP or RTP off the wire. The claim covers ng only. Claiming those here would change what every existing capture containing HEP reports, which is a much larger decision than this required.
  • Mid-call reconciliation. A control message that already happened is not on the wire for sipnab to read, so a call already running when the capture starts stays unnamed until a re-offer. Closing it means asking rtpengine directly, which makes sipnab send packets to a production relay — tracked as RE4 with the bounds that keeps it from becoming a service.