What you're seeing
The call connects and stays up, but audio flows one way only. Often it's always the same direction, or only calls through one site or cloud region. Capture the SDP on both sides of Kamailio. After Kamailio and rtpengine, no c= line should carry a private (RFC 1918) address:
v=0
o=- 3890 3891 IN IP4 10.0.1.20
s=-
c=IN IP4 10.0.1.20 rtpengine's private interface: unreachable from outside
t=0 0
m=audio 30214 RTP/AVP 8 101On the rtpengine host, tcpdump -ni any udp portrange 30000-40000 and host <phone-public-ip> shows packets arriving from one party and nothing going back, or packets going to an address that never answers.
Likely causes
Most common first.
- rtpengine advertises its private address. On AWS, Azure, GCP or any NATed host, the interface address is private. Unless rtpengine's interface is configured with the public address to advertise (private!public), it writes the private one into the SDP.
- rtpengine isn't engaged on every leg. rtpengine_manage() must run for the offer and the answer: on the INVITE, on every 1xx/2xx reply with SDP (onreply_route), and on an ACK carrying SDP. If the reply route or branch route isn't armed, or the reply route skips 180 with early media, one side still sees the other's original, unreachable address. The stock config also only calls rtpengine when it detects NAT.
- The RTP port range is blocked. rtpengine allocates UDP ports from its port-min to port-max range. That whole range must be open inbound on the host firewall and any cloud security group, not just 5060.
- The phone is behind NAT and rtpengine trusts its SDP. A NATed phone puts its LAN address in the SDP. Tell rtpengine to use the address the SIP came from (SIP-source-address) when nat_uac_test("8") finds a private address in the SDP. rtpengine then learns the real media source from the first packets.
- A SIP ALG on the customer's router. Yes. Many consumer and SMB routers rewrite SIP and SDP badly, or not at all on retransmissions. If one site keeps failing and others don't, disable SIP ALG there before changing Kamailio.
How to fix
1. Give rtpengine the public address to advertise
In rtpengine.conf (not kamailio.cfg), the part after ! is what goes in the SDP:
[rtpengine]
interface = 10.0.1.20!203.0.113.20
listen-ng = 127.0.0.1:2223
port-min = 30000
port-max = 40000Open UDP 30000–40000 inbound on the host firewall and the cloud security group. Newer rtpengine releases also accept an interfaces-config form with per-interface address= and advertised= keys. The single-line interface = form above is still supported.
2. Call rtpengine for the offer and the answer
This is the stock Kamailio 5.8 config with WITH_NAT and WITH_RTPENGINE defined, the #!ifdef lines removed. If you've customised these routes, compare them with it. What matters is that route[NATMANAGE] runs from the branch route, from the reply route for every 1xx/2xx (including 180 with early-media SDP), and for in-dialog ACKs:
#!define FLT_NATS 5
#!define FLB_NATB 6
loadmodule "rtpengine.so"
modparam("rtpengine", "rtpengine_sock", "udp:127.0.0.1:2223")
route[RELAY] {
if (is_method("INVITE|BYE|SUBSCRIBE|UPDATE")) {
if (!t_is_set("branch_route")) t_on_branch("MANAGE_BRANCH");
}
if (is_method("INVITE|SUBSCRIBE|UPDATE")) {
if (!t_is_set("onreply_route")) t_on_reply("MANAGE_REPLY");
}
if (is_method("INVITE")) {
# failure_route[MANAGE_FAILURE] as in the stock config
if (!t_is_set("failure_route")) t_on_failure("MANAGE_FAILURE");
}
if (!t_relay()) {
sl_reply_error();
}
exit;
}
route[NATMANAGE] {
if (is_request()) {
if (has_totag()) {
if (check_route_param("nat=yes")) {
setbflag(FLB_NATB);
}
}
}
if (!(isflagset(FLT_NATS) || isbflagset(FLB_NATB))) return;
if (nat_uac_test("8")) {
rtpengine_manage("SIP-source-address replace-origin replace-session-connection");
} else {
rtpengine_manage("replace-origin replace-session-connection");
}
if (is_request()) {
if (!has_totag()) {
if (t_is_branch_route()) {
add_rr_param(";nat=yes");
}
}
}
if (is_reply()) {
if (isbflagset(FLB_NATB)) {
if (is_first_hop()) set_contact_alias();
}
}
if (isbflagset(FLB_NATB)) {
# no new connections for in-dialog requests to a NATed party
if (is_request()) {
if (has_totag()) {
set_forward_no_connect();
}
}
}
return;
}
branch_route[MANAGE_BRANCH] {
route(NATMANAGE);
}
onreply_route[MANAGE_REPLY] {
if (status =~ "[12][0-9][0-9]") {
route(NATMANAGE);
}
}The stock route[WITHINDLG] already sends in-dialog ACKs to route(NATMANAGE), which late-offer calls need. Keep that if you've rewritten it. The early return means rtpengine is only used when NAT is detected. If media must always be anchored, for example because Kamailio and the media servers are in a private VPC, remove it so every call goes through rtpengine.
3. Record-Route every initial INVITE
rtpengine needs to see the whole dialog, including re-INVITEs and BYE. If record_route() isn't called on the initial INVITE, in-dialog requests bypass Kamailio. The session is never updated or deleted, and re-INVITEs (hold, transfer) break audio mid-call.