One-Way Audio, Fixed: NAT, Multi-WAN and EIP Media Routing

M
Miuda Team
Building conversational AI tooling

“Outbound calls have no audio, inbound is fine.” If you’ve run SIP for a week, you’ve met this sentence. It’s almost never the codec — it’s address advertisement: the PBX told the far end an IP it can’t reach.

RustPBX separates the three addresses that SIP/WebRTC care about, and lets you override each per deployment, per network profile, or per trunk.

The Three Addresses

AddressLives inWrong value causes
RTP / SDP addressSDP c=/o=, ICE candidatesOne-way or dead audio
SIP Contact hostContact header for dialogsBYE/re-INVITE lost after connect
Signaling bindWhere the listener actually listensNothing — but often confused with the above

Behind NAT these must be set deliberately:

# RTP/SDP public address, or auto-detect
external_ip = "203.0.113.10"
# auto_external_ip = "http://ifconfig.me"   # mutually exclusive

# SIP Contact host (unset → follows external_ip)
# sip_external_ip = "203.0.113.10"
# contact_lan_use_bind = true   # LAN destinations get the bind address

contact_lan_use_bind = true is the fix for hairpin problems: LAN-to-LAN calls get the internal bind address in Contact instead of hairpinning through your NAT address.

Multi-WAN & Overlay Networks

SIP trunk configuration — media options and IP controls

Real deployments don’t have one egress. RustPBX models this with network profiles — named groups of RTP/SDP + Contact settings:

default_network_profile = "wan"

[[network_profile]]
id = "wan"
external_ip = "203.0.113.10"

[[network_profile]]
id = "overlay"
external_ip = "100.64.10.1"   # Tailscale/WireGuard-facing

A trunk (or extension side) references the profile that matches its path. Per-trunk external_ip / bind_ip override the profile when one carrier needs its own address.

EIP / Cloud NAT Failure Modes

Cloud EIP setups fail in a specific way: the public IP isn’t on the interface, so the OS can’t hairpin back to itself, and SDP advertising the private IP reaches a far end that tries to send RTP into your VPC. Two switches help:

  • Per-dialplan relay_only — force media through the PBX regardless of what SDP negotiated. The blunt, reliable fix for a path that never works directly.
  • ice_lite = true on the trunk — for strict full-ICE peers (Teams Direct Routing); the PBX answers ICE-lite on plain-RTP legs while WebRTC legs keep full ICE.

Local Networks

Tell the PBX which ranges are internal so it can decide when to advertise the bind address:

local_networks = ["192.168.0.0/16", "10.0.0.0/8", "172.16.0.0/12"]

A Debugging Order That Works

  1. Read both SDPs (Diagnostics → dialogs) — compare advertised IPs to reality.
  2. Capture RTP with sngrep/tcpdump — does return audio arrive at all?
  3. Check the CDR’s media evidence — proxy.leg_media_incomplete marks a leg that never delivered media.
  4. If the direct path is structurally impossible (EIP, some CGNAT), stop debugging and set relay_only.

Guides: Trunk Management and Troubleshooting.

Get new posts by email

Deep dives on Rust telephony, contact centers and wholesale voice. No spam.

Thanks — you're on the list.

We use cookies for anonymous analytics to improve the site. Nothing is loaded until you accept. Privacy Policy