Интеграция asterisk с Active Directory

На некотором этапе развития нашей организации было решено перейти на VoIP телефонию. В качестве платформы безоговорочно был выбран Asterisk-PBX. Оконечное оборудование брали бюджетное из доступного — DLink DPH-150.
В результате проделанной работы получилась автоматизированная VoIP система, с управлением через стандартную оснастку MS ActiveDirectory.

Asterisk 1.8.4 собрали из исходников на Ubuntu 9.04.
Пройдясь по просторам интернета с поисковой фразой «asterisk active directory», было решено использоваться частичную интеграцию на базе скриптов perl, формирующих файлы конфигурации для asterisk. Полная интеграция с AD на базе ядра Asterisk выглядела удручающе в виду мизерного количества информации по этому поводу в интернете. Причиной использовать выбранный вариант интеграции стал один простенький скрипт на perl (к сожалению истоков которого уже и не найти), который запускался кроном и формировал конфигурационный файл 'users.conf', после чего служба asterisk перезапускалась. После тщательного исследования возможностей asterisk и расширения функционала найденного скрипта получилось следующее.

Формирование и подключение users.conf


Нижеприведенный скрипт находит в AD всех пользователей, у которых указан атрибут 'phone' и выводит в stdout список пользователей в формате конфига users.conf.
В файл конфигурации users.conf, скрипт подключается следующим образом:
#exec /etc/asterisk/scripts/users.pl

Сам скрипт users.pl:
#!/usr/bin/perl
# users.pl v1.1
#
# Script to generate asterisk 'users.conf' file from Active Directory (LADP) on users which contains 'phone' attribute
# 
# Using:
# 1. Print users to STDOUT:
# users.pl 
#
# 2. Print users to file:
# users.pl users_custom.conf

use strict;
use warnings;
use Net::LDAP;
use Lingua::Translit;

######################
### BEGIN SETTINGS ###
######################
my $debug = 0;
my $warning = 0;

# name of Domain
my $AD="domain";

# Domain name in format AD
# for example  mydomain.ru
my $ADDC="DC=domain";

# user in Active directory
# example: "CN=asterisk,CN=Users,$ADDC"
my $ADUserBind="CN=asterisk,CN=Users,$ADDC";
my $ADpass="p@s$w0rd";

# base search tree
# example "OU=Users,$ADDC"
my $ADUsersSearchBase="OU=Organisation,$ADDC";

# Field in active directory where telephone number, display name, phone stored
# "telephonenumber", "displayname", "mail"
my $ADfieldTelephone="telephonenumber";
my $ADfieldFullName="displayname";
my $ADfieldMail="mail";
my $ADfieldUser="samaccountname";

# You need to create a dialplan in your asterisk server;
my $dialplan="office";

# default settings
my $user_static = 
"context = $dialplan
call-limit = 100
type = friend
registersip = no
host = dynamic
callgroup = 1
threewaycalling = no
hasdirectory = no
callwaiting = no
hasmanager = no
hasagent = no
hassip = yes
hasiax = yes
nat=yes
qualify=yes
dtmfmode = rfc2833
insecure = no
pickupgroup = 1
autoprov = no
label =
macaddress =
linenumber = 1
LINEKEYS = 1
callcounter = yes
disallow = all
allow = ulaw,alaw,iLBC,h263,h263p
";
#######################
### END OF SETTINGS ###
#######################

my $ldap;

# get array DNS names of AD controllers
my $dig = "dig -t srv _ldap._tcp.$AD" . '| grep -v "^;\|^$" | grep SRV | awk "{print \$8}"';
my @adControllers = `$dig`;
# try connect to AD controllers
foreach my $controller (@adControllers){
	$controller =~ s/\n//;
	#INITIALIZING
	$ldap = Net::LDAP->new ( $controller ) or next;
	print STDERR "Connected to AD controller: $controller\n" if $debug > 0;
	last;
}
die "$@" unless $ldap; 

my $mesg = $ldap->bind ( dn=>$ADUserBind, password =>$ADpass);

#PROCESSING - Displaying SEARCH Results
# Accessing the data as if in a structure
#  i.e. Using the "as_struct"  method
my $ldapUsers = LDAPsearch ( 
	$ADUsersSearchBase, 
	"$ADfieldTelephone=*",  
	[ $ADfieldFullName, $ADfieldTelephone, $ADfieldMail, $ADfieldUser ]
)->as_struct;

# translit RUS module.
# GOST 7.79 RUS, reversible, GOST 7.79:2000 (table B), Cyrillic to Latin, Russian
my $tr = new Lingua::Translit("GOST 7.79 RUS");

my %hashPhones = ();
my $phones = \%hashPhones;

my @out;

while ( my ($distinguishedName, $attrs) = each(%$ldapUsers) ) {
	# if not exist phone or name - skipping
	my $attrPhone = $attrs->{ "$ADfieldTelephone" } || next;	
	my $attrUser = $attrs->{ "$ADfieldUser" } || next;
	my $attrName = $attrs->{ "$ADfieldFullName" } || next;	
	my $encName = $tr->translit("@$attrName");	
	my $attrMail = $attrs->{ "$ADfieldMail" } || [""];


	# check for duplicates phone number
	if ( $phones -> {"@$attrPhone"} ){
		my $currUser = "@$attrName";
		my $existUser = $phones -> {"@$attrPhone"};
		print STDERR "@$attrPhone alredy exist! Exist:'$existUser' Current:'$currUser'... skipping - '[@$attrPhone] $currUser'\n" if $warning;
		next;
	} else {			
		$phones -> {"@$attrPhone"} = "@$attrName";
	}
	
	# password for SID = (telephonenumber without first digit) + 1
	# example: phone=6232 pass=233
	#$phsecret =sprintf("%03d",( substr("@$attrVal",1,100)+1));
	my $phsecret = "@$attrPhone";
	push (@out,  
		"[@$attrPhone]\n"
		. "fullname = $encName\n"
		. "email = @$attrMail\n"
		. "username = @$attrUser\n"
		#. "mailbox = @$attrPhone\n"
		. "cid_number = @$attrPhone\n"
		. "vmsecret = $phsecret\n"
		. "secret = $phsecret\n"	
		. "transfer = yes\n"	
		. "$user_static\n"
	);
}	# End of that DN

# print to file
if (@ARGV){
	open FILE, "> $ARGV[0]" or die "Error create file '$ARGV[0]': $!";
	print STDOUT "Printing to file '$ARGV[0]'";
	print FILE @out;	
	close FILE;
	print STDOUT " ...done!\n";
}
# print to STDOUT
else{
	print @out;
}

exit 0;

#OPERATION - Generating a SEARCH 
#$base, $searchString, $attrsArray
sub LDAPsearch
{
	my ($base, $searchString, $attrs) = @_;
	my $ret = $ldap->search ( base    => $base,
             	              scope   => "sub",
            		          filter  => $searchString,
                    	      attrs   => $attrs
                        	);
	LDAPerror("LDAPsearch", $ret) && die if( $ret->code );
	return $ret;
}

sub LDAPerror
{
	my ($from, $mesg) = @_;
	my $err = "[$from] - error" 
		."\nCode: " . $mesg->code
		."\nError: " . $mesg->error . " (" . $mesg->error_name . ")"
		."\nDescripton: " . $mesg->error_desc . ". " . $mesg->error_text;
	print STDERR $err if $warning;
}

Формирование телефонных групп на базе групп AD

Основной план нумерации расписан в ручную в файле конфигурации extensions.conf. Но в нашей организации довольно часто сотрудники переходят из одного отдела в другой, из за чего пришлось бы постоянно переформировывать конфиг extensions.conf, что в совокупности с человеческим фактором приводило бы к неминуемым ошибкам. Суть альтернативного решения такова, что в AD, в определенном OU (в скрипте $ADGroupsSearchBase) создаются группы, в 'description' которых пишется телефонный номер группы а в 'members' включаются те абоненты, на которые будет поступать звонок при наборе номера группы.

Скрипт в конфиге подключается так же:
#exec /etc/asterisk/scripts/exten.pl

Скрипт:
#!/usr/bin/perl
# exten.pl v1.1
#
# Script to generate extensions 'extensions_custom.conf' file, 
# from Active Directory (LADP) on groups in OU=ADGroupsSearchBase 
# which groups contains 'description' attribute
# 
# Using:
# 1. Print users to STDOUT:
# exten.pl 
#
# 2. Print users to file:
# exten.pl exten_custom.conf

use strict;
use warnings;
use Net::LDAP;
use Lingua::Translit;

######################
### BEGIN SETTINGS ###
######################
my $debug = 0;
my $warning = 1;

#name of Domain
my $AD="domain";

#Domain name in format AD
#for example  mydomain.ru
my $ADDC="DC=domain";

# user in Active directory
# example: "CN=asterisk,CN=Users,$ADDC"
my $ADUserBind="CN=asterisk,CN=Users,$ADDC";
my $ADpass="p@s$w0rd";

# base search Groups tree example "OU=Users,$ADDC"
my $ADGroupsSearchBase = "OU=asterisk,OU=Groups,OU=Organisation,$ADDC";
# base search Users tree example "OU=Users,$ADDC"
my $ADUsersSearchBase = "OU=Organisation,$ADDC";

# default email to send voicemail if email user not set
my $defaultEmail = 'asterisk@Organisation.com';

# Field in active directory where telephone number, display name, phone stored ...
# "telephonenumber", "displayname", "mail", ...
my $ADfieldTelephone = "telephonenumber";
my $ADfieldMember = "member";
my $ADfieldMemberOf = "memberof";
my $ADfieldInfo = "info";
my $ADfieldDescription = "description";
my $ADfieldMail = "mail";
#######################
### END OF SETTINGS ###
#######################

my $ldap;

# get array DNS names of AD controllers
my @adControllers = `dig -t srv _ldap._tcp.$AD | grep -v '^;\\|^\$' | grep SRV | awk '{print \$8}'`;
# try connect to AD controllers
foreach my $controller (@adControllers){
	$controller =~ s/\n//;
	#INITIALIZING
	$ldap = Net::LDAP->new ( $controller ) or next;
	print STDERR "Connected to AD controller: $controller\n" if $debug > 0;
	last;
}
die "$@" unless $ldap; 

my $mesg = $ldap->bind ( dn=>$ADUserBind, password =>$ADpass);

#PROCESSING - Displaying SEARCH Results
# Accessing the data as if in a structure
#  i.e. Using the "as_struct"  method
my $ldapGroups = LDAPsearch ( 
	$ADGroupsSearchBase, 
	"$ADfieldDescription=*",  
	[ $ADfieldMember, $ADfieldDescription ]
)->as_struct;

# translit RUS module.
# GOST 7.79 RUS, reversible, GOST 7.79:2000 (table B), Cyrillic to Latin, Russian
my $tr = new Lingua::Translit("GOST 7.79 RUS");

my $hash = ();

# process each group in $ADGroupsSearchBase with phone
while ( my ($distinguishedName, $groupAttrs) = each(%$ldapGroups) ) {
	print STDERR "Processing GROUP: [$distinguishedName]\n" if $debug > 1;
	my $attrMembers = $groupAttrs->{ $ADfieldMember } or next;
	my $desc = $groupAttrs->{ $ADfieldDescription } or next;
	my $groupNumber = "@$desc";
	
	print STDERR "MEMBERS: @$attrMembers\nDESC: $groupNumber  (Count=$#$attrMembers+1)" if $debug > 1;
	
	# process members in current group
	foreach my $member (@$attrMembers) {				
		my $ldapMember = LDAPsearch(
			$ADUsersSearchBase, 
			"$ADfieldTelephone=*", 
			[ $ADfieldTelephone ]
		) -> as_struct;
		
		my $memberAttrs = $ldapMember->{$member};
		my $memberPhone = $memberAttrs->{$ADfieldTelephone}[0] or next;		
		
		print STDERR "\nMEMBER: $member" if $debug > 1;
		print STDERR "\tPHONE:$memberPhone" if $debug > 1;		
		
		if ($hash -> {$groupNumber}){
			my $a = $hash -> {$groupNumber};
			push @$a, $memberPhone;
		} else {			
			$hash -> {$groupNumber} = [$memberPhone];
		}
	}
	print STDERR "\n\n" if $debug > 1;	
}	# End of that groups in $ADGroupsSearchBase

my @out;

while ( my ($groupPhone, $userPhones) = each (%$hash) ) {	
	print STDERR "GROUP: $groupPhone\t PHONES: @$userPhones\n" if $debug > 1;
	#foreach my $userPhone (@$userPhones)	{
	push (@out, "exten => $groupPhone,1,Dial(sip/" . join('&sip/', @$userPhones) . ")\n");	
}

# print to file
if (@ARGV){
	open FILE, "> $ARGV[0]" or die "Error create file '$ARGV[0]': $!";
	print STDOUT "Printing to file '$ARGV[0]'";
	print FILE @out;	
	close FILE;
	print STDOUT " ...done!\n";
}
# print to STDOUT
else{
	print @out;
}

exit 0;

#OPERATION - Generating a SEARCH
# $base, $searchString, $attrsArray
sub LDAPsearch
{
	my ($base, $searchString, $attrs) = @_;
	my $ret = $ldap->search ( base    => $base,
             	              scope   => "sub",
            		          filter  => $searchString,
                    	      attrs   => $attrs
                        	);
	LDAPerror("LDAPsearch", $ret) && die if( $ret->code );
	return $ret;
}

sub LDAPerror
{
	my ($from, $mesg) = @_;
	my $err = "[$from] - error" 
		."\nCode: " . $mesg->code
		."\nError: " . $mesg->error . " (" . $mesg->error_name . ")"
		."\nDescripton: " . $mesg->error_desc . ". " . $mesg->error_text;
	print STDERR $err if $warning;
	#print STDERR "\nServer error: " . $mesg->server_error if $debug;
}


Вывод скрипта примерно такой:
exten => 605,1,Dial(sip/157&sip/130&sip/444&sip/103&sip/119&sip/151&sip/117)
exten => 602,1,Dial(sip/122&sip/110&sip/106)
exten => 607,1,Dial(sip/444&sip/122&sip/110&sip/100&sip/101)
exten => 601,1,Dial(sip/155&sip/101)
exten => 606,1,Dial(sip/444&sip/110&sip/100&sip/101)


Автоматизация

Для автоматической подгрузки новых данных из AD в cron добавлено задание перезагрузки конфигурации asterisk:
asterisk -rx reload

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

Продолжение

Если статья вызовет интерес у сообщества, то готов продолжить повествование в которые хотел бы включить следующие темы:
  1. Автоматическое разворачивание конфигурации телефонов DLINK DPH-150, и других аппаратов, поддерживающих autoprovision
  2. Использование ПО DialFox для автоматического набора номеров с авторизацией NTLM через AD. В частности, прикручивание mod_ntlm к apache2


Благодарю за уделенное внимание.

P.S. По ходу написания скриптов все комментарии старался составлять на английском языке для универсальности. Но к сожалению грамматика на ино-языке оставляет желать лучшего. Надеюсь основной смысл комментариев будет понятен.

UPD: Обновил скрипты. Добавил:
1. Определение домен контроллеров из DNS сервера.
2. Возможность запуска скрипта с параметром — именем файла, в который будет записываться stdout.
Share post

Similar posts

AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 32

    +2
    Спасибо, интересно. Ждем продолжения.
      +1
      Т.е. вы закрепляете за сотрудником телефонный аппарат и он с ним путешествует по отделам?
        0
        По сути да. За мембером в AD закрепляется mac адрес аппарата. Я об этом расскажу в своей следующей статье.
          0
          Это явно не best practices :-)
            0
            Все зависит от задач. У нас в организации телефонные аппараты не у всех сотрудников. А значит, что на место сотрудника «с тел.аппаратом» может переехать сотрудник «без тел.аппарата».
            Аппаратура выдается в IT отделе и закрепляется за сотрудником, как компьютер с мониторами так и телефонный аппарат.

            С другой стороны, если у Вас задачи иные, то никто не мешает изменить mac адрес аппарата, привязав таким образом другой аппарат к сотруднику.
              0
              Как правило, стараются абстрагироваться от железа (и много чего)
              И как-то у меня не укладывается в стройную систему ваш организационный подход: необходимое аппаратное и программное обеспечение зависит от роли сотрудника, а не от цвета его глаз. А у вас наоборот.
                0
                Увы, таковой подход диктует не IT отдел а бизнес процессы.
                К тому же изменение физически места работы сотрудника не меняет направленности его деятельности. Менеджер программистом не станет при переезде.
            0
            А не проще автопровизионинг делать на аппарат привязанный к компу куда зашёл пользователь? Странные у Вас бизнес-процессы. А у кого нет аппарата, поставьте софтфон…
          +1
          Очень интересует автоматическое разворачивание конфигурации телефонов DLINK DPH-150, жду продолжения!
            0
            есть над чем подумать, спасибо за наводку
              0
              Извините за вопрос не в тему… но интересно, перехват трафика разговора позволяет восстановить разговор? В смысле прослушки.
                +1
                Разумеется. Сохранять звуковые файлы на основании собранного RTP-трафика умеет тот же wireshark.
                0
                Интересная статья, спасибо. Как показали себя в работе данные телефонные аппараты? Собираюсь в ближайшем времени перевести офис на цифровую телефонию и не могу определиться с выбором трубок.
                Мы все знаем, какое бывает порой качество у небезызвестного всем d-link.
                  0
                  Дык есть б.у. телефоны cisco по цене ниже Длинка, есть linksys новые и тд… выбирайте! Не один же Длинк на рынке!
                    0
                    я к сожалению нахожусь далековато от Москвы, тут б.у. не найти, линксис дороже длинка в 2-3 раза что очень существенно.
                      0
                      Дык покупайте в Москве или Питере и заказывайте доставку!
                      На аукционах можно купить б.у. железки за копейки, продают часто по несколько штук и продавцы готовы отправлять куда скажете. Мой босс так покупал кучу телефонов циско для одной дружественной компании в средней полосе России, и ничо. Вся эта куча за пару дней была доставлена и установлена. Такие телефоны циско, возможно в их городе только у них одних ;) Но все сотрудники уже там к ним привыкли и регулярно пользуются удобными функциями этих телефонов. Телефоны по цене вышли дешевле новых длинков. Мне кажется, что в 21 веке проблем по доставке железа не существует. Есть проблемы с желанием!

                      Займитесь, и все получится! Удачи!
                        0
                        К сожалению помимо аукционов у нас ещё существует налоговая, бухгалтерия и совковые начальники… Но за совет спасибо, есть только вариант покупки на shop.nag.ru но там циско по цене тот же длинк\еалинк, несмотря на то что б\у.
                          0
                          Я покупал в офис 7912-ых цисок ящик у какой-то питерской конторы, просто загуглил «б.у. 7912» и выбрал тех кто вызвал больше доверия. Если пойдете по тому же пути то не забудьте что к телефонам нужны блоки питания, которые обычно в комплекте не идут. 7912, 7940, 7960 не умеют PoE, им нужен Cisco inline power.
                    0
                    Использую DLINK DPH-150S впринципе нареканий нету, но потом нашел более лучший выбор в виде продукции Yealink от IPMATIKA конкретно модели Yealink SIP-T20 или Yealink SIP-T20P(если нужен POE), а для секретарей и руководителей Yealink SIP-T28P.
                      0
                      Как раз выбираю между Yealink и DLINK, думаю всё-таки лучше Yealink брать.
                        0
                        Сейчас вот сравнивая оба аппрата Yealink гораздо более функциональней и подающщейся масосовой настройке, также качества пластика и кнопок у них получше и удобная вертикальная подставка! Так что советую Yealink.
                      0
                      Аппараты DPH-150 показали себя вполне адекватно. Как со стороны аппаратной части так и с программной. Основной недостаток при работе с этой моделью — практически полное отсутствие адекватной документации. Так же из минусов можно отметить внешний блок питания в формфакторе «зарядка от телефона», которую сложно подключить к бесперебойнику пользователя. Если подключать сеть к компьютеру через аппарат, то при пропадании питания телефона сеть так же обрывается.

                      Брак встретился всего один из примерно 30 штук. Не работали пара кнопок на самом аппарате.

                      Опять таки при выборе аппаратов исходите из своего бюджета и требований. Но если бюджет не сильно тяготит, то я бы остановился на аппаратах поддерживающих питание по сети PoE (802.3af) +сетевой свитч с поддержкой той же технологии и хорошая батарейка в серверной. Тогда при сбоях электропитания телефония будет более автономной.
                      0
                      А почему не использовали realtime для хранения пользователей? Он не требует передёргивания всего конфига, плюс свежедобавленные подтягиваются автоматом.
                        0
                        Какой именно realtime вы имеете ввиду?
                        Если связка с AD на уровне ядра asterisk, то причину я описывал вкратце в начале статьи.

                        К тому же минус приведенного выше метода лишь в отсутствии синхронизации доменных паролей, что в принципе для нагих задач не столь важно.
                          0
                          www.voip-info.org/wiki/view/Asterisk+RealTime
                          www.voip-info.org/wiki/view/Asterisk+RealTime+Sip

                          Это не LDAP auth, это хранение данных о пользователях и т.п. в odbc-enabled базе, из плюсов вы получите отсутствие перегрузки всего конфига при изменении пользователей + меньше шансов на нештатную ситуацию при ошибочных/ неправильной обработке входящих данных из AD т.к. они будут контролироваться на уровне схемы БД.
                            0
                            Да, возможно в этом есть смысл. И не исключено что мы не перейдем к такой схеме организации бэкенда.
                            Но от динамического изменения плана нумерации групп, в таком случае, все равно отказаться не удастся.
                              0
                              www.voip-info.org/wiki/view/Asterisk+func+db — общение с базой из диалплана
                              ну или agi/fastagi скрипт позволит сделать то же с диалпланом
                                0
                                Почитал про agi. Довольно мощный интерфейс.
                                Но по моему субъективному мнению упростить реализацию идеи, с его помощью, довольно трудно и не тривиально.
                                perl как то ближе к сердцу.

                                Но как вариант реализации идеи, однозначно имеет место быть.
                                  0
                                  www.voip-info.org/wiki/view/Asterisk+AGI
                                  посмотрите, там есть примеры на perl

                                  на .Net fast agi пишется минут за 30 под вашу задачу
                                    0
                                    Да, я посмотрел варианты использования AGI интерфейса asterisk. Но я не понимаю какие преимущества этого метода по сравнению с тем, который описан выше?

                                    Касательно .net на linux я пока отношусь довольно скептически. К тому же сколько же надо поставить пакетов и библиотек mono для поддержки .net платформы? На сколько я помню размеры там от 30мегабайт. Дополнительные пакеты это большой минус решению.
                        0
                        Автор, спасибо! Хорошая статья!
                        Для удобства можно использовать include, как, например в trixbox:
                        users.conf:
                        ...
                        #include users_custom.conf
                        ...

                        В этом случае не придется вставлять вывод скрипта в середину конфига, и таким образом минимизируются шансы накосячить.
                          0
                          Да, именно так изначально и подключался скрипт.
                          Сначала формировался файл конфигурации и лишь потом подключался инклудом. Не помню по какой причине решили перейти на '#exec'

                          Похоже что метод инклуда имеет еще один плюс, помимо Вами описанного. Так в случае, если AD не доступен, то скрипт не сбросит кэш текущих пользователей. Надо лишь предусмотреть такую ситуацию в скрипте. Чуть позже сделаю обновление статьи, где такой механизм реализован.

                        Only users with full accounts can post comments. Log in, please.