Предыстория
Материал изначально готовился как доклад для asterconf 2020. Теперь постараюсь описать все более подробно в этой статье.
MIKOPBX - это бесплатная АТС с открытым исходным кодом на базе Asterisk 16. Год назад мы взялись за переход на PJSIP.
Основные причины:
PJSIP поддерживает "множественную регистрацию". На одном аккаунте можно без проблем регистрировать несколько конечных UAC
Корректная работа входящей маршрутизации при настройке регистрации нескольких учетных записей провайдера на одном адресе (IP+PORT)
PJSIP более гибок в настройке
chan_sip не развивается и объявлен deprecated в Asterisk 17
Далее опишу с какими сложностями мы столкнулись и какие выгоды получили.
Основная причина - необходимость в поддержке "множественной регистрации". Крайне удобно подключить к аккаунту несколько софтфонов / телефонов и не беспокоится, входящий вызов поступит где бы ты не находился.
Лично у меня подключены следующие устройства:
Аппаратный телефон на рабочем столе в офисе
Софтфон на ноутбуке
Софтфон на смартфоне
При поступлении входящего звонка на добавочный, все устройства звонят одновременно.
С чего начать?
В нашем случае был готовый файл конфигурации sip.conf. Стало интересно, возможно ли как то конвертировать старый конфиг в новый формат (структура pjsip.conf отличается значительно).
Готовый скрипт был найден в исходниках asterisk. Найти можно по пути:
contrib/scripts/sip_to_pjsip/sip_to_pjsip.py
Из встроенной справки:
Usage: sip_to_pjsip.py [options] [input-file [output-file]]
Converts the chan_sip configuration input-file to the chan_pjsip output-file.
The input-file defaults to 'sip.conf'.
The output-file defaults to 'pjsip.conf'.
Скрипт позволяет получить рабочий конфиг и начать его тестировать и дорабатывать напильником.
Настройка множественной регистрации
После конвертации конфигурационного фала потребовалось увеличить количество контактов, которые могут подключаться к учетной записи (далее endpoint).
Каждую входящую регистрацию Asterisk рассматривает как contact.
Параметр "max_contacts" позволяет ограничить количество устройств, которые могут подключиться к endpoint.
;pjsip.conf
[226]
type = aor
max_contacts = 5
Количество подключенных контактов можно посмотреть в CLI консоли Asterisk:
mikopbx*CLI> pjsip show contacts
Contact: <Aor/ContactUri..............................> <Hash....> <Status> <RTT(ms)..>
==========================================================================================
Contact: 201/sip:201@172.16.156.1:60616;ob 418d36496b Avail 3.793
Contact: 201/sip:201@172.16.156.1:60616;ob ba56853d54 Avail 2.189
Contact: 203/sip:203@172.16.156.1:60616;ob 2cd641799f Avail 0.988
Objects found: 3
Для того, чтобы при входящем звонили сразу все контакты, потребовалось доработать dialplan.
Пример c комментариями:
;extensions.conf
[internal-users]
; контекст для набора 3х значных внутренних номеров
; PJSIP_DIAL_CONTACTS - функция возвращает Dial-совместимую строку с контактами
; Контакты разделены символом &
; В качестве параметра функции необходимо передать ID endpoint
exten => _XXX,1,Set(dialContacts=${PJSIP_DIAL_CONTACTS(${EXTEN})})
; Перед Dial обязательно необходимо проверить
; заполнена ли переменная "dialContacts"
; если нет, то на endpoint никто не зарегистрировался
same => n,ExecIf($["${dialContacts}x" != "x"]?Dial(${DC},,Tt))
После правки dialplan началось интересное поведение системы.
Наши ожидания не оправдались. Мы предполагали, что при таком звонке, asterisk будет оперировать двумя каналами "Кто звонит" и "Кому звонит". На практике, все оказалось иначе.
О природе каналов и их происхождении
Каждый канал SIP и PJSIP непосредственно связан с SIP диалогом "PBX - UAC".
Проще говоря один INVITE = один канал вида SIP/104-0000XX.
Если к endpoint подключено несколько контактов, то при звонке на внутренний номер INVITE будет отправлен каждому контакту, будет создано несколько каналов.
Зная это, можно сделать следующие выводы:
Чем больше каналов, тем больше событий в AMI
Каждый канал пройдет определенный для него dialplan
Каждый канал повлияет на CDR записи
Если кратко подвести итог, то, после включения множественной регистрации, мы видим влияние на все основные модули наших продуктов:
История звонков на АТС
Функция записи разговоров
Работа CTI приложений, завязанных на AMI
Автоподъем. Paging. Intercom
Это крайне интересные функции. Все они завязаны на функцию "Автоответ". Может работать как с настольными телефонами, так и с многими софтфонами.
Принцип работы многих UAC схож. Чтобы "поднять трубку" достаточно в INVITE передать дополнительный заголовок. Пример:
Call-Info:\;answer-after=0
В случае с аппаратным телефоном будет включена либо громкая связь, либо произойдет ответ в гарнитуре.
При работе с chan_sip при originate достаточно было установить переменную SIPADDHEADER:
Action: Originate
Channel: SIP/104
Context: from-internal
Exten: 74952293042
Priority: 1
Callerid: 104
Variable: SIPADDHEADER="Call-Info:\;answer-after=0"
Работа с этой переменной была описана в chan_sip.с и при звонке заголовок добавлялся автоматически в INVITE.
В случае с PJSIP подход отличается. Упрощенный пример extensions.conf:
[internal-users]
exten => 204,1,Dial(${PJSIP_DIAL_CONTACTS(204)},,Ttb(dial_create_chan,s,1)))
[dial_create_chan]
exten => s,1,Set(PJSIP_HEADER(add,Call-Info)=\;answer-after=0)
same => n,return
Опция "b" в команде "Dial" позволяет созданный канал назначения с помощью Gosub направить в дополнительный контекст "dial_create_chan".
Только в этом месте есть возможность управлять SIP заголовками ДО отправки INVITE.
Интересный вывод: "dial_create_chan" - место в dialplan, где канал еще существует, но НЕ связан с SIP диалогом.
Теперь более правильный пример установки заголовка:
[internal-users]
; Получаем контактны:
exten => _XXX,1,Set(dС=${PJSIP_DIAL_CONTACTS(${EXTEN})})
; Считаем количество контактов:
same => n,ExecIf($["${FIELDQTY(dС,&)}"!="1"]?Set(__SIPADDHEADER=${EMPTY}))
same => n,ExecIf($["${dС}x" != "x"]?Dial(${DC},,Ttb(dial_create_chan,s,1)))
[dial_create_chan]
exten => s,1,ExecIf($["${SIPADDHEADER}x" == "x"]?return)
same => n,Set(header=${CUT(SIPADDHEADER,:,1)})
same => n,Set(value=${CUT(SIPADDHEADER,:,2)})
same => n,Set(PJSIP_HEADER(add,${header})=${value})
same => n,Set(__SIPADDHEADER=${EMPTY})
same => n,return
С помощью функции "FIELDQTY" мы анализируем количество контактов, подключенных к endpoint. Если контактов несколько, то функцию лучше отключить, ведь сложно предугадать, на каком из телефонов сработает ответ на вызов.
С помощью функции "CUT" происходит разбор строки "SIPADDHEADER", выделяем имя заголовка и его значение.
Обязательно, после PJSIP_HEADER очищаем значение переменной SIPADDHEADER. Это страховка от случайного срабатывания "ответа" на вызов при переадресациях.
Получение значения UserAgent
Для выборка корректного SIP заголовка необходимо понимать какое конечное устройство подключено к endpoint. В случае с pjsip ситуация несколько изменилась. Пример:
[get-user-agent]
exten => 300,1,NoOp(--- Incoming call ---)
same => n,Set(vContact=${PJSIP_AOR(300,contact)})
same => n,Set(vUserAgent=${PJSIP_CONTACT(${vContact},user_agent)})
same => n,NoOp(--- ${vContact} & ${vUserAgent} ---)
... ... ...
same => n,Hangup()
Пример в одну строчку для AOR с ID 300. Для упрощения ID endpoint = ID AOR и = EXTEN:
; ${PJSIP_CONTACT(${PJSIP_AOR(${EXTEN},contact)},user_agent)}
В функцию "PJSIP_AOR" передаем ID AOR, и в качестве опции указываем, что вернуть нам следует поле "contact".
В функцию "PJSIP_CONTACT" передаем полученный контакт, и в качестве опции указываем, что вернуть следует поле "user_agent".
Обратите внимание, PJSIP_AOR(300,contact) вернет ID контакта, но это не тоже самое, что можно увидеть в CLI.
Пример результата PJSIP_AOR:
201;@e758f5661420b391e239386a94edbefe
Пример вывода в CLI:
pjsip show contacts 201/sip:201@172.16.156.1:57130;ob
Contact: 201/sip:201@172.16.156.1:57130;ob
Исходящая регистрация
Согласно документации Asterisk, разработчики выделяют два основных вида проблем регистрации:
Временные (temporary) проблемы
No Response
408 Request Timeout
500 Internal Server Error
502 Bad Gateway
503 Service Unavailable
504 Server Timeout
Некоторые 6xx ответы
Постоянные (Permanent) проблемы
401 Unauthorized
403 Forbidden
407 Proxy Authentication Required
Прочие 4xx, 5xx, 6xx ошибки
В pjsip.conf при настройке исходящей регистрации обязательно необходимо описать опции для повторной попытки регистрации:
[74952293042]
type = registration
; Временные неудачи
; Интервал для повторных попыток регистрации
retry_interval = 30
; Максимальное количество попыток
max_retries = 100
; "Постоянные" неудачи
; Интервал используется при получении 403 Forbidden ответа.
forbidden_retry_interval = 300
; Интервал используется при получении Fatal ответов (non-temporary 4xx, 5xx, 6xx)
fatal_retry_interval = 300
Если sip_to_pjsip.py для конвертации конфигурации, то эти опции придется описать вручную.
Идентификация провайдера
Для рада провайдеров телефонии может наблюдаться следующая картина:
Успешно проходит регистрация по адресу sip.test.ru
Допустим sip.test.ru резолвится в 10.10.10.10
Входящие вызовы поступают с 11.11.11.11
Входящие могут поступать и с 10.10.10.10
Вызовы могут не пройти авторизацию и будут завершены.
В PJSIP есть возможность идентификации по IP адресу:
[74952293042]
type = identify
; ... ... ...
match=sip.test.ru,185.45.152.0/24,185.45.155.0/24;
; ... ... ...
В параметре "match", через запятую, можно описать все IP адреса провайдера. В этом случае входящий будет корректно сопоставлен с нужным endpoint.
Кроме того, следует обратить внимание на опцию "endpoint_identifier_order".
Значение по умолчанию:
endpoint_identifier_order=ip,username,anonymous
Если у вас есть несколько учетных записей одного провайдера, которые регистрируются на одном и том же адресе IP:PORT, то имеет смысл поменять порядок идентификации:
endpoint_identifier_order=username,ip,anonymous
Пример, есть три транка:
99999 - подключается к 10.10.10.10:5060
88888 - подключается к 10.10.10.10:5060
77777 - подключается к 10.10.10.10:5060
Если не настроить "endpoint_identifier_order", то:
все входящие будут направлены в контекст произвольного endpoint (идентификация пройдет по адресу IP:PORT), к примеру в контекст endpoint "99999" .
канал, созданный при входящем будет всегда ассоциироваться с одним и тем же endpoint, к примеру PJSIP/99999-0000XXX, на какой внешний номер бы ни звонил клиент
Входящие без регистрации SIP URI
Для ряда случаев удобно направлять входящие на АТС без регистрации.
Обязательно следует подгрузить модуль "res_pjsip_endpoint_identifier_anonymous.so".
Пример настройки pjsip.conf
[anonymous]
type = endpoint
allow = alaw
timers = no
context = public-direct-dial
Пример extensions.conf
[public-direct-dial]
exten => 74952293042,NoOp(--- Incoming call to ${EXTEN} ---)
same => n,Dial(PJSIP/204,,TKg));
same => n,Hangup()
Контекст public-direct-dial должен быть изолирован от исходящих dialplan.
В качестве exten описываются все DID номера и логика маршрутизации.
Подведу итоги
Переход на PJSIP состоялся. С chan_pjsip АТС работает стабильно, надежно
Нами был получен огромный опыт работы с PJSIP
PJSIP более гибок в настройке, предоставляет больше возможностей
Функция множественной регистрации крайне удобна и порой незаменима
chan_pjsip живой, активно развивается и поддерживается сообществом
Из минусов перехода на chan_pjsip стоит отметить:
Требуется модернизация dialplan
Изменение поведения AMI, что отражается на CTI клиентах
Меняется поведение CDR, требуется доработка легирования истории звонков
chan_pjsip активно развивается, в свежих релизах asterisk встречаются грубые ошибки. не стоит гнаться за новыми версиями, лучше выждать появления "certified" версий
agic
как приятно слышать эти слова