One-Way Audio, Fixed: NAT, Multi-WAN and EIP Media Routing
“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
| Address | Lives in | Wrong value causes |
|---|---|---|
| RTP / SDP address | SDP c=/o=, ICE candidates | One-way or dead audio |
| SIP Contact host | Contact header for dialogs | BYE/re-INVITE lost after connect |
| Signaling bind | Where the listener actually listens | Nothing — 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

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 = trueon 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
- Read both SDPs (Diagnostics → dialogs) — compare advertised IPs to reality.
- Capture RTP with
sngrep/tcpdump— does return audio arrive at all? - Check the CDR’s media evidence —
proxy.leg_media_incompletemarks a leg that never delivered media. - If the direct path is structurally impossible (EIP, some CGNAT), stop debugging and set
relay_only.
Guides: Trunk Management and Troubleshooting.