Monday, February 23, 2026

Chromaprint for fingerprinting audio

I'm continuing to develop a VOLTS framework for functional testing of VoIP systems. And one of the problems it tries to catch is a no-media issue, which is greatly detected via sox. But better is actually to know that what is sent/received on audio is correct. And sox is really not a good option here.

Luckily, there is a tool called fpcalc, which is part of a chromaprint/AcoustID project, which is actually aiming for this. In other words - fingerprinting audio files. Only thing here - it's not really for for a short files, so better to have long (10-15 seconds) audio samples.

fpcalc was really easy to integrate into VOLTS, so now you can check not only if audio is present, but also if the audio is correct.

More info can be found on the project page and the VOLTS documentation.

Happy testing! 

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.

Monday, January 26, 2026

Compiling rtpengine 13.5.x on RHEL/Alma/Rocky 9

rtpengine having Debian as a target platform. But sometimes you need Red Hat-based distros.

So, voici how to compile and install rtpengine version mr13.5.1.3 (and I guess all 13.5.x branch) on a plain RHEL 9 system:

Enable Required Repositories

sudo dnf -y install epel-release
sudo dnf -y update


Install Development Tools

sudo dnf -y groupinstall "Development Tools"
sudo dnf -y groupinstall "RPM Development Tools"


Install Build Dependencies

sudo dnf -y install \
    gcc \
    make \
    pkgconfig \
    redhat-rpm-config \
    glib2-devel \
    libcurl-devel \
    openssl-devel \
    pcre-devel \
    zlib-devel \
    hiredis-devel \
    systemd-devel \
    libpcap-devel \
    libevent-devel \
    json-glib-devel \
    gperf \
    perl-IPC-Cmd \
    perl-podlators \
    libatomic \
    ncurses-devel \
    xmlrpc-c-devel \
    iptables-devel \
    iptables-nft \
    libmnl-devel \
    libnftnl-devel \
    pandoc \
    wget \
    git \
    libtiff-devel \
    libjpeg-turbo-devel \
    libsndfile-devel \
    libxml2-devel \
    autoconf \
    automake \
    libtool \
    mosquitto-devel \
    libwebsockets-devel \
    opus-devel \
    libjwt-devel

Install FFmpeg

sudo dnf -y install ffmpeg-free-devel

Note: you can use ffmpeg-devel from RPM-Fusion repo

Install MariaDB Development Files

sudo dnf -y install mariadb-devel mariadb-connector-c-devel

Create mysqlclient.pc symlink (rtpengine looks for this)


sudo ln -sf /usr/lib64/pkgconfig/mariadb.pc /usr/lib64/pkgconfig/mysqlclient.pc

Install Kernel Development Packages (for kernel module)

sudo dnf -y install kernel-devel kernel-headers elfutils-libelf-devel

Create Stub libiptc.pc File

RHEL 9 doesn't provide libiptc via pkg-config. Create a stub file:

sudo tee /usr/lib64/pkgconfig/libiptc.pc << 'EOF'
prefix=/usr
exec_prefix=${prefix}
libdir=${exec_prefix}/lib64
includedir=${prefix}/include

Name: libiptc
Description: Stub libiptc for RHEL 9 (iptables chain management disabled)
Version: 1.8.9
Cflags:
Libs:
EOF

Verify it works

pkg-config --exists libiptc && echo "libiptc.pc created successfully"

Build and Install spandsp from FreeSWITCH Fork

The standard spandsp package doesn't work correctly on RHEL 9. Use the FreeSWITCH fork:

cd /usr/src
sudo git clone https://github.com/freeswitch/spandsp.git
cd spandsp
sudo ./bootstrap.sh
sudo ./configure --prefix=/usr
sudo make -j$(nproc)
sudo make install
sudo ldconfig

Build rtpengine and it's components

cd /usr/src
sudo wget -O rtpengine-mr13.5.1.3.tar.gz \
    https://github.com/sipwise/rtpengine/archive/refs/tags/mr13.5.1.3.tar.gz
sudo tar xzf rtpengine-mr13.5.1.3.tar.gz
sudo mv rtpengine-mr13.5.1.3 rtpengine
cd rtpengine


Build rtpengine Daemon

cd /usr/src/rtpengine/daemon
sudo make RTPENGINE_VERSION=mr13.5.1.3 -j$(nproc) with_iptables_option=no


Build Recording Daemon

cd /usr/src/rtpengine/recording-daemon
sudo make RTPENGINE_VERSION=mr13.5.1.3 -j$(nproc) with_iptables_option=no


Build Kernel Module (Optional)

cd /usr/src/rtpengine/kernel-module
KERNEL_VERSION=$(uname -r)
sudo make RTPENGINE_VERSION=mr13.5.1.3 KSRC=/usr/src/kernels/$KERNEL_VERSION
sudo make install
sudo depmod -a


Install rtpengine

cd /usr/src/rtpengine/daemon
sudo make install with_iptables_option=no

cd /usr/src/rtpengine/recording-daemon
sudo make install with_iptables_option=no


Create rtpengine User and Directories

sudo useradd -r -s /sbin/nologin rtpengine

sudo mkdir -p /var/run/rtpengine
sudo mkdir -p /var/spool/rtpengine
sudo mkdir -p /etc/rtpengine

sudo chown rtpengine:rtpengine /var/run/rtpengine /var/spool/rtpengine


Systemd integration

sudo tee /etc/rtpengine/rtpengine.conf << 'EOF'
[rtpengine]
table = -1
interface = eth0
listen-ng = 127.0.0.1:2223
port-min = 10000
port-max = 20000
log-level = 6
log-facility = daemon
foreground = false
pidfile = /var/run/rtpengine/rtpengine.pid
EOF

sudo tee /etc/systemd/system/rtpengine.service << 'EOF'
[Unit]
Description=RTPEngine Media Proxy
After=network.target

[Service]
Type=forking
PIDFile=/var/run/rtpengine/rtpengine.pid
ExecStart=/usr/bin/rtpengine --config-file=/etc/rtpengine/rtpengine.conf --pidfile=/var/run/rtpengine/rtpengine.pid
ExecStop=/bin/kill -TERM $MAINPID
Restart=on-failure
User=root
LimitNOFILE=65536

[Install]
WantedBy=multi-user.target
EOF

sudo systemctl daemon-reload


Configure Firewall (if applicable)

sudo firewall-cmd --permanent --add-port=10000-20000/udp
sudo firewall-cmd --permanent --add-port=2223/udp
sudo firewall-cmd --reload


Load Kernel Module (if built)

sudo modprobe xt_RTPENGINE

Verify it's loaded
lsmod | grep xt_RTPENGINE

To load on boot, create a modules-load.d file
echo "xt_RTPENGINE" | sudo tee /etc/modules-load.d/rtpengine.conf

Start rtpengine

sudo systemctl enable rtpengine
sudo systemctl start rtpengine
sudo systemctl status rtpengine
rtpengine --version


Verification

ss -ulnp | grep 2223

Test with rtpengine-ctl (if available)
echo "list totals" | nc -u 127.0.0.1 222

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.

Tuesday, January 7, 2025

Asterisk PJSIP endpoint identification by To number

Recently got an interesting task.

For calls, that are made to a specific number, apply DTMF transcoding towards caller side. Means when someone (an automated system, actually) answers on this specific number, it starts send DTMF codes towards caller. But the main issue as, that callers, that are calling to this number accept DTMF only in inband mode, despite that looking on SIP traces they should accept usual RFC4733.

DTMF transcoding (in PJSIP) is handled by Asterisk via endpoint parameters. So, all calls ONLY to this number need to be put into a specific endpoint. Gladly, it's not so hard to achieve, but the documentation is lacking examples. So, here is the one:

pjsip.conf

[global]
...
# Depending on your configuration you might need to adjust the order
endpoint_identifier_order=ip,username,header,anonymous

# Actual endpoint we need call to be placed FROM
[test_endpoint]
type=endpoint
...
dtmf_mode=inband
identify_by=header

[test_endpoint]
type=identify
endpoint=test_endpoint
match_header=To:/123456789/

Now all calls made to a number, that consists sequence 123456789 will be made from test_endpoint and all DTMF towards this endpoint will be transcoded to inband.

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.

Monday, July 29, 2024

Sipsak → sipexer for small SIP tests

I think a lot of peoples who are working with SIP knows and use sipsak. It's a Swiss-army knife for SIP, as it states. But not anymore. In a way migrating to OpenSSL 3.0 and newer distributions proper TLS transport support was lost. Or, in another words, I cannot make it work on Alma 9.

 

So, meet a replacement - sipexer from Kamailio author. Doing mainly the same (for me) and supports TLS and WebSocket out of the box. As a bonus for simple testing, you can get SIP reply code as a return code of the program itself.

So, instead of checks like

 

sipsak -s sip:check_server_health@localhost:5061 --transport=tls

if [ $? != 0 ]; then

  restart_kamailio

fi

 

you can do 

 

sipexer -timeout-write 500 -timeout 500 -vl 0 -mt options -ruser check_server_health tls:localhost:5061 >> /dev/null

if [ $? != 200 ]; then

  restart_kamailio

fi

 

Quite a simple replacement and due to sipexer being written on Go, you can just get the binary and use it right away.

I like it when things become simpler.

Thursday, June 27, 2024

No or distorted sound using SRTP and rtpengine

 

TL;DR;

SDES-pad 

Long version:

Recently had a chance of upgrading rtpengine from version 8 to version 12. 

For those, who don't know what it is, it's just one of the most popular RTP proxy in the opensource ecosystem. And usually coming with Kamailio and OpenSIPS as a default RTP Proxy option nowdays. 

And I must say, it's a really stable software with great backwards compatibility. I did not expected to get any issues on this upgrade, but quite fast, got into situation, when receiving a calls on Linphone (mobile, if it matters to someone), sound get distorted in a case of Android and completely absent in a case of iOS.

First I've blamed a new chiper suites, that had been added and disabled it. (SDES-no-<CHIPER> string in rtpengine_manage(), if someone interested). But that does not help.

In the process of searching I've found this issue on github. And yes, turning on SDES-pad resolved the issue, but I was really interested why this option was introduced in a first place.

So, according to the documentation,

pad

   RFC 4568 (section 6.1) is somewhat ambiguous regarding the base64 encoding format of a=crypto parameters added to an SDP body. The default interpretation is that trailing = characters used for padding should be omitted. With this flag set, these padding characters will be left in place.

Hm, let's read the RFC about it.

   When base64 decoding the key and salt, padding characters (i.e., one or two "=" at the end of the base64-encoded data) are discarded (see [RFC3548] for details).
   Base64 encoding assumes that the base64 encoding input is an integral number of octets.  If a given crypto-suite requires the use of a concatenated key and salt with a length that is not an integral number of octets, said crypto-suite MUST define a padding scheme that results in the base64 input being an integral number of octets.

What I can see here is reference to how base64 is made, just explicit remark, that padding characters are not carrying any data, just.. padding according to the corresponding RFC:

   In some circumstances, the use of padding ("=") in base encoded data is not required nor used.  In the general case, when assumptions on size of transported data cannot be made, padding is required to yield correct decoded data.
   Implementations MUST include appropriate pad characters at the end of encoded data unless the specification referring to this document explicitly states otherwise.

Really, I can't see any way, that padding is "optional" in base64 encoding by default, both RFC's expicitly saying, that padding is needed and this is the case for SDP option a=crypto.

As a takeway for me, "default" options even in poular software not meaning "right".

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, March 1, 2024

Python and random task distribution in ThreadPoolExecutor

Not a usual article for me, but will leave this boilerplate here for myself.

So, the task was the following: send multiple PUBLISH messages over SIPP, but not overload the SIP Proxy, and distribute them in time, like not sending all at once. Yes, I do know SIPP can do this, but I wanted to have SIPP exit code after sending each message and try to re-send it in case of issues. Order of sending is whatever. Just need to be delivered.

Here is the boilerplate Python function that I've used to achieve this

#!/usr/bin/python
import logging
import time
import random

from concurrent.futures import ThreadPoolExecutor

logging.getLogger('paramiko').setLevel(logging.INFO)

logging.basicConfig(
    format = '[%(asctime)s.%(msecs)03d] %(threadName)s %(name)s %(levelname)s: %(message)s',
    level=logging.INFO,
)

def get_timer_delay():
    '''
    Generator to be passed in ThreadPool to have delays in multiple iterated value processes.
    random delay between 5 and 200 ms. When reaching 1 sec - resets back to 0
    '''
    num = 0.0
    while True:
        yield num
        if num < 1:
            num += float(random.randrange(5, 200, 3)) / 1000
        else:
            num = 0.0

def process_iterated_value(value, start_delay):
    '''
    This function is called in thread-wise way to have multiple values processed at the moment
    '''
    time.sleep(start_delay)

    # Call SIPP process here (with exit code control)

    logging.info(f"Processed value {value} with delay {start_delay}")

#### ---------------- Script start ---------------- ########

execute_timeout = get_timer_delay()
iterated_value = range(0, 30)

logging.info("Process start")

with ThreadPoolExecutor(max_workers=5) as executor:
    executor.map(process_iterated_value, iterated_value, execute_timeout)


logging.info("Process end")

Output is looks like this. All values are processed in a more-or-less distributed way that can be seen by timestamps

[00:06:43,375.375] MainThread root INFO: Process start
[00:06:43,375.375] ThreadPoolExecutor-0_0 root INFO: Processed value 0 with delay 0.0
[00:06:43,504.504] ThreadPoolExecutor-0_0 root INFO: Processed value 1 with delay 0.128
[00:06:43,620.620] ThreadPoolExecutor-0_1 root INFO: Processed value 2 with delay 0.244
[00:06:43,794.794] ThreadPoolExecutor-0_2 root INFO: Processed value 3 with delay 0.417
[00:06:43,928.928] ThreadPoolExecutor-0_3 root INFO: Processed value 4 with delay 0.5509999999999999
[00:06:44,053.053] ThreadPoolExecutor-0_4 root INFO: Processed value 5 with delay 0.6759999999999999
[00:06:44,334.334] ThreadPoolExecutor-0_0 root INFO: Processed value 6 with delay 0.828
[00:06:44,334.334] ThreadPoolExecutor-0_0 root INFO: Processed value 11 with delay 0.0
[00:06:44,475.475] ThreadPoolExecutor-0_0 root INFO: Processed value 12 with delay 0.14
[00:06:44,590.590] ThreadPoolExecutor-0_1 root INFO: Processed value 7 with delay 0.968
[00:06:44,689.689] ThreadPoolExecutor-0_0 root INFO: Processed value 13 with delay 0.21400000000000002
[00:06:44,769.769] ThreadPoolExecutor-0_2 root INFO: Processed value 8 with delay 0.973
[00:06:44,914.914] ThreadPoolExecutor-0_3 root INFO: Processed value 9 with delay 0.984
[00:06:44,941.941] ThreadPoolExecutor-0_1 root INFO: Processed value 14 with delay 0.35100000000000003
[00:06:45,185.185] ThreadPoolExecutor-0_0 root INFO: Processed value 15 with delay 0.494
[00:06:45,185.185] ThreadPoolExecutor-0_4 root INFO: Processed value 10 with delay 1.13
[00:06:45,413.413] ThreadPoolExecutor-0_2 root INFO: Processed value 16 with delay 0.643
[00:06:45,641.641] ThreadPoolExecutor-0_3 root INFO: Processed value 17 with delay 0.726
[00:06:45,686.686] ThreadPoolExecutor-0_1 root INFO: Processed value 18 with delay 0.743
[00:06:45,686.686] ThreadPoolExecutor-0_1 root INFO: Processed value 23 with delay 0.0
[00:06:45,740.740] ThreadPoolExecutor-0_1 root INFO: Processed value 24 with delay 0.053
[00:06:45,810.810] ThreadPoolExecutor-0_1 root INFO: Processed value 25 with delay 0.07
[00:06:45,886.886] ThreadPoolExecutor-0_1 root INFO: Processed value 26 with delay 0.07500000000000001
[00:06:45,952.952] ThreadPoolExecutor-0_0 root INFO: Processed value 19 with delay 0.766
[00:06:46,021.021] ThreadPoolExecutor-0_1 root INFO: Processed value 27 with delay 0.134
[00:06:46,071.071] ThreadPoolExecutor-0_4 root INFO: Processed value 20 with delay 0.885
[00:06:46,142.142] ThreadPoolExecutor-0_0 root INFO: Processed value 28 with delay 0.19
[00:06:46,354.354] ThreadPoolExecutor-0_1 root INFO: Processed value 29 with delay 0.33299999999999996
[00:06:46,376.376] ThreadPoolExecutor-0_2 root INFO: Processed value 21 with delay 0.962
[00:06:46,721.721] ThreadPoolExecutor-0_3 root INFO: Processed value 22 with delay 1.078
[00:06:46,721.721] MainThread root INFO: Process end
 

For sure, all values need to be adjusted after, but the idea is there.

The only thing that bothers, I'm not 100% sure how generator value num would behave in a multithread environment due to Python GIL. But here I'm asking for someone who has a Python knowledge to comment.

Tuesday, November 28, 2023

Asterisk and sharing <Custom:> device states

Asterisk has a mechanism to sharing device states across the servers using just PJSIP.

But what is interesting, this mechanism is not really applies to Custom: device states. Means if you have 2 servers and want to share BLF state via hint and have something like

extensions.conf

...

exten => MY_STATE,hint,Custom:MY_DEVICE


and changing it via asterisk CLI


> devstate change Custom:MY_DEVICE BUSY


on the local server, it will change the hint on the remote server, but not the actual devstate that you're connecting this hint with 

> devstate list 

---------------------------------------------------------------------
--- Custom Device States --------------------------------------------
---------------------------------------------------------------------
---
--- Name: 'Custom:MY_DEVICE'  State: 'NOT_INUSE'
 

> core show hints

    -= Registered Asterisk Dial Plan Hints =-
MY_STATE@hints: Custom:MY_DEVICE  State:Busy            Presence:not_set         Watchers  0


This situation gives me a lot of hard times, when you have visual inconsistency on the same server, but totally forgetting that you have second spare server.

For me, the resolution was to sync the actual Custom: device states via external service using AMI that monitors DeviceStateChange events and using Setvar just changing the state explicitly.

Just a note, that if someone will fall into the same pit.

Thursday, September 21, 2023

Asterisk queues, local channels and transfers

Here comes the same old story about Asterisk queues and using Local channels within it. But with PJSIP flavour.

So, it is known, that local channels in Asterisk can be tricky and might require disable of the optimizations when used in Queues. Yes, famous /n at the end of the channel name so the Queue application can track the status of the channel

But the drawback of this method is also known and this is transfers calls from the agents. Means when you do a transfer, initial channel is not freed.

Or outgoing calls from the agents. Like if agent do an outbound call and still resides in a queue, it would not be considered as busy.

Many methods to workaround this, and I'm not exception to build own based on GROUP and GROUP_COUNT originating from Asterisk: The Definitive Guide book, but with some modifications regarding new ways of setting variables on PJSIP channels and further optimisations of Local Channel in the Asterisk.


queues.conf

...

[QUEUE_A]

member => Local/AGENT_A@agents

...

extensions.conf

...

[incoming]

exten => QUEUE_A,1,Queue(QUEUE_A)

[agents]

; First to  check if there are active calls on this agent

exten => AGENT_A,1,ExecIf($[ ${GROUP_COUNT(${EXTEN}@agents)} >= 1 ]?Congestion())

    ; We  need to set GROUP on outgoing channel as current channel will be destroyed upon answer

    same => n,Dial(PJSIP/AGENT_A@trunk,,b(set_group^${EXTEN}^1))

[agents_outgoing] 

; Make sure we're not getting calls to agent when it's on outgoing call

exten => OUT_NUM,1,Gosub(set_group,${CALLERID(num)},1)

    same => n,Dial(PJSIP/${EXTEN}@out_trunk)


[set_group]

exten => AGENT_A,1,Set(GROUP(agents)=${EXTEN})

    same => n,Return()

Just a small warning, it's more a pseudo-code, but just to give an overall idea

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.

Monday, December 5, 2022

VOLTS - framework for testing VoIP deployments

 Last year I was working on VOLTS - framework I'm using for testing VoIP deployments in automated way. 

Right now it combines tools like voip_patrol and sipp in one tool with some extra features like database write/delete and media file check.

Not to repeat here all README, two links - github and the following video will tell it all.



 


Do you test your deployments?

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

Thursday, July 7, 2022

Small script to record calls on Asterisk on the fly

 Sometimes there is a need to record ongoing call on Asterisk, where usually there is no recording (due to GDPR or smth like this). Usual way with mixmonitor start in CLI requires a channel ID, which is usually not at hand.

 Small script that will get calls with caller and/or callee numbers (based on assumption, that these numbers are available in Asterisk channel name) and put recordings of them in /tmp directory. Separated for A/B legs and mixed.

Made for myself to simplify some tasks and not to look for it again.

Usage:

./call_record.sh <CALLER_NUM> [<CALLEE_NUM>]

call_record.sh

#!/bin/bash

NUM_1=${1:-"DUMMY"}
NUM_2=${2:-"DUMMY"}

CHANNELS=$(asterisk -rx 'core show channels' | grep -E "${NUM_1}|${NUM_2}" | awk '{print $1}')

if [ -z ${CHANNELS} ]; then
    echo "Cannot find channel"
    exit 0
fi

DATE=$(date '+%F-%H-%M-%S')
ID=1

for CHANNEL in $CHANNELS; do

    DIRNAME=/tmp/${DATE}-${NUM_1}-${NUM_2}

    mkdir -p ${DIRNAME}

    REC_COMMAND=`echo ${DIRNAME}/${ID}-mix.wav,r'('${DIRNAME}/${ID}-in.wav')'t'('${DIRNAME}/${ID}-out.wav')'`

    echo "Recording ${ID} at ${DIRNAME}"

    asterisk -rx "mixmonitor start ${CHANNEL} ${REC_COMMAND}"
    
    ID=$[ ${ID} + 1 ]

done