The platform in this example
Picture a hosted PBX: desk phones in an office, two Kamailio 6.1 proxies at the edge, a pair of FreeSWITCH servers behind them, rtpengine relaying audio, MariaDB holding subscribers and CDRs, and two SIP trunk carriers. This isn't a real customer. It's a common layout, simplified so that one call fits on a page.
Kamailio handles signalling: who the caller is, where the call goes and what happens when something fails. FreeSWITCH handles anything that touches audio, such as prompts, collecting digits and recording. rtpengine moves the RTP. All three are community projects, and their own documentation has the detail: Kamailio modules, FreeSWITCH and rtpengine.
08:59: the phone registers
The phone boots and sends a REGISTER over TLS to port 5061, and the tls module terminates it. A phone that sends SIP over plain UDP across the internet exposes its digest exchange and call metadata to anyone on the path, so TLS is worth the certificate admin. The certificates need renewing, and the TLS settings need reviewing as old ciphers are retired.
Cheap checks run first: mf_process_maxfwd_header() from maxfwd, sanity_check() from sanity, and a flood check with pike or an htable counter. A scanner hammering the edge gets dropped before it costs a database query. Then auth_check() from auth_db checks the request against the subscriber table. The first REGISTER has no credentials, so auth_challenge() sends a 401. The phone retries with a digest response and passes.
nat_uac_test() from nathelper sees that the phone is behind the office router. fix_nated_register() records the real source address, and save("location") from registrar stores the contact in usrloc. The user sees a green line key. If it never turns green and the phone keeps retrying, the 401 loop guide covers the usual causes.
09:02: dialling out
The user dials a mobile number. The INVITE goes through the same front-door checks, and then Kamailio challenges it. For requests other than REGISTER, auth_challenge() replies 407 Proxy Authentication Required. Flag 1 on auth_check() makes sure the From user matches the authenticated user, so one account can't place calls as another.
Authentication alone isn't fraud control: a stolen password authenticates perfectly. So the call also goes through limits. Concurrent calls are capped per account with dialog profiles, and premium-rate and high-cost international prefixes are blocked unless the account is allowed them. Fraud patterns change, so review these limits regularly. A cap that made sense last year may be too loose now.
# profile declared with: modparam("dialog", "profiles_with_value", "calls")
route[AUTH] {
# FreeSWITCH nodes (permissions group 1) are trusted by source address
if (is_method("INVITE") && allow_source_address("1")) return;
if (!auth_check("$fd", "subscriber", "1")) {
auth_challenge("$fd", "0");
exit;
}
if (!is_method("REGISTER")) consume_credentials();
}
route[LIMITS] {
# initial INVITEs from phones only, never the FreeSWITCH outbound leg
# From user already checked against the auth user (flag 1 above)
get_profile_size("calls", "$fU", "$var(n)");
if ($var(n) >= 4) {
sl_send_reply("403", "Call limit reached");
exit;
}
dlg_manage();
set_dlg_profile("calls", "$fU");
}record_route() from rr keeps Kamailio in the path so the BYE comes back through it later. rtpengine_manage() rewrites the SDP so the audio goes through rtpengine, which handles the NAT and can convert SRTP from the phone into RTP for the inside network. Then ds_select_dst() from dispatcher picks a FreeSWITCH node, and t_relay() from tm sends the INVITE there.
Into FreeSWITCH: the account-code prompt
Why send an outbound call through FreeSWITCH at all? In this example the business wants a project code on every external call for billing. mod_sofia receives the INVITE on a profile that trusts the Kamailio addresses through apply-inbound-acl. With auth-calls on, calls from those addresses skip the digest challenge; the firewall is what keeps everyone else off the profile. The XML dialplan (mod_dialplan_xml) matches the number and runs play_and_get_digits, which plays a prompt, collects the code and checks it against a regex before storing it in a channel variable.
To play that prompt, FreeSWITCH either answers the call or sends early media with pre_answer. If it answers, Kamailio sees a 200 OK from FreeSWITCH, so the Kamailio-side CDR starts before the carrier has picked up. That's fine as long as everyone knows it. Bill carrier time from the FreeSWITCH B-leg.
Users notice the DTMF mode. The phone sends keypresses as RFC 4733 telephone-events. If the phone, the SDP and the FreeSWITCH profile don't agree on that, the prompt ignores keypresses and the user hangs up annoyed.
Out to the carrier
With a valid code, FreeSWITCH runs bridge and sends the B-leg back to Kamailio rather than straight to a carrier, which keeps carrier routing in one place. Kamailio recognises FreeSWITCH with allow_source_address() from permissions. This is the only route to the carriers that skips the digest challenge, which makes the trust list a security control. Keep it to the exact FreeSWITCH addresses, and review it whenever a node is added or removed.
A second dispatcher set holds the carriers. If the first carrier returns a 503 or times out, t_on_failure() and ds_next_dst() try the second, as long as dispatcher's failover support is switched on (the flags parameter, value 2). Answers like 404 or 486 are final, and retrying them only wastes time. If calls stall instead of failing over, the dispatcher failover guide walks through the checks.
The carrier sends 183 with early media, and the caller hears ringback from the mobile network. Then it sends 200 OK and the call is up. Unless you set bypass or proxy media, FreeSWITCH stays in the media path: phone to rtpengine to FreeSWITCH, then on to the carrier, through rtpengine again if you anchor that leg too. If anyone reports hearing only one side, the one-way audio guide is the place to start.
09:07: hang-up and the CDR
The caller hangs up. The phone sends a BYE, and loose_route() follows the Record-Route headers through Kamailio to FreeSWITCH. FreeSWITCH hangs up the B-leg and sends its own BYE to the carrier, again through Kamailio. rtpengine_manage() sees each BYE and releases the media ports. The dialog ends, and the account's profile count drops by one.
That leaves two sets of records. With cdr_enable set, Kamailio's acc module writes a CDR when a tracked dialog ends, with start time, end time and duration. In this example that's the phone leg, where dlg_manage() runs; call it on FreeSWITCH's carrier leg too if you want Kamailio to record that as well. FreeSWITCH writes its own: mod_cdr_csv is loaded by default, and mod_xml_cdr can post records to a web service. Both can include the hangup cause and the project code variable. mod_cdr_csv logs only the A-leg unless you set its legs parameter to ab. Each leg has its own Call-ID, so pass a shared identifier between the systems if you want to join the records later.
Ops teams usually spot fraud in the CDRs first, provided someone is looking. Call counts per account and per destination, plotted over time, show unusual activity well before the carrier invoice arrives.
Where to look when a step fails
- Phone won't register: TLS,
auth_db, or NAT handling innathelper. - Calls rejected with 403 or 407: auth, the From/auth user check, or a dialog profile limit.
- Keypresses ignored at the prompt: DTMF negotiation between the phone, rtpengine and the
mod_sofiaprofile. - Calls fail on one carrier but not the other: dispatcher state, failure route and carrier responses.
- No audio or one-way audio: rtpengine and the SDP that
rtpengine_manage()wrote. - Billing doesn't match the carrier: which side answered first, and which CDR you're reading.
Most of a Kamailio and FreeSWITCH investigation comes down to working out which line of that list you're on. Run supported releases of all three projects, keep up with their security announcements and patches, and raise bugs through the projects' own trackers and mailing lists. The edge you secured last year needs the same attention this year.
Staying current
On the Kamailio side, that means the 6.1 series. Kamailio 6.1.4 is the latest stable release at the time of writing, and 5.8.8 was the last release planned for 5.8, so a 5.8 edge is now overdue an upgrade. Kamailio is very good at backwards compatibility. Point releases keep the configuration file and database schema compatible, so moving within a series needs no changes. Between series, the official upgrade guides from 5.8 to 6.0 and 6.0 to 6.1 list only a handful of changes: a few archived modules, one removed dialog parameter (dlg_flag), app_python3 dropping its legacy non-KEMI interface, and two small database changes, to the htable columns and to the acc_cdrs duration column. Check your config with kamailio -c, apply those database changes, and test on a staging node before you move the edge.
FreeSWITCH's current series is 1.11, and its recent releases include security fixes, so the media tier deserves the same attention. If you'd like a hand planning either upgrade, it's everyday work for our Kamailio and FreeSWITCH team.