1 August 2011

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

Development of communication systems
Sandbox
На некотором этапе развития нашей организации было решено перейти на 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.
Tags:asteriskactive directory
Hubs: Development of communication systems
+28
28k 123
Comments 32
Popular right now
SEO-специалист
December 7, 202064,900 ₽Нетология
iOS-разработчик с нуля
December 7, 202070,740 ₽Нетология
UX-дизайнер
December 7, 202047,940 ₽Нетология
Python для работы с данными
December 7, 202031,500 ₽Нетология