Skip to content

Four RTP/media-routing bugs found in production (NAT SDP address, overlay-network IP leak, parallel-fork port mismatch, queue bridge echo) #231

Description

@herakles-now

Summary

While running rustpbx in production behind a WireGuard/Tailscale-style overlay network (some extensions reachable only via the overlay, others via plain internet SIP/RTP, plus a NAT'd softphone), we found and fixed four related RTP/media-routing bugs. Filing here in case they're useful upstream — happy to open PRs for any of these if wanted.

Relevant files (as of our fork, based on restsend/rustpbx main): src/proxy/proxy_call/sip_session.rs, src/media/bridge.rs, src/config.rs, src/call/mod.rs.

1. Callee SDP can advertise an unreachable private address

Symptom: No RTP at all on one leg of a call — the callee's SDP c=/o= lines sometimes contained a private LAN IP that wasn't reachable from the caller side, instead of the address the proxy's locator actually resolved the contact through.

Fix: Rewrite the c=/o= address in the callee SDP to the address the locator already knows is reachable, before it's forwarded to the caller side (in prepare_caller_answer_from_callee_sdp). We use the winning Location.destination IP captured at fork-resolution time as the rewrite source.

2. Single external_ip doesn't work when some extensions are overlay-only

Symptom: An extension that's only supposed to be reachable via an internal overlay network (Tailscale/WireGuard/etc.) would sometimes get the public external_ip advertised in its SDP instead of the overlay address, causing its RTP to route outside the overlay entirely — a real security/hardening problem, not just a connectivity bug, since the whole point of putting those extensions behind the overlay is that nothing else should be able to reach them.

Root cause: rustpbx only has one external_ip config value, applied uniformly regardless of which leg/direction is being built.

Fix: Added a second config value, internal_external_ip, alongside external_ip (both in top-level Config and RtpConfig). When building the RTP track for the callee leg, we select internal_external_ip vs external_ip based on DialDirection (i.e. whether this leg is dialing into the internal/trusted side or out to a normal external target). This lets an operator run one rustpbx instance that advertises a NAT/public IP to external legs and an overlay-only IP to internal legs, with no risk of an internal-only extension leaking its RTP outside the overlay.

3. Parallel-fork queue: port mismatch between promised SDP and actual bound port

Symptom: In QueueDialMode::Parallel, when a fork target answered, the SDP offer sent to it earlier didn't always match the port the shared "callee" media peer was actually bound to by the time media forwarding started — leading to audio never arriving from the answering side.

Root cause: prepare_callee_media_offer (and the underlying create_callee_track) gets invoked once per fork target, and each call recreates/rebinds the single shared callee-keyed peer in LegRegistryLegRegistry keeps states (Leg) and peers (Arc) in separate maps keyed independently, so renaming a fork leg to callee on answer doesn't actually repoint which peer/track is bound. Whichever fork target's offer was generated last silently won, regardless of which one actually answered.

Fix: Cache the callee media offer the first time it's generated for a given callee-resolution attempt and reuse it for all fork targets, instead of regenerating (and re-binding) it per target.

4. Queue ringback bridge causes echo/distortion after an agent answers

Symptom: After an agent answered a parallel-queue call, the caller sometimes heard echo / distorted audio.

Root cause: The queue's temporary app-level BridgePeer (used for ringback/hold before an agent answers) wasn't fully torn down before the real caller↔agent ForwardingTrack media path started, so both were forwarding audio to the caller in parallel. Our first fix attempt called BridgePeer::stop(), which internally closes both the caller's and callee's PeerConnection — since BridgePeer holds the same PeerConnection objects used by the rest of the call, this broke the call outright (caught by our own e2e test suite before it reached production).

Fix: Use BridgePeer::stop_forwarding() instead, which cancels the relay tasks without touching the underlying PeerConnections.

Notes

  • All four were found via live testing (real phone calls + tcpdump packet capture) against a production-adjacent deployment, not synthetic/unit tests — add prerequisites #3 and fix unused variable #4 in particular only reproduce with a real NAT'd endpoint and real parallel-fork timing, so they were hard to catch with loopback-only e2e tests.
  • Happy to share diffs/PRs for any of these bugs if there's interest — items 1, 3, and 4 look like straightforward upstream bug fixes; item 2 (internal_external_ip) is more of a feature addition and might warrant a design discussion first (e.g. whether per-leg IP selection should be more general than just "internal vs external").

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions