FusionPBX, или снова-здорово, FreeSWITCH +9


В ту же реку


Относительно недавно я написал себе шпаргалку по настройке FreeSWITCH. Описанный там процесс настройки привел к работоспособной в тестовых условиях конфигурации. Тест был необходим для составления предварительного представления о том, с чем придется иметь дело после переезда организации и запуске телефонии в продакшн. Однако, когда переезд состоялся и началось подключение в рабочем режиме, то первое же включение показало неработоспособность конфигурации: перестали ходить внутренние вызовы.

Это стало для меня полнейшим сюрпризом, поскольку с момента финальной настройки и проверки работоспособности, по мотивам которой была написана шпаргалка, по момент включения в рабочем режиме никаких изменений в конфиг не вносилось. Были лишь массово добавлены внутренние номера и маршруты для входящих и исходящих вызовов для тех сотрудников, за кем были закреплены прямые городские номера (порядка 60 с хвостиком номеров).

Был проведен дебаг, выявлен косяк, и все заработало. Однако, осталось ощущение костыля. Описывать его не стану, поскольку пребываю в уверенности, что примененное решение не верное, хоть и привело к искомому результату. Кроме того, выяснились нюансы: при исходящих вызовах изнутри наружу определялся только тот номер, что был указан в настройке SIP-транка в поле default_provider_username:

<X-PRE-PROCESS cmd="set" data="default_provider_username=3435555555"/>

а не тот, что указан в конфигурации абонентского номера:

<variable name="outbound_caller_id_name" value="3435555566"/>

Техподдержка провайдера сообщила, что все вызовы, прилетающие к ним от нас, в поле From имеют именно номер 3435555555, то есть косяк на моей стороне. Плюс ко всему, я вдруг совершенно завис с задачей переадресации вызовов. А вишенкой на торте стал вынос мозга аппаратами Ericsson Dialog 4422, отказавшимися выполнять трансфер вызова, и аппаратами Cisco 7945g, решившими, что их предел длительности соединения составляет 90-100 секунд при отсутствии малейшего намека на подобную настройку в конфиге. В то же время аппараты Yealink T21 E2 работали полностью без нареканий.

На этом этапе я осознал, что достиг предела своей компетенции в области телефонии и взял тайм-аут на то, чтобы все в голове утряслось и уложилось. Этому решению так же очень могуче способствовало общее утомление после совершенно диких двух рабочих недель без выходных и с ненормированным рабочим графиком, которые последовали сразу после заезда на новое место размещения организации.

FusionPBX


Не смотря на отсутствие у меня симпатий к графическим интерфейсам там, где правит бал консоль и текстовые конфиги, я все же стал смотреть в сторону решения с веб-мордой, именуемого FusionPBX. Первой причиной такой измены собственным принципам стало желание видеть весь объем настроек по каждому функциональному элементу, собранных в одном месте в виде работоспособной «из коробки» конфигурации. Именно такую возможность дает графический интерфейс. Дополнительным бонусом продуманного графического интерфейса является наглядное представление взаимосвязей между модулями и функциями. Для новичка (лично для меня) меньший уровень абстракции с конкретным способом реализации способствует более быстрому обучению и приходу к понимаю того, как эта штука работает. Второй причиной стал www.pbxforums.com, на который я попадал по ссылке через одну при поиске информации по FreeSWITCH, и попадал по иронии судьбы именно на скриншоты страниц настроек FusionPBX.

FusionPBX это FreeSWITCH с веб-мордой и с настройками, хранящимися в базе данных. Скрипт автоматической установки выполняет установку и FreeSWITCH'а, и Nginx'а, и PostgreSQL, и, собственно, веб-интерфейса самого FusionPBX. Останавливаться на этом моменте не стану, все без запинок ставится по инструкции из документации. Ставил все на рекомендуемую разработчиками 64-битную Debian 8.

Импорт абонентских номеров

Здесь не будет рассматриваться процесс настройки абонентских номеров и входящих маршрутов. Этот процесс описан в официальной документации.

Вместо него будет описана процедура импорта всего скопом. Описаний, мануалов и советов по выполнению данной процедуры мною найдено не было.

По окончании установки включаем автоматический вход в Adminer (аналог phpMyAdmin):
Advanced>Default settings:
auto_login
Value: true
Enabled: true
После изменения значений на текущей странице нажимаем Save, на странице настроек по умолчанию Reload.

Переходим в Adminer: Advanced>Adminer.

Интерес для нас представляют следующие таблицы:

v_extensions — абонентские номера.
v_destinations — маршруты для входящих вызовов на городские номера, закрепленные за внутренними абонентскими номерами.
v_dialplans — справочник диалпланов.
v_dialplan_details — настройки диалпланов входящих вызовов.
v_voicemails — настройки голосовой почты.

Формулировка задачи была следующей: выгрузить из AD ФИО сотрудников и их номера внутренних телефонов, сохранить выгрузку в CSV-файл и импортировать его в БД в таблицу абонентских номеров и настроек голосовой почты (голосовая почта должна быть отключена).
Используя справочник соответствия городских номеров внутренним, создать CSV-файлы для импорта в таблицы с маршрутами и диалпланами входящих вызовов.

Я не стану подробно рассматривать эту задачу, просто спрячу готовые скрипты под спойлер.
Внимание!

Предлагаемые скрипты вы используете на свой страх и риск, автор не несет ответственности за их неправильное использование или неожиданные побочные эффекты их правильного использования.

  • Присвойте переменной $nums значения, соответствующие вашим номерам.
  • Перед использованием скриптов необходимо везде заменить UUID домена на значение, присвоенное домену при установке (поле domain_uuid).
  • Так же необходимо заменить IP-адрес домена (172.18.253.1) на ваш.
  • Не забудьте откорректировать значение ключа -SearchBase, указав свою область выборки вместо «OU=Ekaterinburg,DC=dc,DC=domain,DC=local»
  • UUID приложения Voicemail (поле app_uuid) так же заменить на UUID, присвоенный при установке.
  • Значения UUID'ов можно посмотреть, например, в таблице v_dialplans.
  • Всем абонентским номерам будет присвоен пароль для регистрации «12345», пароль на голосовую почту и прочие сервисы — совпадающий с абонентским номером.
  • Скрипт дописывает файлы построчно! Поэтому не забывайте удалять файлы перед каждым запуском скрипта или очищать их содержимое!
.
Абонентские номера и голосовая почта
$Utf8NoBomEncoding = New-Object System.Text.UTF8Encoding $False
$nums=@{"1111"="5555555";"1112"="5555566"}
[System.IO.File]::AppendAllText("d:\v_extensions.csv", "extension_uuid;domain_uuid;extension;number_alias;password;accountcode;effective_caller_id_name;effective_caller_id_number;outbound_caller_id_name;outbound_caller_id_number;emergency_caller_id_name;emergency_caller_id_number;directory_full_name;directory_visible;directory_exten_visible;limit_max;limit_destination;missed_call_app;missed_call_data;user_context;toll_allow;call_timeout;call_group;call_screen_enabled;user_record;hold_music;auth_acl;cidr;sip_force_contact;nibble_account;sip_force_expires;mwi_account;sip_bypass_media;unique_id;dial_string;dial_user;dial_domain;do_not_disturb;forward_all_destination;forward_all_enabled;forward_busy_destination;forward_busy_enabled;forward_no_answer_destination;forward_no_answer_enabled;follow_me_uuid;enabled;description;forward_caller_id_uuid;absolute_codec_string;forward_user_not_registered_destination;forward_user_not_registered_enabled;force_ping`r`n", $Utf8NoBomEncoding)
[System.IO.File]::AppendAllText("d:\v_voicemails.csv", "domain_uuid;voicemail_uuid;voicemail_id;voicemail_password;greeting_id;voicemail_alternate_greet_id;voicemail_mail_to;voicemail_sms_to;voicemail_attach_file;voicemail_file;voicemail_local_after_email;voicemail_enabled;voicemail_description;voicemail_name_base64`r`n", $Utf8NoBomEncoding)
Get-ADUser -Filter * -SearchBase "OU=Ekaterinburg,DC=dc,DC=domain,DC=local" -Properties Telephonenumber,sn,initials,cn|%{
if(-not $_.Telephonenumber -eq ""){
    if($nums.Get_Item($_.Telephonenumber) -eq $null)
    {$outn = "5555555"}
    else
    {$outn = $nums.Get_Item($_.Telephonenumber)}
    $extension_uuid = (New-Guid).Tostring()
    $domain_uuid = "ffffffff-ffff-ffff-ffff-ffffffffffff" ## Заменить!!!
    $extension = $_.Telephonenumber
    $number_alias = ""
    $password = "12345"
    $accountcode = "172.18.253.1"
    $effective_caller_id_name = $_.sn + " " + $_.initials
    $effective_caller_id_number = $extension
    $outbound_caller_id_name = $outn
    $outbound_caller_id_number = $outn
    $emergency_caller_id_name = $effective_caller_id_name
    $emergency_caller_id_number = $extension
    $directory_full_name = $_.cn
    $directory_visible = "true"
    $directory_exten_visible = "true"
    $limit_max = "1"
    $limit_destination = "error/user_busy"
    $missed_call_app = ""
    $missed_call_data = ""
    $user_context = "172.18.253.1"
    $toll_allow = "domestic,international,local"
    $call_timeout = "30"
    $call_group = ""
    $call_screen_enabled = "false"
    $user_record = ""
    $hold_music = "local_stream://default"
    $auth_acl = ""
    $cidr = ""
    $sip_force_contact = ""
    $nibble_account = ""
    $sip_force_expires = "3600"
    $mwi_account = ""
    $sip_bypass_media = ""
    $unique_id = ""
    $dial_string = ""
    $dial_user = ""
    $dial_domain = ""
    $do_not_disturb = ""
    $forward_all_destination = ""
    $forward_all_enabled = ""
    $forward_busy_destination = ""
    $forward_busy_enabled = ""
    $forward_no_answer_destination = ""
    $forward_no_answer_enabled = ""
    $follow_me_uuid = ""
    $enabled = "true"
    $description = $_.sn + " " + $_.initials
    $forward_caller_id_uuid = ""
    $absolute_codec_string = ""
    $forward_user_not_registered_destination = ""
    $forward_user_not_registered_enabled = ""
    $force_ping = ""
    $csv="$extension_uuid;$domain_uuid;$extension;$number_alias;$password;$accountcode;$effective_caller_id_name;$effective_caller_id_number;$outbound_caller_id_name;$outbound_caller_id_number;$emergency_caller_id_name;$emergency_caller_id_number;$directory_full_name;$directory_visible;$directory_exten_visible;$limit_max;$limit_destination;$missed_call_app;$missed_call_data;$user_context;`"$toll_allow`";$call_timeout;$call_group;$call_screen_enabled;$user_record;$hold_music;$auth_acl;$cidr;$sip_force_contact;$nibble_account;$sip_force_expires;$mwi_account;$sip_bypass_media;$unique_id;$dial_string;$dial_user;$dial_domain;$do_not_disturb;$forward_all_destination;$forward_all_enabled;$forward_busy_destination;$forward_busy_enabled;$forward_no_answer_destination;$forward_no_answer_enabled;$follow_me_uuid;$enabled;$description;$forward_caller_id_uuid;$absolute_codec_string;$forward_user_not_registered_destination;$forward_user_not_registered_enabled;`"$force_ping`"`r`n"
    [System.IO.File]::AppendAllText("d:\v_extensions.csv", $csv, $Utf8NoBomEncoding)
    
    $voicemail_uuid = (New-Guid).Tostring()
    $voicemail_id = $extension
    $voicemail_password = $extension
    $greeting_id 
    $voicemail_alternate_greet_id 
    $voicemail_mail_to = ""
    $voicemail_sms_to
    $voicemail_attach_file 
    $voicemail_file = ""
    $voicemail_local_after_email = "true"
    $voicemail_enabled = "false"
    $voicemail_description = $description
    $voicemail_name_base64
    [System.IO.File]::AppendAllText("d:\v_voicemails.csv", "$domain_uuid;$voicemail_uuid;$voicemail_id;$voicemail_password;$greeting_id;$voicemail_alternate_greet_id;$voicemail_mail_to;$voicemail_sms_to;$voicemail_attach_file;$voicemail_file;$voicemail_local_after_email;$voicemail_enabled;$voicemail_description;$voicemail_name_base64`r`n", $Utf8NoBomEncoding)}}

Маршруты и диалпланы
$Utf8NoBomEncoding = New-Object System.Text.UTF8Encoding $False
[System.IO.File]::AppendAllText("d:\v_destinations.csv", "domain_uuid;destination_uuid;dialplan_uuid;fax_uuid;destination_type;destination_number;destination_number_regex;destination_caller_id_name;destination_caller_id_number;destination_cid_name_prefix;destination_context;destination_app;destination_data;destination_enabled;destination_description;destination_accountcode`r`n", $Utf8NoBomEncoding)
[System.IO.File]::AppendAllText("d:\v_dialplans.csv", "domain_uuid;dialplan_uuid;app_uuid;dialplan_context;dialplan_name;dialplan_number;dialplan_continue;dialplan_order;dialplan_enabled;dialplan_description`r`n", $Utf8NoBomEncoding)
[System.IO.File]::AppendAllText("d:\v_dialplan_details.csv", "domain_uuid;dialplan_uuid;dialplan_detail_uuid;dialplan_detail_tag;dialplan_detail_type;dialplan_detail_data;dialplan_detail_break;dialplan_detail_inline;dialplan_detail_group;dialplan_detail_order`r`n", $Utf8NoBomEncoding)
$nums="1111=5555555;1112=5555566"
$nums.Split(";")|%{
    $innum = $_.Split("=")[0]
    $outnum = $_.Split("=")[1]
    $domain_uuid = "ffffffff-ffff-ffff-ffff-ffffffffffff" ## Заменить!!!
    $destination_uuid = (New-Guid).Tostring()
    $dialplan_uuid = (New-Guid).Tostring()
    $fax_uuid
    $destination_type = "inbound"
    $destination_number = "343$outnum"
    $destination_number_regex = "^(343$outnum)$"
    $destination_caller_id_name
    $destination_caller_id_number
    $destination_cid_name_prefix
    $destination_context = "public"
    $destination_app
    $destination_data 
    $destination_enabled = "true"
    $destination_description = "$outnum-$innum" 
    $destination_accountcode
    [System.IO.File]::AppendAllText("d:\v_destinations.csv", "$domain_uuid;$destination_uuid;$dialplan_uuid;$fax_uuid;$destination_type;$destination_number;$destination_number_regex;$destination_caller_id_name;$destination_caller_id_number;$destination_cid_name_prefix;$destination_context;$destination_app;$destination_data;$destination_enabled;$destination_description;$destination_accountcode`r`n", $Utf8NoBomEncoding)
    
    $app_uuid = "ffffffff-ffff-ffff-ffff-ffffffffffff" ## Заменить!!!
    $dialplan_context = "public"
    $dialplan_name = $destination_number
    $dialplan_number = $destination_number
    $dialplan_continue = "false"
    $dialplan_order = "100"
    $dialplan_enabled = "true"
    $dialplan_description = $destination_description
    [System.IO.File]::AppendAllText("d:\v_dialplans.csv", "$domain_uuid;$dialplan_uuid;$app_uuid;$dialplan_context;$dialplan_name;$dialplan_number;$dialplan_continue;$dialplan_order;$dialplan_enabled;$dialplan_description`r`n", $Utf8NoBomEncoding)
    
    $dialplan_detail_break
    $dialplan_detail_inline
    $dialplan_detail_group

    $dialplan_detail_uuid = (New-Guid).Tostring()
    $dialplan_detail_tag = "condition"
    $dialplan_detail_type = "destination_number"
    $dialplan_detail_data = "^(343$outnum)$"
    $dialplan_detail_order = 20    
    [System.IO.File]::AppendAllText("d:\v_dialplan_details.csv", "$domain_uuid;$dialplan_uuid;$dialplan_detail_uuid;$dialplan_detail_tag;$dialplan_detail_type;$dialplan_detail_data;$dialplan_detail_break;$dialplan_detail_inline;$dialplan_detail_group;$dialplan_detail_order`r`n", $Utf8NoBomEncoding)

    $dialplan_detail_uuid = (New-Guid).Tostring()
    $dialplan_detail_tag = "action"
    $dialplan_detail_type = "transfer"
    $dialplan_detail_data = "$innum XML 172.18.253.1"
    $dialplan_detail_order = 30
    [System.IO.File]::AppendAllText("d:\v_dialplan_details.csv", "$domain_uuid;$dialplan_uuid;$dialplan_detail_uuid;$dialplan_detail_tag;$dialplan_detail_type;$dialplan_detail_data;$dialplan_detail_break;$dialplan_detail_inline;$dialplan_detail_group;$dialplan_detail_order`r`n", $Utf8NoBomEncoding)
}

Проверка связи на рандомно выбранные номера показала работоспособность импорта.

Настройка шлюза
Accounts>Gateways
Gateway: 172.16.253.3
Username: 3435555555
Password: not-used
From User: 3435555555
From Domain: 172.16.253.3
Proxy: 172.16.253.3
Register: False
Caller ID In From: True
Обратите внимание!
В документации по FusionPBX недвусмысленно указывается, что при выполнении настроек поля, выделенные жирным текстом, обязательны для заполнения.
Однако я, по непонятной мне причине, жирность поля Proxy не углядел и значение ему не выставил. В итоге получил работающие входящие внешние вызовы, но не работающие исходящие наружу. Команда sofia status gateway ffffffff-ffff-ffff-ffff-ffffffffffff не показывала аномалий настройки и даже показывала назначенное значение поля Proxy, соответствующее значению Gateway. Точно такой же вывод команды при точно таких же настройках демонстрировал «голый» FreeSWITCH в предыдущей инсталляции, и при этом совершенно беспроблемно позволял совершать исходящие вызовы наружу.
FusionPBX же заработал только после явного указания значения Proxy.
*ffffffff-ffff-ffff-ffff-ffffffffffff — UUID шлюза
Настройка ACL

Выполнил настройки в соответствии со шпаргалкой и тут же получил сломавшиеся внутренние вызовы. Логи показывали, что аппараты почему-то оказались в контексте external, соответственно, обрабатывались «не своим» диалпаном, от чего вызов завершался ошибкой ROUTE_NOT_FOUND.

Лирическое отступление
Юмор ситуации заключался еще и в том, что эта «беда» стряслась у меня до того, как я обнаружил обязательность заполнения поля Proxy в настройках шлюза. И стоило мне настроить ACL, как начинали приходить вызовы снаружи, но ломались внутренние. И как я ни играл с применением ACL и с их значениями, итог был один: или звонки снаружи внутрь, или внутренние звонки без звонков изнутри наружу и снаружи внутрь.

Как выяснилось, настройка ACL была выполнена неправильно!
Важно!

ACL-списки только для сетей и доменов провайдеров.
Ваших собственных сетей и доменов в них быть не должно.
Список domains должен быть по умолчанию deny.
Сами правила должны быть разрешающими и в них должен быть прописан IP-адрес шлюза провайдера с маской /32, поле domain заполнять не нужно.

Итак, выполняем настройку ACL: Advanced>Access Controls>domains. Удаляем существующие правила, создаем новое:
Type: allow
CIDR: 172.16.253.3/32
Domain:
Description: default SIP-trunk

По окончании жмем Save, далее чтобы новые ACL вошли в силу: Status>Sip Status и жмем Reload ACL.

Системные переменные

Advanced>Default Settings
Здесь мы укажем выданный нам провайдером внешний IP-адрес, который мы использовали при настройке 1:1 NAT в шпаргалке, укажем телефонный код региона, язык и голос для голосовых ответов, тип гудка.

Раздел Defaults:
default_areacode: 343
default_language: ru
default_dialect: RU
default_voice: elena
ringback: $${ru-ring}
transfer_ringback: $${ru-ring}
Раздел IP Address
external_rtp_ip: 172.16.160.154
external_sip_ip: 172.16.160.154
Раздел SIP Profile: Internal
internal_auth_calls: true
Собственно говоря, именно эта переменная в значении true отвечает за считывание настроек абонентского номера и передачу из него значений ${outbound_caller_id_number} и ${outbound_caller_id_name}. Чтобы эта переменная имела силу, необходимо, чтобы была отключена авторизация внутренних абонентских номеров по ACL. По умолчанию, из коробки, это сделано и так: ACL-авторизация отсутствует, вместо нее используется Digest (по абонентскому номеру и паролю): internal_auth_calls: true.
Важно!

Чтобы корректно определялись прямые городские номера, присвоенные внутренним в настройках через поля Outbound Caller ID Name и Outbound Caller ID Number, необходимо выполнение трех условий:
  1. Отсутствие ACL-авторизации внутренних абонентов
  2. Включенная Digest-авторизация в настройках SIP-профиля:
    internal_auth_calls: true
  3. Наличие в настройках шлюза:
    Caller ID In From: True
Исходящие маршруты

Dialplan>Outbound Routes
Пожалуй, это единственный пункт настроек, не подвергшийся переосмыслению.
Подробно разбирать его не стану. Отмечу лишь, что были использованы следующие регулярные выражения для различных направлений:

  • Внутригород: ^(\d{7})$ (набор прямого городского 7-значного номера без всяких префиксов в виде нулей, девяток и прочего).
  • Внутригород с кодом города: ^(8343\d{7})$ (набор городского 7-значного номера с префиксом 8343).
  • Сотовые: ^(89\d{9})$ (звонок на сотовый с префиксом 8, что является стандартом де-факто)
  • Межгород: ^(8\d{10})$ (междугородний звонок, так же привычные: 8, код населенного пункта, номер абонента)
  • Международный: ^(810\d+)$ (стандартный же префикс 810, далее код страны, код территории, номер абонента).

Для всех маршрутов было отредактированы два тега action типа set: effective_caller_id_name=${default_areacode}${outbound_caller_id_name} effective_caller_id_number=${default_areacode}${outbound_caller_id_number} таким образом, чтобы передаваемый оператору номер вызывающего абонента включал в себя код города.

Лечим сброс вызова через 90-100 секунд на аппаратах Cisco

Как было отмечено выше, сюрпризом стал обрыв установленного соединения через 90-100 секунд на всех аппаратах Cisco 7945g. Подкручивание всех таймеров с более или менее релевантным названием переменной в конфиге аппаратов результата не дало. Курение логов в консоли FreeSWITCH выявило Session Expire.

Гуглинг, кроме матов в сторону нежелания аппаратов Cisco нормально работать хоть с кем-то, кроме Call Manager'а, выявил, что такое поведение вполне может быть вылечено отключением переменной aggressive-nat-detection.

Advanced>SIP Profile
aggressive-nat-detection
Value: true
Enabled: False
Русификация голосового отклика

Нам потребуются файлы озвучки, созданные альтруистичными профессионалами.

Качаем:

files.freeswitch.org/releases/sounds/freeswitch-sounds-ru-RU-elena-48000-1.0.51.tar.gz
files.freeswitch.org/releases/sounds/freeswitch-sounds-ru-RU-elena-32000-1.0.51.tar.gz
files.freeswitch.org/releases/sounds/freeswitch-sounds-ru-RU-elena-16000-1.0.51.tar.gz
files.freeswitch.org/releases/sounds/freeswitch-sounds-ru-RU-elena-8000-1.0.51.tar.gz

Каждый из архивов содержит готовую структуру каталогов. Каждый из архивов распаковываем в /usr/share/freeswitch/sound/

Поскольку ранее мы уже выполнили настройку значений по умолчанию, с этого момента файлы русской озвучки подхватятся и начнут воспроизводиться без дополнительных движений. Единственное, что вам, возможно, придется сделать (мне пришлось), так это во всех четырех папках ru/RU/elena/voicemail/_bitrate_/ переименовать файл vm-not_available_no_voicemail.wav и дать ему новое имя vm-no_answer_no_vm.wav. Только после этой манипуляции я получил голосовой отклик на событие недоступности вызываемого абонента.

P.S.: Как и предыдущая часть, данный текст был написан исключительно с целью документирования возникающих сложностей и путей их решения. Несмотря на то, что текст так же освещает быстрый старт с нуля все того же FreeSWITCH'а, пусть и с «графическим лицом», считаю, что текст самодостаточный и является неким форком, и имеет право на самостоятельную жизнь. Предыдущая часть так же сохраняет некоторую ценность благодаря описанной настройке сетевого оборудования. Некорректные настройки в том тексте будут исправлены и приведены к тем, что используются в данной статье.




К сожалению, не доступен сервер mySQL