Aaryan

/Anatomy of a Kamailio Config

August 26, 2026

Kamailio

SIP

VoIP

Telephony

Kamailio is the workhorse of serious SIP deployments: a proxy that sits between the wilds of the public telephone network and your application, and decides — for every SIP message — whether to accept it, reject it, rewrite it, or relay it. All of those decisions live in one place: kamailio.cfg.

Config files for proxies are usually boring. This one is not — it is your routing logic, written in a small DSL of conditions, function calls, and exit statements. This post takes a production-style request_route apart line by line, so the next config you read (or write) makes sense.

The three parts of a kamailio.cfg

Every config file has the same skeleton:

1. Global parameters     — listen sockets, logging, SIP timers
2. Modules               — loadmodule + modparam lines
3. Routing blocks        — where the actual logic lives

Global parameters say where Kamailio listens (listen=udp:0.0.0.0:5060, TLS sockets and so on) and how it behaves. Modules add capabilities — tm.so gives you transaction state, sl.so stateless replies, permissions.so address-based access control. modparam lines feed each module its settings.

Then come the routing blocks. The two you touch most:

  • request_route — runs once for every SIP request (INVITE, BYE, REGISTER, ...). The main entry point.
  • route[NAME] — named blocks you call like functions from other routes, with route(NAME).

There are also event-driven blocks — on_reply, failure_route, branch_route, event_route — which the transaction machinery invokes later, as replies and failures come back.

With that, let's read the main block.

request_route, line by line

Loop protection: Max-Forwards

if (!mf_process_maxfwd_header("10")) {
    sl_send_reply("483", "Too Many Hops");
    exit;
}

SIP has a TTL for messages: the Max-Forwards header, decremented by every proxy. mf_process_maxfwd_header("10") decrements it and fails if it hits zero. Without this check, a routing loop between two proxies forwards the same INVITE to each other forever — so it comes first, before the message costs you anything. sl_send_reply fires a stateless reply (cheap, no transaction created), and exit stops processing immediately.

Structural validation: sanity_check

if (!sanity_check("17895", "7")) {
    xlog("L_WARN", "Malformed SIP from $si:$sp\n");
    exit;
}

Fuzzed packets, broken clients, and malformed messages from port scanners all arrive at a public SIP port. sanity_check validates the message structure — required headers, parseable URIs, sane Content-Length — before anything else looks at it. The first argument is a bitmask of checks, the second a minimum header count. Malformed junk gets logged (note the pseudo-variables: $si source IP, $sp source port) and silently dropped: there is no point replying to something that broken.

In-dialog traffic: has_totag and loose_route

if (has_totag()) {
    if (loose_route()) {
        t_on_reply("LOG_REPLY");
        t_on_failure("LOG_INDLG_FAILURE");
        route(RELAY);
    } else if (is_method("ACK") && t_check_trans()) {
        route(RELAY);
    } else {
        sl_send_reply("404", "Not here");
    }
    exit;
}

A To header containing a tag means this request belongs to an existing dialog — re-INVITEs, BYEs, REFERs, NOTIFYs. Mid-dialog messages carry a Route header stamped by Record-Route when the dialog was set up; loose_route() uses exactly that to send them where the dialog expects, and the transaction module (tm) rewrites the destination so you don't have to think about it. Before relaying we arm the callbacks — t_on_reply and t_on_failure name the blocks that run when answers come back — then hand off to route(RELAY).

ACK is the odd one out: it terminates the INVITE transaction but has no transaction of its own, so it can't be loose_routed the same way. t_check_trans() matches it against a known pending transaction — if there isn't one, the ACK belongs to nobody and is dropped.

And the 404? If a mid-dialog request has no Route header and matches no transaction, it's almost certainly stray replayed or misrouted traffic. Answer "Not here" and be done.

CANCEL: only if there's something to cancel

if (is_method("CANCEL")) {
    if (t_check_trans()) { route(RELAY); }
    exit;
}

A CANCEL aborts a ringing INVITE. It only makes sense if that INVITE exists here — t_check_trans() verifies it, which also stops an attacker from using your proxy to amplify fake CANCELs. Unknown CANCELs fall through the if and hit the bare exit: swallowed silently.

OPTIONS: answer and move on

if (is_method("OPTIONS")) {
    sl_send_reply("200", "OK");
    exit;
}

OPTIONS is the SIP ping. Providers and SBCs use it for keepalives and health probes. Answering 200 OK statelessly costs nothing and keeps upstream monitors happy — you almost never want to route it.

INVITE: dispatch by where it came from

if (is_method("INVITE")) {
    # Outbound from the application server (loopback)
    if (src_ip == 127.0.0.1) {
        route(FROM_APP);
        exit;
    }
 
    # Inbound — must be from a known trunk provider
    if (!route(CHECK_ACL)) {
        xlog("L_WARN", "BLOCK $si — not on ACL, to=$rU\n");
        sl_send_reply("403", "Forbidden");
        exit;
    }
 
    route(FROM_PROVIDER);
    exit;
}

This is the interesting split. Calls flow in two directions:

  • Outbound — your application dials out. It talks to Kamailio on loopback, so src_ip == 127.0.0.1 identifies it with zero auth overhead, and route(FROM_APP) handles rewriting before the trunk.
  • Inbound — a call arriving from the internet. Before you spend a single cycle on it, verify the sender is a provider you actually have a trunk with. That's the ACL — and it's a named route, which deserves its own section.

The catch-all

xlog("L_WARN", "405 $rm cid=$ci from=$si:$sp — method not handled\n");
sl_send_reply("405", "Method Not Allowed");

Anything that survived all the branches above is a method this proxy simply doesn't handle. Say so explicitly — and log it with $rm (method) and $ci (Call-ID) so you can see what's knocking.

route[CHECK_ACL]: access control as a building block

The inbound INVITE branch calls route(CHECK_ACL) inside an if. Named routes return a value, and that value is usable as a condition — which makes routes like this composable: the check lives in one place, and every caller reuses it.

Here's a typical implementation using the permissions module:

loadmodule "permissions.so"
modparam("permissions", "db_url", DB_URL)
 
route[CHECK_ACL] {
    # Group 1 = trunk provider addresses (the "address" table)
    if (allow_source_address(1)) {
        return 1;
    }
    return -1;
}

allow_source_address(1) checks the source IP against address group 1 — a list maintained in the address table (or loaded from a file at startup). return 1 makes route(CHECK_ACL) truthy; return -1 makes it false, so the caller's if (!route(CHECK_ACL)) fires the 403.

Why a route, instead of inlining the check? Because "is this sender known" is asked in more than one place — INVITEs, REGISTERs, message routing — and you want one list, one lookup, one log line. When you add a second provider, you add a row to the table, not three edits across the config.

The poor-cousin version works too and is fine to start with:

route[CHECK_ACL] {
    if ($si == "203.0.113.10" || $si == "198.51.100.7") {
        return 1;
    }
    return -1;
}

Hardcoded IPs mean a config reload for every change — the permissions table exists precisely so you don't do that.

The mental model

Reading a request_route is reading a funnel, ordered from cheapest to most specific:

  1. Kill the impossible — loops, malformed messages.
  2. Handle the self-contained — in-dialog traffic, CANCEL, OPTIONS.
  3. Dispatch the real work — INVITE, split by where it comes from and whether the sender is trusted.
  4. Refuse everything else loudly.

Two last conventions make the whole file readable. exit ends processing for this message entirely; return ends only the current route block and hands control back to the caller — the same distinction as a process aborting versus a function returning. And when you arm t_on_reply/t_on_failure before relaying, later events have somewhere to go: the transaction module remembers, even though the request long since left the building.

That's really all a kamailio.cfg is: a very honest, very fast if-tree about whose traffic you trust and where each message should go next.