-
Notifications
You must be signed in to change notification settings - Fork 1
Internals Rtpengine Control Plane
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.
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.
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.
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.
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.
| File | Holds |
|---|---|
src/rtpengine/bencode.rs |
Bencode decoder. Borrowed values, depth-limited, hostile-input facing |
src/rtpengine/ng.rs |
Cookie/body split, command classification, field extraction |
src/rtpengine/control.rs |
list and query, their reply parsers, and the UDP client that carries them |
src/rtpengine/reconcile.rs |
The two moments sipnab asks, the port index, the bounds that keep asking from becoming polling, and the hand-off the capture path offers to |
src/rtpengine/mod.rs |
sdp_links_from_ng, the bridge into the existing SDP linking |
src/app/relay_reconciler.rs |
The thread that drains the hand-off and does the asking |
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.
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.
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.
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 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.
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.
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:
| Mutation | Caught by |
|---|---|
| Classifier claim removed | tests/rtpengine_ng_test.rs |
| Relay links stored with signaled provenance | pipeline::relay_control_tests |
| Correlation-id fallback removed |
rtpengine::ng tests |
| Media-creating commands attributed as legs |
rtpengine::ng tests |
| Duplicate-key check removed |
rtpengine::bencode tests |
| Depth limit removed |
rtpengine::bencode tests |
| Trailing-byte check removed |
rtpengine::bencode tests |
| Decoder made to require sorted keys |
rtpengine::bencode tests |
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.
Everything above decodes a message somebody else sent. A control message that
already happened never returns to the wire, so a call already running when the
capture starts stays unnamed until a re-offer — and incident response usually
begins mid-call. --rtpengine-control names the relay's own ng port, and
sipnab asks it.
Two questions reach the relay: list, which names the active calls, and
query, which says which relay-side PORT belongs to which call. The port is
the one thing sipnab can already see in a capture, and the relay is the only
thing that knows what it allocated the port for.
Nothing else reaches it, and the last section on this page says why delete
is unreachable rather than merely unused. A TransmitPermit gates both
questions and only a live capture source yields one, so an analyst opening a
customer's pcap cannot make sipnab send packets at the addresses inside it.
Startup, before the capture opens.
bootstrap::relay_startup_snapshot runs inside
launch's privileged window, against the RESOLVED capture source, and spends
one list plus one query per call. The answers become a port index, and
pipeline::apply_relay_snapshot registers every
socket in it as a media ENDPOINT through link_endpoint_from.
Registering rather than linking is what the rest of this depends on. A stream that turns up later resolves its endpoint from memory at creation, exactly as it would from an SDP body already on file, so the packet path never makes a network call. Against rtpengine 12.5.1:
rtpengine at 127.0.0.1:22222: 2 call(s) enumerated, complete; queried 2 of them, 8 relay port(s) now attributable
A stream nothing explains.
rtp::stream_store records an orphan socket
at stream CREATION, under the write lock the RTP path already holds. That is
what makes the second trigger an event rather than a periodic sweep: nothing
rescans the store looking for orphans, and a run whose relay accounts for
every stream it sees asks nothing after startup.
Both ends of the stream go into the hand-off, because which end belongs to the relay is exactly what the packet cannot say. A socket the relay does not hold costs one lookup in the index the startup snapshot already built.
rtpengine at 127.0.0.1:22222: 2 unexplained stream(s) offered, 0 attributed, 4 control transaction(s) spent of a ceiling of 66
One question is a UDP round trip, and DEFAULT_CONTROL_TIMEOUT gives it two
seconds. Asking from the packet path would stall capture for as long as the
relay stayed quiet, trading dropped packets for an attribution, which is the
wrong way round for a tool whose job is watching the traffic. So the
capture path only OFFERS a socket: OrphanSink has no blocking send at all,
so the only way to hand a socket over cannot wait, however long the relay
takes.
app::relay_reconciler does the asking,
and it holds no timer. Its loop blocks on the hand-off queue and wakes only
when the capture path finds a stream nothing explains. When every OrphanSink
drops, the queue closes, the loop ends, and the thread prints what it did.
There is no flag to poll and no timeout to pick.
The join order follows from that. The run joins the reconciler only AFTER the capture-processing thread, because that thread owns the sink whose drop closes the queue. Join the reconciler first and it waits on a queue nothing has closed, forever.
Three things bound the asking, and not one of them depends on how much traffic the capture carries:
-
Once per socket.
asked_portslatches, so a stream that stays unexplained — the normal case for media the relay never touched — costs one lookup for the run rather than one per packet. -
A ceiling on transactions.
DEFAULT_BUDGETis `2 * (DEFAULT_LIST_LIMIT- 1)`, 66 at the defaults, which covers a full startup snapshot and a comparable refresh after it. A capture full of orphans cannot turn into a flood of control traffic.
-
A bounded hand-off.
ORPHAN_QUEUE_DEPTHcaps the queue andMAX_PENDING_ORPHAN_SOCKETScaps the buffer feeding it, and neither one ever makes the capture path wait.
When one of them bites, sipnab counts it and says so — OrphanSink::dropped
and StreamStore::orphan_sockets_dropped for the hand-off, and the
offered-versus-attributed and spent-of-ceiling figures in the closing line
above for the other two. A stream sipnab never asked about is not a stream the
relay disowned, and a bound that bites quietly is how those two turn into the
same sentence.
ReadyReconciler carries the reconciler from launch into the capture loop
rather than rebuilding it there, because rebuilding discards all three at
once: a second reconciler starts with a full ceiling, an empty asked_ports,
and no memory of which calls it had already read.
Reconciler's index keys on (IpAddr, u16), matching the key StreamStore
uses for SDP endpoints. The two indexes join on that key, so they have to
agree on what a socket is.
Keying on the port alone was wrong, and not subtly: rtpengine allocates from
ONE port range across every --interface, so the same port number genuinely
sits on two addresses at once. A port-keyed index hands one interface's media
to the other's call, and reports it with the same confidence as a correct
answer.
SdpProvenance::relay_queried sets origin: None, and the absence is the
point. Wire, Hep and Uprobe each assert that sipnab OBSERVED the
addressing somewhere. This addressing sipnab ASKED FOR instead, and no
InputOrigin says that honestly. A binding made from such an endpoint
therefore withholds the cross-source claim rather than inventing one — the
same rule EndpointAssertion follows earlier on this page, applied to the
other axis.
The rtpmap stays empty for a related reason: a query reply names a codec but
no payload type, and an rtpmap entry needs both. Synthesizing the missing half
would put a number sipnab never heard into a field an operator reads as
measured.
Unattributed exists to prevent one collapse. "The relay does not hold this
port" and "sipnab could not ask" are different facts, and a type with a single
way of saying no reports the second as the first — a run that never reached
the relay then reads exactly like a run the relay answered.
So it keeps five reasons apart:
- the relay refused the question, or nothing answered at all
- the relay named calls sipnab could not read
- the relay's enumeration came back capped
- the run had already spent its transaction ceiling
- sipnab asked, and the relay does not hold the port
Only the last of those says anything about the relay. why_not tests them
most-conclusive-first, so the last arm becomes reachable only after a complete
answer. Where two gaps hold at once it names the truncated enumeration first,
because that is the wider gap and the operator's fix for it — raise the limit,
or enumerate over TCP — shrinks the other one too.
-
subscribe/publish/start recordingand 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. sipnab counts them (rtpengine::media_creating_commands_seen) andoutput::dialog_reportprints that count beside the relay-named calls, on a line beginningMedia-creating relay commands seen:, so a run says what it did not attribute. The counter landed first and no surface read it, which is the same silence it exists to break. A run that saw none prints nothing rather than a zero. -
HEP carrying SIP or RTP off the wire. The claim covers
ngonly. Claiming those here would change what every existing capture containing HEP reports, which is a much larger decision than this required. -
Sending anything that changes the relay.
offer,answer,deleteandstart recordingare the other half of thengprotocol, and every one of them changes a production box: it moves media, tears a call down, or fills a disk. None of them is reachable:ReadOnlyCommandhas two variants andReadOnlyRelayhas two methods, so no value in this codebase meansdelete. A reviewer asking "does sipnab ever tell a relay to do something" reads one enum instead of auditing every call site, and an edit that wants to send one has to add a variant — which shows up in a diff rather than passing as an oversight.
Website · Repository · Issues · Generated from docs/ — edit there, not here.
Getting started
Using the TUI
CLI & automation
- CLI Reference
- Filter DSL
- Output Formats
- SIP Response Codes
- SIP Methods
- SIP Header Fields
- SIP Parameters
- SIP Lint Rules
- MOS and Codecs
Configuration
Integrations (API & MCP)
- REST API
- Prometheus Metrics
- Authentication
- MCP
- MCP Deployment
- MCP Across an Estate
- MCP Tools
- MCP Protocol
- Uprobe Walkthrough
- Capture SIP over TLS
- WASM Plugins
Development & internals
- Internals Index
- Internals Subsystem Guide
- Internals Invariants
- Internals Testing
- Internals Walkthroughs
- Internals Build CI Release
- Internals Profiling
- Internals Domain Primer
- Library API
- Benchmarks
- Fault Model
- Architecture
- Internals Threading
- Internals TUI Testing
- Internals Zero Copy Payloads
- Internals Uprobe Capture
- Internals Rtpengine Control Plane