Showing posts with label kamailio. Show all posts
Showing posts with label kamailio. Show all posts

Tuesday, February 10, 2026

Notes about using $var(...) in Kamailio

Everyone using Kamailio knows that $var() is a tricky beast. If you are not resetting it at the beginning of a script, you might get interesting effects later, or trying to use it with async statements is also not a good idea.

One of the things that tends to be overlooked is that now $null is implemented, despite its description in the documentation.

TL;DR: there is no $null, just a "0" (zero) value.

But one more thing is that an empty string is also not an empty string, but "0". So having  a statement like

uac_replace_from("$var(name)", "...");

will give you actually "0" in a CallerID name if you made something like

$var(name) = "";

before. Tricky beast as is. 

P.S.: To add, $vn(...) will also threat empty string as $null, but in this case will do even worse making From header like

From: <null> <sip:....> 

which is just a malformed header.

Thursday, June 5, 2025

Granulating hangup causes on Asterisk with Kamailio. Or getting one.

 I'm not touching Asterisk SIP-related things a lot, but when I do, it can be frustrating from time to time. Or maybe I don't know the "correct way" of doing things.

Task is simple - after invoking Dial() get something more granular than ${DIALSTATUS}. Especially in a case of failed call. 

But we also have a ${HANGUPCAUSE} variable here. And turns out, it integer value is coming directly from SIP responce, Reason header. Which we can modify on Kamailio.

So, if we have an answer something like  

SIP/2.0 604 Does not exist anywhere
...

from upstream, we can add/modify custom reason with something like:

kamailio.conf

onreply_route{

...

   if ($rs == 604) {

      remove_hf("Reason");

      append_hf("Reason: Q.850;cause=3;text=\"No route to destination\"\n");

   }

}

And in Asterisk we will have 3 in a ${HANGUPCAUSE} after Dial(). Point, there is a limitation on possible numbers here.

Other option to get it purely on Asterisk could be using  ${HANGUPCAUSE(chan,tech)} function, but this required knowing of an outgoing channel name. Which we can also get in a parent channel via Pre-Dial Handler (see example here) and passing this name via AstDB for example using parent ${CHANNEL} as a key, as I'm not sure we can propagate variable up to stack.

As an example:

extensions.conf

 ...

    same => n,Set(__PARENT_CHANNEL=${CHANNEL})

    same => n,Dial(PJSIP/${EXTEN}@${TRUNK},,b(save_child_channel^s^1)) 

    same => n,Set(CHILD_CHANNEL=${DB_DELETE(chan_save/${CHANNEL})})

    same => n,NoOp(SIP Reply: ${HANGUPCAUSE(${CHILD_CHANNEL},tech)}

[save_child_channel]

exten => s,1,Set(DB(chan_save/${PARENT_CHANNEL})=${CHANNEL})

    same => n,Return()

 A bit overcomplicated as for me, but maybe I miss some "built-in function".

Friday, May 16, 2025

Kamailio TLS connection lifetime

 TL;DR;

Pay attention to 

modparam("tls", "connection_timeout", 600) # Default

 A bit longer.

Usually, you don't need to use SIP OPTIONS ping over TCP connections. Because you have TCP keepalives built-in. And the default registration time is 1h, which is also generous in the case of TCP (TLS). But the default connection timeout, found in mod_tls parameters is only 10 minutes. So, if your registration time is lower than 10 minutes and there are no calls on this phone (which is ok as well), you'll have a TLS connection drop every 10 minutes with a corresponding indication on the phone itself.

 Again, defaults might not be 100% sane.

Wednesday, March 5, 2025

Kamailio - change script behavoir at a realtime

For the moment, it's impossible to apply changes to Kamailio config file (not using KEMI, for sure) without restarting Kamailio.

Here is a small trick that you can use to enable/disable some parts of a script in a realtime, that is very useful for debugging. Not the whole script, but something.

kamailio.cfg

#!KAMAILIO

# REALTIME changable parameters
...
# Enable or disable test numbers function. Done as realtime not to use group (SQL) functions every call
realtime.test_destination=0 

...

 route[TEST_DESTINATION_DETECT] {
    if ($(sel(cfg_get.realtime.test_destination){s.int}) != 1) {
        return;
    }

    if (is_user_in("To", "test-dst-to")) {
        xlog("$var(debug_level)", "[TEST_DESTINATION_DETECT] $rU as a test destination TO number\n");

        setflag(FLT_TESTNUMBER);
        return;
    }

    if (is_user_in("From", "test-dst-from")) {
        xlog("$var(debug_level)", "[TEST_DESTINATION_DETECT] $fU as a test destination FROM number\n");

        setflag(FLT_TESTNUMBER);
    }
}

In this example the whole route execution is based on a value of realtime.test_destination parameter. By default it's 0 (means false), but can be easily changed with 

# kamcmd cfg.set realtime test_destination 1

And no need to restart script. Small, but useful trick for me. Only thing to keep in mind, that unlike KEMI this trick is not taking into accout transaction states, etc. Just change and that's it.


P.S:  Got some feedback, that $(set(cfg_get...)) might lead to a crashes on a high load. Htable might be a good alternative for this as well.

Thursday, October 10, 2024

Collecting MOS stats from rtpengine with Kamailio

 Not a secret, that rtpengine can collect MOS stats during a call. So, this small note is just filling some gaps in an official documentation:

kamailio.cfg

# rtpengine cumulative MOS
modparam("rtpengine", "mos_min_pv", "$avp(re_mos_min)")
modparam("rtpengine", "mos_min_at_pv", "$avp(re_mos_min_at)")
...
# rtpengine A-Leg MOS
modparam("rtpengine", "mos_A_label_pv", "$avp(re_mos_A_label)")
modparam("rtpengine", "mos_min_A_pv", "$avp(re_mos_min_A)")
modparam("rtpengine", "mos_min_packetloss_A_pv", "$avp(re_mos_min_packetloss_A)")
...
# rtpengine B-Leg MOS
modparam("rtpengine", "mos_B_label_pv", "$avp(re_mos_B_label)")
modparam("rtpengine", "mos_min_B_pv", "$avp(re_mos_min_B)")
modparam("rtpengine", "mos_min_packetloss_B_pv", "$avp(re_mos_min_packetloss_B)")
...

# route is called on both request and responce.
route[RTPENGINE_MANAGE]
# Here we're adding labels to A and B channels.
  ...
  # Values would be used later in rtpengine_delete() call
  if (is_request()) {
    $var(rtpengine_params) = $var(rtpengine_params) + " label=leg_A";
  } else {
    $var(rtpengine_params) = $var(rtpengine_params) + " label=leg_B";
  }

  ...
  rtpengine_manage("$var(rtpengine_params)");

}

...

# This route is called on freeing resources on rtpengine
route[RTPENGINE_DELETE] {

  # Here we're initializing variables that were mentioned in modparam.
  $avp(re_mos_A_label) = "leg_A";
  $avp(re_mos_B_label) = "leg_B";
 
  rtpengine_delete();

  # Print quality stats for the call
  if (!is_method("BYE")) {
        return;
  }

  if ($avp(re_mos_average) != $null) {
        xlog("L_NOTICE", "[CALL_STATS][MIN] MOS:$avp(re_mos_min) T:$avp(re_mos_min_at) L:$avp(re_mos_min_packetloss) J:$avp(re_mos_min_jitter) RTT:$avp(re_mos_min_roundtrip) [MAX] MOS:$avp(re_mos_max) T:$avp(re_mos_max_at) L:$avp(re_mos_max_packetloss) J:$avp(re_mos_max_jitter) RTT:$avp(re_mos_max_roundtrip) [AVG] MOS:$avp(re_mos_average) L:$avp(re_mos_average_packetloss) J:$avp(re_mos_average_jitter) RTT:$avp(re_mos_average_roundtrip)\n");
  }
  if ($avp(re_mos_average_A) != $null) {
        xlog("L_NOTICE", "[CALL_STATS][A][MIN] MOS:$avp(re_mos_min_A) T:$avp(re_mos_min_at_A) L:$avp(re_mos_min_packetloss_A) J:$avp(re_mos_min_jitter_A) RTT: $avp(re_mos_min_roundtrip_A) [MAX] MOS:$avp(re_mos_max_A) T:$avp(re_mos_max_at_A) L:$avp(re_mos_max_packetloss_A) J:$avp(re_mos_max_jitter_A) RTT:$avp(re_mos_max_roundtrip_A) [AVG] MOS:$avp(re_mos_average_A) L:$avp(re_mos_average_packetloss_A) J:$avp(re_mos_average_jitter_A) RTT:$avp(re_mos_average_roundtrip_A)\n");
  }
  if ($avp(re_mos_average_B) != $null) {
        xlog("L_NOTICE", "[CALL_STATS][B][MIN] MOS:$avp(re_mos_min_B) T:$avp(re_mos_min_at_B) L:$avp(re_mos_min_packetloss_B) J:$avp(re_mos_min_jitter_B) RTT:$avp(re_mos_min_roundtrip_B) [MAX] MOS:$avp(re_mos_max_B) T:$avp(re_mos_max_at_B) L:$avp(re_mos_max_packetloss_B) J:$avp(re_mos_max_jitter_B) RTT:$avp(re_mos_max_roundtrip_B) [AVG] MOS:$avp(re_mos_average_B) L:$avp(re_mos_average_packetloss_B) J:$avp(re_mos_average_jitter_B) RTT:$avp(re_mos_average_roundtrip_B)\n");
  }
}

Example is not far from the documentation, but just to point how initialize the corresponding variables correctly.

Tuesday, May 28, 2024

Sublime syntax for Kamailio

TL;DR;

Now you can install it via Package Control. 

 Longer story:

For a long time I've been using Visual Studio Code for writing code and it's still a great product, it's becoming more and more bloated with M$ telemetry and even using VSCodium does not help a lot (especially how hard to get Pylance working there).

So now it's a good time to "prepare a spare airfield" and every time I'm going back to Sublime. Yes, it's paid, but it's worth it. And Sublime Merge is the best git GUI over there for me.

One of the things that were missing for me, was the lack of syntax highlighting for Kamailio in Sublime Text.

Luckily, there is a syntax file for VSCode from Daniel-Constantin Mierla, so it was really easy to adapt it for Sublime.

The files are here. It's less advanced than the original file, mainly cause I need to dig into syntax format a bit more, but as a start, it will work.

Now it's a bit easier to write Kamailio configs in Sublime

Github link

Friday, August 18, 2023

Importance of creating dialog in Kamailio right

I know, the things described here might be obvious and considered "for beginners", but everyone can make stupid errors. Even the best of us :)
So, what I'm using Kamailio dialog module for?

  • Keepalives with ka_timer parameter, mainly cause of the nature of mobile clients that are unstable due to the mobile network nature
  • Presence PUBLISH'es via pua/pua_dialoginfo modules to separate presence server

With the second one actually, I've issued some "problems" due to configuration.

Most examples (at least what I've seen) on Kamailio configs, that are operating with the dialogs, have more-or-less this structure:

request_route {
   route(REQINIT);

   if (is_method("INVITE")) {
       dlg_manage();
   }

   route(WITHINDLG);

   ...

   route(AUTH);

   ...

}

There is a problem with this code. And it is, that this code creates dialogs for EVERY INVITE, even if not authed. 

So, the usual flow INVITE - 401 - INVITE (w/auth) - 200 will create 2 dialogs in this case. The first one will be not terminated correctly with connection with pua module. It will generate PUBLISH with the Trying state,  but on 401 - ACK, there would be no corresponding PUBLISH for the terminated state. And as usual, Expire here is 3600, and you will have a lot of "ringing" devices on your presence server.

The answer would be: create dialogs only for authed (and valid) INVITEs. This will also save some CPU not to create dialogs for spammers.

request_route {
   route(REQINIT);

   route(WITHINDLG);

   ...

   route(AUTH);

   if (is_method("INVITE")) {
       dlg_manage();
   }

   ...

}

Tuesday, April 4, 2023

Adding a bit of privacy to Kamailio's pua_dialoginfo module

Kamailio is a great product. With really good customisation possibilities.One of examples will follow here.

Task is the following: we have a setup with 2 different servers, proxy and presence server. Presence server is simple and just processing SUBSCRIBE/PUBLISH messages and generating NOTIFY's. Mainly for BLF's.  But users wants to have some privacy in a question, than not everyone can subscribe to anyone and more interesting - monitor who's calling whom. But some of the users, especially in boss-assistant scenarios want to have full information about calls. Like assistant of the boss usually should know who's calling to boss phone and answer (pickup) in some cases. 

Kamailio allows to achieve this scenario, but with a bit of customisation.

Idea is to have concept of 2 groups. 

Group1 for ACL about possibility of user monitor other user in general, second one - having ACL for seeing call details.

Here I'll point on idea with some code examples for proxy part. And just to mention, pickup functions are implemented not on proxy server, so it's not broken for me here.

Proxy

kamailio.cfg

loadmodule "pua.so"
loadmodule "pua_dialoginfo.so"
...
modparam("pua", "db_url", DBURL)
modparam("pua", "outbound_proxy", "sip:<PRESENCE_SERVER>:5060")
modparam("pua", "db_mode", 0)
...
modparam("pua_dialoginfo", "include_callid", 0)
modparam("pua_dialoginfo", "include_tags", 0)
# we want to have info on who's calling
modparam("pua_dialoginfo", "include_localremote", 1)
... 
# Table to allow subscription one on each other. If users share the same group - they can subscribe one to each other.
# By default all shares the same group = 1. If group == 0, that means nobody can see your state.
# Format is <extension>=><list_of_groups_comma_separated>
modparam("htable", "htable", 'presence_view_grp=>size=15;')
# Table to allow subscribers view each other call details data. By default it's forbidden.
modparam("htable", "htable", 'presence_view_details_grp=>size=15;')
# You can fill these tables via database or whatever else method you prefer

...
route(PRESENCE_PRIVACY);
route(WITHINDLG);
...

route(AUTH)
route(PRESENCE)
...
# Presence server route
route[PRESENCE] {
    if(!is_method("PUBLISH|SUBSCRIBE")) {
        return;
    }
    # Check if user is registered
    if (is_method("SUBSCRIBE") && !(registered("location", "$fu", 4) == 1)) {
        append_to_reply("Retry-After: 10\r\n");
        send_reply("500", "Retry Later");
        exit;
    }

   $du = "sip:<PRESENCE_SERVER>:5060";
   
    # By default - allow anyone subscribe to anyone.
    $var(presence_grp_from) = "1";
    $var(presence_grp_to) = "1";

    if ($sht(presence_view_grp=>$fU) != $null) {
        $var(presence_grp_from) = $sht(presence_view_grp=>$fU);
    }

    if ($sht(presence_view_grp=>$tU) != $null) {
        $var(presence_grp_to) = $sht(presence_view_grp=>$tU);
    }


    if (is_method("SUBSCRIBE")) {
        if ((str)$var(presence_grp_to) == "0") {
            # Presence group "0" means fully private.

            send_reply("404", "Subscriber not exists");
            exit;
        }

        $var(group_1) = $var(presence_grp_to);
        $var(group_2) = $var(presence_grp_from);

        if (!route(CHECK_GROUP_INTERSECTION)) {
            # No common groups for From/To
            send_reply("404", "Subscriber not exists");
            exit;
        }
    }

    route(RELAY);
}

route[PRESENCE_PRIVACY] {
    # We're processing only NOTIFY's with Dialog XML data here
    if (!is_method("NOTIFY") || !has_body("application/dialog-info+xml")) {
        return;
    }

    if ($sht(presence_view_details_grp=>$fU) == $null || $sht(presence_view_details_grp=>$tU) == $null) {
        # By default all call data is private
        route(PRESENCE_CLEAR_CALL_DETAILS);
        return;
    }

    $var(group_1) = $sht(presence_view_details_grp=>$tU);
    $var(group_2) = $sht(presence_view_details_grp=>$fU);

    if (!route(CHECK_GROUP_INTERSECTION)) {
        # No common groups for From/To
        route(
PRESENCE_CLEAR_CALL_DETAILS);
        return;
    }

    # At this point all is ok.

}

route[PRESENCE_CLEAR_CALL_DETAILS] {

    # Forming new body of the presence info
    # For replace trick see https://lists.kamailio.org/pipermail/sr-users/2022-February/114185.html
    $xml(body=>doc) = $(rb{s.replace,xmlns=,xyzwq=});
   
    # Forming new XML
    $var(new_body) = "<?xml version=\"1.0\"?>\n";
    $var(new_body) = $var(new_body) + "<dialog-info xmlns=\"" + $xml(body=>xpath:/dialog-info/@xyzwq) + "\"";
    $var(new_body) = $var(new_body) + " version=\"" + $xml(body=>xpath:/dialog-info/@version) + "\"";
    $var(new_body) = $var(new_body) + " state=\"" + $xml(body=>xpath:/dialog-info/@state) + "\"";
    $var(new_body) = $var(new_body) + " entity=\"" + $xml(body=>xpath:/dialog-info/@entity) + "\">\n";
    $var(new_body) = $var(new_body) + "  <dialog id=\"" + $xml(body=>xpath:/dialog-info/dialog/@id) + "\"\n";
    $var(new_body) = $var(new_body) + " direction=\"" + $xml(body=>xpath:/dialog-info/dialog/@direction) + "\">\n";
    $var(new_body) = $var(new_body) + "    <state>" + $xml(body=>xpath:/dialog-info/dialog/state/text()) + "</state>\n";
    $var(new_body) = $var(new_body) + "  </dialog>\n</dialog-info>";

    replace_body_atonce("^.+$", $var(new_body));
}

route[CHECK_GROUP_INTERSECTION] {
# Checks if contents of $var(group_1) and $var(group_2) that are comma-separated list of groups have at least one in common
    $var(group_1_param_count) = $(var(group_1){s.count,,});

    while ($var(group_1_param_count) >= 0) {
        $var(group_1_param) = $(var(group_1){s.select,$var(group_1_param_count),,});

        if (in_list("$var(group_1_param)", "$var(group_2)", ",")) {
            return 1;
        }
        $var(group_1_param_count) = $var(group_1_param_count) - 1;
    }
    return -1;
}

Idea here is we're stripping local/remote data from NOTIFY XML that indicates a dialog. In this case we're indicating fact of the call, but not exposing any sensitive data.

Tuesday, October 25, 2022

Protect Kamailio from TCP/TLS flood

 After stress-testing Kamailio with sipflood tool from sippts suite (which deserves another article), not so good outcome was faced.

Using CentOS 7 default OpenSSL library (1.0.2k-fips) with using Kamailio 5.4-5.6 with TLS transport, it's quite easy to get a segfault inside tls routines. I've found that roughly 10 000 OPTIONS packets with 200 threads is enough to ruin Kamailio process.

Basically, you can DoS the whole server regardless of it's power just with a single mid-range computer.

Solution was found with using Kamailio 5.6, but with tlsa flavour and latest openssl 1.1.x compiled.

Turns out it's a really simple process. 

As we're gonna need to compile Kamailio anyway, assume, that we have all necessary packets for build already on the system.

First - we need to get openssl sources:

# cd /usr/src

# wget https://www.openssl.org/source/openssl-1.1.<latest>.tar.gz

# tar xvf https://www.openssl.org/source/openssl-1.1.<latest>.tar.gz

# cd  openssl-1.1.<latest>

#  ./config

# make

(Optionally) Here we can make sure that this release is passing tests

# yum install perl-Test-Simple

# make test

Next step - point Kamailio to newly compiled openssl

# cd /usr/src

# wget https://www.kamailio.org/pub/kamailio/5.6.<latest>/src/kamailio-5.6.<latest>_src.tar.gz

# tar xvf kamailio-5.6.<latest>_src.tar.gz

# cd kamailio-5.6.<latest>

#  sed -i "s?LIBSSL_STATIC_SRCLIB \?= no?LIBSSL_STATIC_SRCLIB \?= yes?g" ./src/modules/tlsa/Makefile

# sed -i "s?LIBSSL_STATIC_SRCPATH \?= /usr/local/src/openssl?LIBSSL_STATIC_SRCPATH \?= /usr/src/openssl-1.1.<latest>?g" ./src/modules/tlsa/Makefile

...

Than goes your usual Kamailio compiling and don't forget to replace all "tls" module mentions in kamailio.cfg to "tlsa"

Results are much better. But than I've faced, that it's possible to "eat" all TCP connections on Kamailio server with this type of flood.

First - ulimit. Never underestimate defaults.  

# ulimit -n unlimited

Next steps - tune TCP stack.

Disclamer: next provided options are discussable and was not found by me and need to be adjusted to your case

kamailio.conf

...

tcp_connection_lifetime=3605
tcp_max_connections=4096
tls_max_connections=4096
tcp_connect_timeout=5
tcp_async=yes
tcp_keepidle=5
open_files_limit=4096

...

/etc/sysctl.conf

...

# To increase the amount of memory available for socket input/output queues
net.ipv4.tcp_rmem = 4096 25165824 25165824
net.core.rmem_max = 25165824
net.core.rmem_default = 25165824
net.ipv4.tcp_wmem = 4096 65536 25165824
net.core.wmem_max = 25165824
net.core.wmem_default = 65536
net.core.optmem_max = 25165824

# To limit the maximum number of requests queued to a listen socket
net.core.somaxconn = 128

# Tells TCP to instead make decisions that would prefer lower latency.
net.ipv4.tcp_low_latency=1

# Optional (it will increase performance)
net.core.netdev_max_backlog = 1000
net.ipv4.tcp_max_syn_backlog = 128
...

This will help, but not fully (at least in my case, I've must miss something and comments here are really welcomed)

As the second part I've decided to go with Fail2Ban and block flood on iptables level.

Setup is quite simple as well.

First - make sure Kamailio will log flood attempts:

kamailio.conf

...

 loadmodule "pike.so"

modparam("pike", "sampling_time_unit", 2)
modparam("pike", "reqs_density_per_unit", 30)
modparam("pike", "remove_latency", 120)

...

if (!pike_check_req()) {
            xlog("L_ALERT", "[SIP-FIREWALL][FAIL2BAN] $si\n");

            $sht(ipban=>$si) = 1;
            if ($proto != 'udp') {
                tcp_close_connection();
            }
            drop;
        }

...

Next - install and configure Fail2Ban

# yum install -y fail2ban

 /etc/fail2ban/jail.local

[DEFAULT]
# Ban hosts for one hour:
bantime = 3600

# Override /etc/fail2ban/jail.d/00-firewalld.conf:
banaction = iptables-multiport
action      = %(action_mwl)s

[cernphone-iptables]
enabled  = true
filter   = mypbx
action   = iptables-mypbx[name=mypbx, protocol=tcp, blocktype='REJECT --reject-with tcp-reset']
           sendmail[sender=<sender_addr>, dest=<dest_addr> sendername=Fail2Ban]
logpath  = <your_kamailio_logfile>
maxretry = 1
bantime  = 3600s
findtime = 10s
 

 /etc/fail2ban/action.d/iptables-mypbx.conf

[INCLUDES]

before = iptables-common.conf

[Definition]

actionstart = <iptables> -N f2b-<name>
              <iptables> -A f2b-<name> -j <returntype>
              <iptables> -I <chain> -p <protocol> -j f2b-<name>

actionstop = <iptables> -D <chain> -p <protocol>  -j f2b-<name>
             <actionflush>
             <iptables> -X f2b-<name>


actioncheck = <iptables> -n -L <chain> | grep -q 'f2b-<name>[ \t]'

actionban = <iptables> -I f2b-<name> 1 -s <ip> -p <protocol> -j <blocktype>

actionunban = <iptables> -D f2b-<name> -s <ip> -p <protocol> -j <blocktype>

 

 /etc/fail2ban/filter.d/mypbx.local

[Definition]
# filter for kamailio messages
failregex = \[SIP-FIREWALL\]\[FAIL2BAN\] <HOST>$
 

# systemctl enable fail2ban

# systemctl start fail2ban

In this case we will get host banned on iptables level.



Monday, July 11, 2022

Kamailio dynamic logging level

 Yet another method to get dynamic logging level on Kamailio. Means to change logging level on the fly.

First to mention - already built-in method for Kamailio inside corex module. But this one could be very verbose.

Other method is to specify level in xlog command explicitly.

kamailio.cfg

#!KAMAILIO

# Level for realtime logging for messages. To see debug messages in realtime, set it to 2
realtime.debug_level=5

...

debug=2

...

request_route {

    $var(debug_level) = $(sel(cfg_get.realtime.debug_level){s.int});

    ...

    xlog("$var(debug_level)", "This is a debug message\n");

}

And than just adjust this debug_level config variable via shell

# kamcmd cfg.set realtime debug_level 2

to get all xlog messages to be printed, or set it to the greater value like

# kamcmd cfg.set realtime debug_level 5

to get em suppressed.  

You can expand this method to have bigger levels of verbosity via different variables, but usually it's enough like this.

P.S.: Also, as an alternative (or build-in method) there is a possibility to use corex module functionality

Tuesday, March 9, 2021

Kamailio and delayed CANCEL on iOS

One who is working with SIP aware of a problem with mobile devices. Long story short, the only reliable and proper way to deliver SIP calls to mobile devices is push notification.

A usual algo here, the server sends push notification to a device (via Apple or Google PNS respectively) and after mobile device woke up an application, it registers to SIP server and only after this SIP server sends INVITE to application. And last part here - device starts ringing.

So, SIP server have to store info on this call and wait for a device to register. To achieve this, Kamailio offers tsilo module, which is working great. If you haven't seen it or having problems with it, there is a great presentation which is a good starting point.

But there is one problem. After the call is canceled or answered, no more info is stored in this module. So, if push arrives later, than the call was answered, the app will not receive any info after REGISTER. Usually, it's ok, but with iOS > 13, there is a new mechanism of push notifications using for VoIP calls. CallKit (iOS developers may correct me here, cause I'm not). Idea is the following. When iOS device receives push notification for the VoIP call, it first will show a screen with info, that call has arrived. Later, after the app is already wakened up and running, the app is taking control over the calling screen to update caller info, show Accept/Reject buttons, etc. Sounds good. But if the call was canceled (or answered elsewhere) at the moment when Call Screen is shown already, but the app is not ready yet? The app is woken up, sends REGISTER to SIP server, but no INVITE is following. The call is already not this device business. 

Usually, this problem is solved on an application level. Like if an app is not receiving INVITE after push within X seconds, it asks for a remote server for info and shows (or not) corresponding missed call info.

But that's not my case, unfortunately. I'm using slightly rebranded Linphone and not an expert in iOS programming. So, need to resolve everything fully on a server side, where Kamailio is my SIP server.

The idea of the solution is following - if iOS device registers within X seconds after the call start and the call is already canceled or answered - a fake call is simulated to this endpoint. X could be any, but we come to 15 seconds. For simulating call the famous sipp is used.

Here following some parts of the code

kamailio.conf

# Flag to define that the call was locally generated via SIPP

#!define FLT_LOCALGEN 4

...

# Save info iOS CallKit workaround
modparam("htable", "htable", "ios_reg=>size=5;autoexpire=2;")
modparam("htable", "htable", "ios_state=>size=5;autoexpire=15")

...

request_route {

...

    if (is_method("CANCEL")) {
        # inbound CANCEL

        xlog("L_INFO", "[REQUEST_ROUTE] Detected CANCEL, saving state\n");

        $sht(ios_state=>$rU) = "state=canceled;callid=" + $ci + ";fromnumber=" + $fU;
    }

    # Save info in a case if call was answered to preserve callerID/destination

    if (is_method("INVITE") && !has_totag()) {

        $avp(orig_fU) = $fU;
        $avp(orig_rU) = $rU;

    }

...

onreply_route {

     if ($rs == 200) {
        # Save call state fr
        xlog("L_INFO", "[MANAGE_REPLY] Call $avp(
orig_fU) -> $avp(orig_rU) was answered\n");

        $sht(ios_state=>$avp(orig_rU)) = "state=answered;callid=" + $ci + ";fromnumber=" + $avp(
orig_fU);
    }

...

route[AUTH]

...

   if ($si == 'MY_IP_ADDR') {
        xlog("L_NOTICE", "[AUTH] Packet from ourselves\n");

        setflag(FLT_LOCALGEN);
        return;
    }

...

# We don't want  this INVITE to be processed via tsilo routine.

route[INVITE_STORE] {

    if (isflagset(FLT_LOCALGEN)) {
        return;
    }

...

 route[PUSH_NOTIFICATION_ON_INVITE] {
    if (!is_method("INVITE") || isflagset(FLT_LOCALGEN)) {
        return;
    }

...

# Small extra route to be called after succesful save("location")

route[REGISTER_IOS] {
    if (!($ua =~ "LinphoneiOS*")) {
        xlog("L_INFO", "[REGISTER_IOS] Not IPhone, not interesting...\n");

        return;
    }

    xlog("L_INFO", "[REGISTER_IOS] IPhone registered, saving contact for 2 sec\n");

#  Saving location info for later
    $var(ios_contact) = "sip:" + $fU + "@" + $si + ":" + $sp + ";transport=" + $pr;

# Adding some random to support several iOS devices per same account.
    $var(ios_reg_hash_id) = $fU + "_" + $RANDOM;

# information on contact is stored in hash memrory, and the hash key would return to us via X-IOS-Contact-Hash header provided by SIPP.
    $sht(ios_reg=>$var(ios_reg_hash_id)) = $var(ios_contact);

    $var(ios_callstate) = $sht(ios_state=>$fU);

    if (not_empty("$var(ios_callstate)")) {
        $var(call_state) = $(var(ios_callstate){param.value,state});
        $var(call_callid) = $(var(ios_callstate){param.value,callid});
        $var(call_from) = $(var(ios_callstate){param.value,fromnumber});

        xlog("L_INFO", "[REGISTER_IOS] Registration from iOS is late. Call state is $var(call_state)\n");

        # Here we actually start call via SIPP

        exec_msg("/usr/bin/nohup launch_sipp.sh $var(call_callid) $fU $var(ios_reg_hash_id) $var(call_state) $var(call_from) >/dev/null 2>& 1 &");
    }
}

...

# Own lookup("location"), but with info saved on [REGISTER_IOS]

route[LOCATION_INTERNAL] {
    if (!isflagset(FLT_LOCALGEN) || !is_present_hf("X-IOS-Contact-Hash")) {
        return;
    }

    $var(ios_reg_hash_id) = $hdr(X-IOS-Contact-Hash);
    $var(ios_contact) = $sht(ios_reg=>$var(ios_reg_hash_id));

    remove_hf("X-IOS-Contact-Hash");

    if (!not_empty("$var(ios_contact)")) {
        xlog("L_WARN", "[LOCATION_INTERNAL] Locally generated packet, but no contact was saved\n");

        send_reply("404", "Saved contact not found");
        exit;
    }

    $ru = $var(ios_contact);
    $du = $ru;

    route(RELAY);
}

launch_sipp.sh

#!/bin/bash

if [ -z "${1}" ]; then
    echo "CallID is not specified"
    exit 1
fi
CALL_ID=${1}

if [ -z "${2}" ]; then
    echo "To number is not specified"
    exit 1
fi
TO_NUMBER=${2}

if [ -z "${3}" ]; then
    echo "HASH ID is not specified"
    exit 1
fi
HASH_ID=${3}

REASON=${4:-answered}
FROM_NUMBER=${5:-MY_PBX}
FROM_NAME=${6:-${FROM_NUMBER}}

# Create CSV file for SIPP first
/usr/bin/echo -e "SEQUENTIAL\n${TO_NUMBER};${FROM_NUMBER};'${FROM_NAME}';${HASH_ID};${REASON};" >> /tmp/invite_cancel_$$.csv

# Run SIPP with provided CallID, as it should be same as declared in Push Notification
/usr/bin/sipp localhost -sf invite_cancel.xml -inf /tmp/invite_cancel_$$.csv -m 1 -cid_str ${CALL_ID}
rm /tmp/invite_cancel_$$.csv

invite_cancel.xml

 <?xml version="1.0" encoding="UTF-8" ?>

<!-- This sipp scenario combines 2 types of CANCEL with different reasons
Reason "Call Completed Elsewhere" will not get into a missed calls list
CANCEL without a reason will get there
Which CANCEL would be sent, controlled by field 4 in CSV line
If this field == 'answered', call is considered answered and will not get into missed call list
In all other cases call considered missed -->

<scenario name="iOS CallKit canceler">
 <send>
  <![CDATA[

    INVITE sip:[field0 line=0]@[remote_ip]:[remote_port] SIP/2.0
    Via: SIP/2.0/[transport] [local_ip]:[local_port]
    From: <sip:[field1 line=0]@[local_ip]:[local_port]>;tag=[call_number]_sipp_call_canceler_[call_number]
    To: <sip:[field0 line=0]@[remote_ip]:[remote_port]>
    Call-ID: [call_id]
    Cseq: [cseq] INVITE
    Allow: OPTIONS, SUBSCRIBE, NOTIFY, PUBLISH, INVITE, ACK, BYE, CANCEL, UPDATE, PRACK, REGISTER, MESSAGE, REFER
    Supported: 100rel, timer, replaces, norefersub
    Session-Expires: 1800
    Min-SE: 90
    Contact: sip:asterisk@[local_ip]:[local_port]
    Max-Forwards: 70
    X-IOS-Contact-Hash: [field3 line=0]
    Content-Type: application/sdp
    Content-Length: [len]

    v=0
    o=- 558046395 558046395 IN IP[media_ip_type] [media_ip]
    s=Asterisk
    c=IN IP[media_ip_type] [media_ip]
    t=0 0
    m=audio 16170 RTP/AVP 107 8 0 101
    a=rtpmap:107 opus/48000/2
    a=fmtp:107 useinbandfec=1
    a=rtpmap:8 PCMA/8000
    a=rtpmap:0 PCMU/8000
    a=rtpmap:101 telephone-event/8000
    a=fmtp:101 0-16
    a=ptime:20
    a=maxptime:20
    a=sendrecv
    m=video 14074 RTP/AVP 100
    a=rtpmap:100 VP8/90000
    a=sendrecv

  ]]>
 </send>

 <!-- Wait for provisional responces and if something wrong (404 or timeout) - end scenario -->
 <recv response="100" optional="true" timeout="5000" ontimeout="12"/>
 <recv response="404" optional="true" next="12"/>
 <recv response="180" timeout="5000" ontimeout="12" />

<!-- Check if field4 == 'answered' and assign bool result to $3 -->

 <nop>
  <action>
    <assignstr assign_to="1" value="[field4 line=0]" />
    <strcmp assign_to="2" variable="1" value="answered" />
    <test assign_to="3" variable="2" compare="equal" value="0" />
  </action>
 </nop>

<!-- If $3 == true, go to label 10  -->
 <nop next="10" test="3" />

 <send>
  <![CDATA[

    CANCEL sip:[field0 line=0]@[remote_ip]:[remote_port] SIP/2.0
    Via: SIP/2.0/[transport] [local_ip]:[local_port]
    From: <sip:[field1 line=0]@[local_ip]:[local_port]>;tag=[call_number]_sipp_call_canceler_[call_number]
    To: <sip:[field0 line=0]@[remote_ip]:[remote_port]>
    Call-ID: [call_id]
    Cseq: [cseq] CANCEL
    Max-Forwards: 70
    Content-Length: 0

  ]]>
 </send>

 <!-- Simple goto "11" after receiving 200 -->
 <recv response="200" next="11"/>

 <label id="10"/>

 <send>
  <![CDATA[

    CANCEL sip:[field0 line=0]@[remote_ip]:[remote_port] SIP/2.0
    Via: SIP/2.0/[transport] [local_ip]:[local_port]
    From: <sip:[field1 line=0]@[local_ip]:[local_port]>;tag=[call_number]_sipp_call_canceler_[call_number]
    To: <sip:[field0 line=0]@[remote_ip]:[remote_port]>
    Call-ID: [call_id]
    Cseq: [cseq] CANCEL
    Max-Forwards: 70
    Reason: SIP;cause=200;text="Call completed elsewhere"
    Content-Length: 0

  ]]>
 </send>

 <recv response="200" />

 <label id="11"/>

 <recv response="487" />

 <send>
  <![CDATA[

    ACK sip:[field0 line=0]@[remote_ip]:[remote_port] SIP/2.0
    Via: SIP/2.0/[transport] [local_ip]:[local_port]
    From: <sip:[field1 line=0]@[local_ip]:[local_port]>;tag=[call_number]_sipp_call_canceler_[call_number]
    [last_To:]
    Call-ID: [call_id]
    Cseq: [cseq] ACK
    Contact: sip:asterisk@[local_ip]:[local_port]
    Content-Length: 0

  ]]>
 </send>

 <label id="12" />

</scenario>


A bit dirty solution, but it's working.

Tuesday, December 15, 2020

Loop protection on Kamailio/Asterisk

 Sometimes, due to incorrect configuration, even not on the system we control, we can have call loops. Imagine a situation with loop call forward with one of the endpoints is a cell phone and forward is made via another operator. Usually, you can't get all these loops relying only on the internal pre-save mechanism.

 

But there is a simple and efficient method to avoid such loops. Idea is to limit calls from A to B, if there are more than X such calls per second.

 

Proposed solutions are Kamailio and Asterisk's implementation of these scenarios.

Kamailio method is based on htable

...

# Not more than FROM_TO_PER_SECOND_RATIO calls per second from X number to Y number. If it is set to 3,  4th call would be blocked
#!define FROM_TO_PER_SECOND_RATIO 3

...

loadmodule "htable.so"

modparam("htable", "htable", "fu_tu=>size=5;autoexpire=5;")

...

request_route{

...

     # Process only new calls
    if (is_method("INVITE") && !has_totag()) {

        $var(from_to_hash) = $fU + '_' + $tU + '_' + $timef(%H_%M_%S);

        if ($sht(fu_tu=>$var(from_to_hash)) != $null) {
            $sht(fu_tu=>$var(from_to_hash)) = $sht(fu_tu=>$var(from_to_hash)) + 1;
            xlog("L_NOTICE", "Possible loop detected on call $fu -> $tu ($sht(fu_tu=>$var(from_to_hash)) total calls during last second)\n");
        } else {
            xlog("L_INFO", "Loop protection enabled on call $fu -> $tu with $var(from_to_hash) hash)\n");
            $sht(fu_tu=>$var(from_to_hash)) = 1;
        }

        if ($sht(fu_tu=>$var(from_to_hash)) > FROM_TO_PER_SECOND_RATIO) {
            xlog("L_WARN", "Loop detected on call $fu -> $tu (IP:$si:$sp)\n");
            send_reply("482", "Loop detected");
            exit;
        }
    }

...

}


Asterisk method is based on GROUP()

[dial_all]

exten => _X.,1,NoOp(Main call context)

  same => n,GoSub(loop_protection, ${EXTEN}, 1)

...

 

[loop_protection]
; Provide loop protection based on calls per second on same from/to numbers.
exten => _X.,1,NoOp(Loop protection...)
  same => n,Set(GROUP_HASH=${CALLERID(num)}_${EXTEN}_${STRFTIME(${EPOCH},,%Y_%m_%d_%H_%M_%S)})
  same => n,Set(GROUP()=${GROUP_HASH})
  same => n,GoToIf($[${GROUP_COUNT(${GROUP_HASH})}<=3]?limit_ok)
  same => n,NoOp(Total Calls From ${CALLERID(num)} to ${EXTEN} during this second exceeded limit of <%= @loop_cps_ratio %>)
  same => n,Hangup(25)
  same => n(limit_ok),NoOp(Total Calls From ${CALLERID(num)} to ${EXTEN} during this second is: ${GROUP_COUNT(${GROUP_HASH})})
  same => n,Return()


The mechanism is fairly simple on both. Have a hash with the following name - <from_number>_<to_number>_<date_with_second>, calculate it for every new call, and if his hash name already exists - increase it by 1 till limit is reached. After this - drop the call, considering this is a loop.

 

Maybe not the best method, but reliable. Only thing I did not investigate much - possible memory leak on Asterisk with group names. I consider this is something to be tested.

Thursday, October 29, 2020

Kamailio and mobile TCP endpoints. Unregistering

The modern world is mobile. You may like it or not. But it is. So, with all these technologies that are about saving your battery life, all application reachability goes to vendor-lock push servers.
 
But the problem emerges in the following. SIP client registers on registrar (Kamailio) with TCP. Yes, you can do SIP keepalives here, but why to have em if we have a built-in mechanism of keepalive in TCP itself? Plus, additional packets over the network will drain the battery faster. And iOS, when putting the app to the background, just cut the network of app off. Literally killing it.
 
But we somehow need to know which actual state of the endpoint is. And here Kamailio is to help us with tcpops module.
 
The idea is quite simple. On each query to location table, we will clean-up it from "dead" TCP connections. So, after save(). And the only actual state of endpoints would be taken in the branch and calling process. Yes, it will give extra initial INVITE on already 'dead' connection, but appears to be, it sometimes cleaning just arrived REGISTER's so it's acceptable.


# Save info for TCP connections for unregister on close/timeout/error
loadmodule "htable.so"

modparam("htable", "htable", "tcpconn=>size=15;autoexpire=7200;")

...

# Handle SIP registrations
route[REGISTRAR] {

   ...

    save("location");
    route(TRACK_TCP_STATE);

    $var(processed_subscriber) = $fu;

    route(TCP_REGISISTER_CLEANUP);

   ...

}

 

route[TRACK_TCP_STATE] {
    if (proto == UDP) {
        return;
    }
    xlog("[TRACK_TCP_STATE] Saving state for future disconect track of $fu\n");

    $sht(tcpconn=>$conid) = $fu;


    tcp_enable_closed_event();
}


# Make sure you set up $var(processed_subscriber) before calling this route

route[TCP_REGISISTER_CLEANUP] {

    if ($var(processed_subscriber) == $null) {
        return;
    }

    xlog("[TCP_REGISISTER_CLEANUP] Processing subscriber $var(processed_subscriber)\n");

    # Getting registered endpoints for this AoR
    if (!reg_fetch_contacts("location", "$var(processed_subscriber)", "subscriber")) {
        $var(processed_subscriber) = $null;
        xlog("[TCP_REGISISTER_CLEANUP] No registered contacts for $var(processed_subscriber)\n");
        return;
    }

    $var(i) = 0;

    # Loop through registered endpoints
    while ($var(i) < $(ulc(subscriber=>count))) {

        $var(stored_subscriber_conid) = $(ulc(subscriber=>conid)[$var(i)]);

        # Make sure proto is TCP
        if ($var(stored_subscriber_conid) != $null) {

            # Check if entry is still active TCP connection. Unregister otherwise.
            if ($var(stored_subscriber_conid) == -1 || !tcp_conid_alive("$var(stored_subscriber_conid)")) {

                $var(stored_subscriber_ruid) = $(ulc(subscriber=>ruid)[$var(i)]);
                $var(stored_subscriber_address) = $(ulc(subscriber=>addr)[$var(i)]);

                xlog("[TCP_REGISISTER_CLEANUP]: Unregistering entry $var(i)/$var(stored_subscriber_conid) -> $var(stored_subscriber_address)\n");

                if (!unregister("location", "$var(processed_subscriber)", "$var(stored_subscriber_ruid)")) {
                    xlog("[TCP_REGISISTER_CLEANUP]: Unregistering entry $var(i)/$var(stored_subscriber_conid) -> $var(stored_subscriber_address) FAILED!\n");
                }
            }
        }
        $var(i) = $var(i) + 1;
    }
    $var(processed_subscriber) = $null;
}


event_route[tcp:closed] {
    xlog("[TCP:CLOSED] $proto connection closed conid=$conid\n");

    $var(processed_subscriber) = $sht(tcpconn=>$conid);

    if ($var(processed_subscriber) != $null) {
        route(TCP_REGISISTER_CLEANUP);
        $sht(tcpconn=>$conid) = $null;
    }
}

event_route[tcp:timeout] {
    xlog("[TCP:TIMEOUT] $proto connection timeout conid=$conid\n");

    $var(processed_subscriber) = $sht(tcpconn=>$conid);

    if ($var(processed_subscriber) != $null) {
        route(TCP_REGISISTER_CLEANUP);
        $sht(tcpconn=>$conid) = $null;
    }
}

event_route[tcp:reset] {
    xlog("[TCP:RESET] $proto reset closed conid=$conid\n");

    $var(processed_subscriber) = $sht(tcpconn=>$conid);

    if ($var(processed_subscriber) != $null) {
        route(TCP_REGISISTER_CLEANUP);
        $sht(tcpconn=>$conid) = $null;
    }
}


Simple and efficient solution. Yes, some of "dead" endpoints would be present in table during expiration time, but I'm ok with that.

Monday, October 28, 2019

One more SIP firewall based on Kamailio

Sometimes it's impossible to put Kamailio in a front of third-party SIP software and it's nice to have sort of SIP firewall upfront.

One of solutions - use iptables (like I did a long ago here), but overall, IPTables is not a SIP processing engine. But Kamailio is.

So, idea is quite simple. We have our main PBX software sitting on ethX (or ensXX), port 5060. We're mirroring SIP traffic on this interface:port to localhost, where Kamailio is listens to them in promiscuous mode. And writing a log file, Fail2Ban can analyze. 
Target here - is to protect main PBX software from consuming a lot of resources on fake INVITE's and REGISTER's from not-too-friendly-scanners.

First - install Kamailio.
I prefer to put syslog messages to separate file.

Configure Kamailio with file

/etc/kamailio/kamailio.cfg
 
#!KAMAILIO

#!define WITH_ANTIFLOOD
#!define WITH_DOMAIN_NAMES
#!define WITH_SANITY_CHECK
#!define WITH_SCANNER_BODY
#!define WITH_SCANNER_MESSAGE

debug=2
log_stderror=no

memdbg=5
memlog=5

log_facility=LOG_LOCAL7

children=6

auto_aliases=no

listen=udp:127.0.0.1:5060


loadmodule "sl.so"
loadmodule "pv.so"
loadmodule "maxfwd.so"
loadmodule "textops.so"
loadmodule "siputils.so"
loadmodule "xlog.so"
loadmodule "sanity.so"
loadmodule "regex.so"
loadmodule "sipcapture.so"
loadmodule "db_text.so"
# ----------------- setting module-specific parameters ---------------
modparam("sipcapture", "db_url", "text:///tmp/")
modparam("sipcapture", "raw_socket_listen", "127.0.0.1:5060")
modparam("sipcapture", "raw_moni_capture_on", 1)
modparam("sipcapture", "raw_interface", "lo")
modparam("sipcapture", "promiscious_on", 1)

#!ifdef WITH_ANTIFLOOD
loadmodule "pike.so"
# ----- pike params -----
modparam("pike", "sampling_time_unit", 2)
modparam("pike", "reqs_density_per_unit", 16)
modparam("pike", "remove_latency", 4)

loadmodule "htable.so"
# ----- htable params -----
/* ip ban htable with autoexpire after 5 minutes */
modparam("htable", "htable", "ipban=>size=8;autoexpire=300;")
#!endif

####### Routing Logic ########

request_route {


#!ifdef WITH_ANTIFLOOD
    # flood detection from same IP and traffic ban for a while
    # be sure you exclude checking trusted peers, such as pstn gateways
    # - local host excluded (e.g., loop to self)
    if(src_ip!=myself) {
        if($sht(ipban=>$si)!=$null) {
            # ip is already blocked
            xlog("L_NOTICE", "[SIP-FIREWALL][ANTIFLOOD-BANNED] [F=$fu R=$ru D=$du M=$rm IP=($si:$sp $Ri:$Rp) ID=$ci]\n");
            exit;
        }
        if (!pike_check_req()) {
            $sht(ipban=>$si) = 1;
            xlog("L_NOTICE", "[SIP-FIREWALL][ANTIFLOOD-ADD] [F=$fu R=$ru D=$du M=$rm IP=($si:$sp $Ri:$Rp) ID=$ci]\n");
            xlog("L_ALERT", "[SIP-FIREWALL][FAIL2BAN] $si\n");
            exit;
        }
    }
#!endif

#!ifdef WITH_SCANNER_MESSAGE
    if (search("friendly-scanner|sipvicious|sipcli*|vaxasip|sip-scan|iWar|sipsak")) {
        xlog("L_NOTICE", "[SIP-FIREWALL][FRIENDLYSCANNER_MESSAGE]: [F=$fu R=$ru D=$du M=$rm IP=($si:$sp $Ri:$Rp) ID=$ci]");
        xlog("L_ALERT", "[SIP-FIREWALL][FAIL2BAN] $si\n");
        exit;
    }
#!endif
#!ifdef WITH_SCANNER_BODY
    if (search_body("friendly-scanner|sipvicious|sipcli*|vaxasip|sip-scan|iWar|sipsak")) {
        xlog("L_NOTICE", "[SIP-FIREWALL][FRIENDLYSCANNER_BODY]: [F=$fu R=$ru D=$du M=$rm IP=($si:$sp $Ri:$Rp) ID=$ci]");
        xlog("L_ALERT", "[FAIL2BAN] $si\n");
        exit;
    }
#!endif
#!ifdef WITH_SANITY_CHECK
    if(!sanity_check("17895", "7")) {
        xlog("L_NOTICE", "[SIP-FIREWALL][MALFORMED] [F=$fu R=$ru D=$du M=$rm IP=($si:$sp $Ri:$Rp) ID=$ci]\n");
        xlog("L_ALERT", "[SIP-FIREWALL][FAIL2BAN] $si\n");
        exit;
    }
#!endif
#!ifdef WITH_DOMAIN_NAMES
    if (!is_method("INVITE|REGISTER")) {
        exit;
    }

    if (pcre_match("$rd", "^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$$")) {
        if (pcre_match("$fd", "^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$$")) {
            xlog("L_NOTICE", "[SIP-FIREWALL][NOT_DOMAIN_BASED_URI]: [F=$fu R=$ru D=$du M=$rm IP=($si:$sp $Ri:$Rp) ID=$ci]");
            xlog("L_ALERT", "[SIP-FIREWALL][FAIL2BAN] $si\n");
            exit;
        }
    }
#!endif
}



It's actually my file, but you can adopt rules to yours.

Next - set up Fail2Ban

/etc/fail2ban/filter.d/kamailio.conf

[Definition]
# filter for kamailio messages
failregex = [SIP-FIREWALL][FAIL2BAN] <HOST>


/etc/fail2ban/jail.conf
...
[kamailio-iptables]
enabled  = true
filter   = kamailio
action   = iptables-allports[name=KAMAILIO, protocol=all]
logpath  = /var/log/kamailio.log
maxretry = 5
bantime  = 1800
findtime = 60


Next - make sure you don't have net.ipv4.ip_forward set to 1. You will run into infinite loops in this case

# sysctl net.ipv4.ip_forward
net.ipv4.ip_forward = 0


And last - use iptables TEE target to mirror traffic to localhost

# iptables -A PREROUTING -t mangle -i eth0 -p udp --dport 5060 -j TEE --gateway 127.0.0.1

That's it.




Wednesday, March 6, 2019

SIP/Kamaiio not-reachable endpoints and timers.

According to RFC3261 we have following mechanism of reliability:
(3 next blocks are taken from this book)


For non-INVITE transactions, a SIP timer, T1, is started by a UAC or a stateful proxy server when a new request is generated or sent. If no response to the request (as identified by a response containing the identical local tag, remote tag, Call-ID, and CSeq) is received when T1 expires, the request is resent. After a request is retransmitted, the next timer period is doubled until T2 is reached. 
If a provisional (informational class 1xx) response is received, the UAC or stateful proxy server immediately switches to timer T2. After that, the remaining retrans- missions occur at T2 intervals. This capped exponential backoff process is continued until a 64*T1, after which the request is declared dead. 


For an INVITE transaction, the retransmission scheme is slightly different. INVITEs are retransmitted starting at T1, and then the timer is doubled after each retransmission. The INVITE is retransmitted until 64*T1 after which the request is declared dead. After a provisional (1xx) response is received, the INVITE is never retransmitted. A stateful proxy must store a forwarded request or generated response message for 32 seconds. 

Suggested default values for T1 and T2 are 500 ms and 4 seconds, respectively. Timer T1 is supposed to be an estimate of the roundtrip time (RTT) in the network.

Main problem here is if remote side is dead, that we can get it after only 32 seconds. Which is too big, especially when we have some conditions like forward on not reachable. So, normally, decision is taken after not receiving provisional (1xx) response after 5 (or less) seconds. 

Kamailio has a fr_timer to control these type of behavior. By default, it's fully compatible with RFC. But it's not what we need. So, idea is to have fr_timer small initially and then - restore it on receiving provisional reply.

...
route[ON_START_TRANSACTION] {
    t_set_fr(INVITE_TIMEOUT, 2000);
    route(REAY);
}
...
reply_route {
   ...
    if(status =~ "1[0-9][0-9]") {
        # Not set INVITE TIMEOUT here to prevent possible custom values
        t_set_fr(0, 30000);
    }
   ...
}

failure_route {
    ....
    # Restore timers if was reset.
    t_set_fr(INVITE_TIMEOUT, 2000);
    ....
    route(TRY_NEXT_DESTINATION);
}
...