Рашид Ачилов
Работаем с данными Active Directory из скриптов
Часть вторая. Применение Perl и PHP
В предыдущей статье мы рассмотрели вопрос, как можно обработать данные, полученные из Active Directory с использованием языка Bourne Shell, и остались не очень довольны громоздкостью конструкций. Попробуем то же самое сделать на языках Perl и PHP.
А вот так – проще!
Совершенно непостижимые на первый взгляд конструкции, составлявшие основу предыдущего скрипта, просто поражают неискушенного человека. Разработаны они были вовсе не с учебной целью, а с целью написания некоторой программы целиком на Bourne Shell. К сожалению (а может, к счастью), в процессе работы выяснилось, что реализовать задуманное только на Bourne Shell или невозможно, или крайне трудоемко, поэтому пришлось мне вспомнить навыки программирования на языке Perl.
Язык Perl, надо сказать, значительно больше подходит для решения поставленной задачи – конструкции сокращаются, логика становится более понятной. Поэтому для разработок такого рода Perl – инструмент, близкий к идеальному. Правда, этот «инструмент» перед началом работы следует оснастить различными «насадками», то есть дополнительными модулями. А именно, нам потребуются (в скобках приводится имя порта FreeBSD):
- Date::Format (p5-TimeDate);
- Getopt::Long (p5-Getopt-Long);
- Config::Simple (p5-Config-Simple);
- MIME::Base64 (p5-MIME-Base64);
- Net::LDAP (p5-perl-ldap);
- Text::Iconv (p5-Text-Iconv).
Также все модули, от которых они зависят, если они еще у вас не установлены.
Общее построение программы ничуть не изменилось – мы также имеем конфигурационный файл, построенный по классической схеме «ключ=значение», файл регистрационного журнала, где отмечается успех или неуспех отдельного действия, и файл списка пользователей Sarg, который должен пополниться новыми пользователями, а если он отсутствует – должен быть создан. Вот таков конфигурационный файл, он уже рассматривался подробно, я привожу его только затем, что на него будут постоянно идти ссылки.
ldap_server=192.168.50.1ldap_basedn="dc=shelton,dc=net"ldap_binddn=ldapread@SHELTON.NETldap_password="cXdlcnR5ezEyM30K 0"ldap_common_filter="(&(sAMAccountName=*)(sAMAccountType=805306368)(telephoneNumber=*))"etcdir=/tmpsargdir=sarg2sarglist=sargusers
Кроме того, не будет рассматриваться реализация процедур, явно не относящихся к теме статьи – usage(), safe_logger(). Интересующиеся могут скачать полную версию скрипта с [1] и посмотреть, хотя ничего особо интересного там нет. Кроме основного модуля, мы рассмотрим процедуру demux_passwd(), как она реализуется на Perl, процедуру разбора командной строки parseOptions() и небольшую, но очень важную процедурку _from_utf8(), которая переводит данные из UTF-8 в KOI-8R. Ну и, разумеется, основной модуль.
Итак:
# Обратное преобразование строки пароля из конфигурационного файла
# Вход: $1 conv_passwd (строка) – преобразованный пароль
# Выход: password (строка) – раскодированный пароль
sub demux_passwd {
# Преобразованый пароль
my $_deconved = shift(@_);
my @_conved = split(/ /,$_deconved);
# Дополнить преобразованную строку заполнителями
if ( $_conved[1] != 0 ) {
for (;$_conved[1] != 0; $_conved[1]--)
{ $_conved[0] = $_conved[0] . "="; }
}
# Раскодировать текст. decode_base64 присоединяет '\n'
# к декодированному тексту, так что мы обрезаем его
my $_iconved = decode_base64($_conved[0]);
chomp($_iconved);
return $_iconved;
}
Здесь, так же как и в ее реализации на Bourne Shell, особо говорить не о чем. Реализация на Bourne Shell выходит даже более компактной из-за особенностей Bourne Shell. Впрочем, если кто предложит более изящное решение (полностью или частично) – я рассмотрю его.
# Перекодировка строки из UTF-8 в KOI8-R
# Вход: $1 source (строка) – строка в кодировке UTF-8
# Выход: dest (строка) – строка в кодировке KOI8-R
sub _from_utf8 {
# Строка в кодировке UTF-8
my $_src = shift(@_);
# Преобразовать в KOI8-R
my $converter = Text::Iconv->new("utf-8", "koi8-r");
my $converted = $converter->convert($_src);
return($converted);
}
Просто и без вопросов. Необходимость в этой процедуре возникает постоянно, как только читаешь что-либо не в коде ASCII, хотя в рассматриваемом примере это можно было и не выносить в процедуру.
# Разобрать командную строку
# Вход: $1 _cmdref (ссылка на хэш) – ссылка на хэш с параметрами командной строки
# Выход: rev (строка) – версия программы в текстовом виде
sub parseOptions {
# Ссылка на хэш с параметрами командной строки
my $_cmdref = shift(@_);
# Версия программы
my $revisionNumber = sprintf("%d.%d", q$Revision: 1.33 $ =~ /(\d+)\.(\d+)/);
# Разрешить обьединение различных опций после одного знака «-»
Getopt::Long::Configure("bundling");
if ( !GetOptions( 'verbose|v' => \$_cmdref{'verbose'},
'help|h' => \$_cmdref{'help'},
'debug|x' => \$_cmdref{'debug'},
'etcdir|e' => \$_cmdref{'etcdir'},
) or $_cmdref{help} ) {
usage($revisionNumber);
exit(20);
}
return $revisionNumber;
}
Интересного в этой процедуре только то, что, следуя естественной логике поведения, когда заданные в командной строке опции перекрывают такие же, но заданные в конфигурационном файле, я достаточно долго добивался возможности указания элементов хэша _Config в качестве полей для передачи туда значений, обрабатываемых Getopt::Long. Добился. Теперь при указании в командной строке и в конфигурационном файле различных параметров, принимается тот, что указан в командной строке.
И, наконец, основной модуль:
# Размещение файла регистрационного журнала
my $logfile = ">>./gensarguserlist.log";
# Размещение конфигурационного файла
my $config = "./gensarguserlist.conf";
# Прочитать конфигурационный файл и импортировать все параметры в хэш _Config,
# если конфигурационный файл существует и читается
safe_logger(sprintf("Config file %s does not exist or does not readable", $_config), "DIE") if (! -e $config) || (! -r $config);
Config::Simple->import_from($config, \%_Config) || die Config::Simple->error();
Я не буду здесь подробно расписывать, как работает Config::Simple, интересующихся отсылаю к man. Ориентирован он главным образом на секционированные конфигурационные файлы (в стиле Windows), но может быть использован и для чтения обычного файла. В этом случае Config::Simple предполагает, что все данные просто входят в одну секцию default. Это очень важное замечание нам пригодится в дальнейшем. Скажу только, что метод import_from() читает исходный файл и создает выходной хэш, в котором ключами будут имена параметров, а значениями – соответственно их значения. При этом имя ключа будет не parameter, а default.parameter в связи с особенностью, упомянутой выше.
# Разобрать командную строку, вернуть номер версии программы и заполнить хэш параметров
my $rev = parseOptions(\%_Config);
# Открыть файл регистрационного журнала и отметить начало работы
open(LOG,$logfile) || die "Sorry, I could not open log file $logfile for writing: $!\n";
safe_logger(sprintf("GenSargUserlist ver. %s started", $rev), "");
# Преобразовать пароль для подключения к LDAP в используемую форму
my $_ldap_pwd = demux_passwd ($_Config{'default.ldap_password'});
Пока что идет рутинная подготовка к работе – открываем файл регистрационного журнала, отмечаем начало работы. Обратить внимание следует только на форму записи ключа хэша – не забывайте, что они все default.parameter!
# Подключиться к LDAP
my $ldap = Net::LDAP->new($_Config{'default.ldap_server'}) || die "Cannot connect to LDAP server $_Config{'default.ldap_server'}: $!\n";
my $msg = $ldap->bind("$_Config{'default.ldap_binddn'}",password => "$_ldap_pwd");
# Если подключиться не удалось — работу продолжать незачем
safe_logger(sprintf("[%d] %s: %s",$msg->code(),$msg->error_name(),$msg->error_text()), "DIE") if ($msg->is_error());
# Путь к файлу пользователей Sarg
my $_sarglist = sprintf("%s/%s/%s",$_Config{'default.etcdir'},$_Config{'default.sargdir'},$_Config{'default.sarglist'});
Здесь Net::LDAP сделает за нас всю черную работу по подключению к AD. Нужно только проверить успешность подключения. Для этого лучше использовать if($msg->is_error()), хотя ранние версии скрипта использовали проверку if ($msg->code()), но она оказалась не слишком надежной, почему-то не срабатывала, хотя должна была.
# Путь к файлу пользователей Sarg
my $_sarglist = sprintf("%s/%s/%s",$_Config{'default.etcdir'},$_Config{'default.sargdir'},$_Config{'default.sarglist'});
Этот маленький кусочек отделен недаром. Во-первых, обратите внимание на написание атрибутов – в массив attrs они заносятся так, как они звучат в схеме AD. Но в полученных структурах имена ключей, соответствующих этим атрибутам, будут строго в нижнем регистре, и указание «правильного» атрибута приведет к тому, что поиск не даст ничего. (Это еще будет отмечено впоследствие.)
Во-вторых, что есть общий фильтр? Общий фильтр, приведенный в конфигурационном файле, разбирался в предыдущей статье и означает «отобрать все записи, у которых заполнены поля sAMAccountName и telephoneNumber, а также значение поля sAMAccountType соответствует константе USER». Вы можете изменять общий фильтр так, как вам надо.
# Обработано записей
my $_processed = 0;
# Эти атрибуты будут запрошены из AD
my $attrs = [ 'displayName', 'sAMAccountName' ];
# Отбираем данные в соответствии с общим фильтром
my $result = $ldap->search ( base => "$_Config{'default.ldap_basedn'}",
scope => "sub",
filter => "$_Config{'default.ldap_common_filter'}",
attrs => $attrs
);
Очень маленькая строка. Но она удостоится очень длинного описания. Разумеется, о safe_logger тут говорить нечего, разве только что упомянуть, что метод $result->count() возвращает число записей, считанных из AD. А вот строчка $entries=$result->as_struct...
Здесь мне придется отвлечься и рассказать о том, как организованы данные, которые передаются Net::LDAP в массив при вызове as_struct().
Как известно, в языке Perl отсутствует структурный тип данных. Хорошо это или плохо – оставим обсуждение многочисленным интернет-форумам. Нельзя просто объявить тип данных typedef struct {...} myArray; и присвоить указателю на переменную типа myArray адрес области данных – любой композитный объект данных в Perl управляется только средствами самого Perl и более ничем (это, например, дает возможность не заботиться об управлении памятью). Кроме того, базовых типов сложных объектов в Perl достаточно мало – массив, хэш и срез, причем последний, строго говоря, самостоятельным типом не является. Все прочие структуры данных в памяти представляются комбинацией первых двух – массивы массивов, хэши хэшей... Массив данных, образующийся в памяти после вызова as_struct, представляет из себя массив хэшей. Элементами массива являются адреса (указатели в Perl все же есть, только с ними нет такой свободы обращения) хэшей, каждый из которых содержит данные по одной записи из AD. Ключом в этом хэше является DN, а данными – адреса хэшей, содержащих по одному атрибуту. Таким образом, один элемент массива – это хэш хэшей. Для перебора всех атрибутов обычно перебираются все поля данных хэша первого уровня, переход в хэш второго уровня и выбор данных, потому что в поле данных хэша второго уровня располагаются не непосредственно данные, а массив! Зачем так было сделано – мне неизвестно, но это так (см. рисунок):
# Строка из файла Sarg userlist
my $line;
# Выходной массив, с логинами, уже присутствующими в Sarg userlist
my @_presented;
# Прочитано строк из файла Sarg userlist
my $q_sarglist = 0;
# Открыть Sarg userlist, если он существует.
# Аварийно завершиться, если есть, но не читается
if (-e $_sarglist) {
open(ADD, $_sarglist) || safe_logger(sprintf("File %s " . "cannot open to read: %s", $_sarglist, $!), "DIE");
# Прочитать Sarg userlist и обработать каждую строку
while (defined($line = <ADD>)) {
chomp($line);
my @_str = split(/ /,$line);
push(@_presented, $_str[0]);
}
$q_sarglist = @_presented;
# Вывести количество записей, прочитанных из Sarg userlist и закрыть файл
safe_logger(sprintf("Read %d records from file %s", $q_sarglist, $_sarglist),"");
close(ADD);
}
Структура массива данных в памяти после вызова result->as_struct
Эта часть неинтересная, здесь просто читается текстовый файл Sarg userlist, если он существовал ранее, и все упомянутые в нем логины (первое поле записи) помещаются в массив, а потом берется количество записей, выводим файл в регистрационный журнал и закрываем его.
# Открываем Sarg userlist для пополнения, создаем если отсутствует
open(ADD,">>" . $_sarglist) || die "Sorry, I could not open file $_sarglist for writing: $!\n";
# Этот хэш будет использован для поиска среди логинов, уже пристутствующих в Sarg userlist
my %seen;
# Инициализируем хэш
@seen{@_presented} = ();
my $ex_value;
Готовимся к основной работе. Для формирования списка нам понадобится постоянно отвечать на вопрос «Присутствует ли в данном массиве строка из данного хэша?». Для упрощения поиска создадим вспомогательный хэш, в котором будут только ключи. Поскольку в левой части переменных больше, чем в правой, хэш будет проинициализирован, при этом все элементы массива станут ключами, а всем полям данных будет присвоено undef.
# Внешний хэш содержит в поле ключа DN, в поле данных – адрес хэша с атрибутами
foreach $ex_value (values %$entries) {
# Взять логин
my $_login = $$ex_value{'samaccountname'};
# Проверить его наличие в хэше из файла Sarg userlist
if (not exists $seen{$$_login[0]}) {
# Взять описание пользователя (фамилию имя и отчество одной строкой)
my $_dispay = $$ex_value{'displayname'};
printf ADD "%s \t%s\n", $$_login[0], _from_utf8($$_dispay[0]);
# Обработано записей
$_processed++;
}
}
close(ADD);
$ldap->unbind();
close(LOG);
Вот это, собственно, самая важная часть программы – все остальное было только получением данных и подготовкой к работе. Внешний цикл идет по полям данных каждого элемента из массива $entries. Пусть вас не смущает несколько странная запись %$entries или $$login[0] – по синтаксическим соглашениям языка Perl первый символ обозначает, как следует интерпретировать значение данной переменной, а второй следует воспринимать просто как некий обязательный элемент, букву, с которой всегда начинается переменная. В первом случае это означает «хэш, адрес которого содержится в переменной $entries», а во втором – «первый элемент массива, адрес которого находится в переменной $_login».
Значением переменной $_login становится логин текущей записи из AD (значение атрибута sAMAccountName). Обратите внимание на то, как записано название атрибута – полностью в нижнем регистре! Если записать его здесь «правильно» – поиск не даст результата.
Ищем, присутствует ли уже полученный логин в файле Sarg userlist. Вот для чего мы и строили вспомогательный хэш %seen – чтобы воспользоваться возможностью поиска в хэше exists/not exists. Если не существует ключа, равного значению первого элемента массива, адрес которого находится в переменной $_login (бррр! Одна маленькая строчка на Perl превращается чуть ли не в сочинение, когда пытаешься проговорить это словами...), то таким же образом получить отображаемое имя пользователя и, собственно, сформировать строку, выводимую в файл Sarg userlist (попутно при этом перекодировав ее из UTF-8 в KOI8-R), подсчитать выведенные строки, и, собственно, все. Все закрыть, ото всего отключиться...
Вот так тоже проще
В заключение рассмотрим реализацию данной задачи на языке PHP. Несмотря на то что он ориентирован в основном на веб-разработку, PHP можно также использовать для разработки скриптов, запускающихся с командной строки (cli-скриптов). Для работы скрипта потребуется расширение PHP для работы с LDAP php5-ldap.
В отличие от скрипта на Perl, который был разработан для использования, этот скрипт был разработан исключительно с демонстрационной целью. Поэтому в нем отсутствуют некоторые вспомогательные компоненты и текст приводится целиком.
// Обратное преобразование пароля
// Вход: $converted (string) — преобразованный пароль
// Выход: $passwd (string) — пароль в виде простого текста
function demux_passwd($converted) {
$_conved = explode(" ", $converted);
// Дополнить строку нужным количеством заполнителей
if ( $_conved[1] != 0 ) {
for (;$_conved[1] != 0; $_conved[1]--)
{ $_conved[0] = $_conved[0] . "="; }
}
// Преобразовать получившуюся строку
$_passwd = base64_decode($_conved[0]);
return rtrim($_passwd);
}
Третий вариант реализации функции обратного преобразования пароля. Практически все варианты одинаковы, отличия только в деталях.
// Вывести сообщение в файл журнала
// Вход: $handle (filehandle) — хэндл открытого файла журнала
// $msg (string) - строка для вывода
// $severity (string) — если DIE, то выйти с ошибкой
// Выход: ничего
function safe_logger($handle, $msg, $severity) {
$formatted = sprintf("%s [%s] gensargulist: %s\n", date("d/m/Y H:i:s"), posix_getpid(), $msg);
fwrite($handle, $formatted);
// Выход, если запрошено
if ($severity == "DIE") die($formatted);
}
Я не нашел способа вывести строку в файл, кроме как через fwrite(), хотя долго искал аналог С-функции fprintf(). Поэтому вот так.
// Перекодировать строку из UTF-8 в KOI-8
// Вход: $1 source (string) - строка в UTF-8
// Выход: dest (string) - строка в KOI8-R
function _from_utf8($source) {
$converted = iconv("UTF-8", "KOI8-R", $source);
return($converted);
}
Реализация данной функции на PHP однако значительно проще, чем на Perl. Наверное, даже в одну строчку можно было бы уложиться.
// Файл регистрационного журнала
$logfile = "./gensarguserlist.log";
// Конфигурационный файл
$config = "./gensarguserlist.conf";
// Открыть файл журнала
$handle = fopen($logfile, "a") or die(sprintf("Log file %s cannot open", $logfile));
// Прочитать конфигурационный файл и импортировать его переменные
if (!is_readable($config))
safe_logger($handle, sprintf("Config file %s does not exist or does not readable", $config), "DIE");
$_config = parse_ini_file($config);
// Преобразовать пароль для подключения к LDAP в читаемую форму
$_ldap_pwd = demux_passwd($_config['ldap_password']);
Здесь ничего примечательного. Отмечу только, что is_readable() проверяет сразу существование и читаемость, и конфигурационные файлы для parse_ini_file() должны быть в формате php.ini, то есть с комментариями в виде знака «;», прочие знаки комментариев дадут ошибку.
// Подключиться к серверу LDAP
if (!$ldapconn = ldap_connect($_config['ldap_server']))
safe_logger($handle, sprintf("Cannot connect to LDAP server $s", $_config['ldap_server']), "DIE");
$ldapbind = ldap_bind($ldapconn, $_config['ldap_binddn'], $_ldap_pwd);
Подключаемся к серверу LDAP. Для подключения используется $ldap_binddn из конфигурационного файла.
// Путь к файлу Sarg userlist
$_sarglist = sprintf("%s/%s/%s", $_config['etcdir'], $_config['sargdir'], $_config['sarglist']);
// Обработано записей
$_processed = 0;
// Атрибуты, которые будут запрошены из AD
$attrs = array("displayName", "sAMAccountName");
// Отобрать данные из AD
$result = ldap_search($ldapconn, $_config['ldap_basedn'], $_config['ldap_common_filter'], $attrs);
// Загрузить полученные данные
$info = ldap_get_entries($ldapconn, $result);
// Вывести количество записей в журнал
safe_logger($handle, sprintf("Read %d records from server %s", $info["count"], $_config['ldap_server']), "");
Сформировать массив с атрибутами, которые мы хотим получить из AD, выполнить поиск, используя $ldap_basedn и общий фильтр, и загрузить полученные данные в массив (о структуре этого массива будет сказано ниже):
// Если файл Sarg userlist существует, попробовать прочитать и разобрать его
if (file_exists($_sarglist)) {
// Выйти с ошибкой, если файл существует, но не читается
if (!is_readable($_sarglist))
safe_logger($handle, sprintf("File %s cannot open to read", $_sarglist), "DIE");
// Прочитать файл в массив
$lines = file($_sarglist);
// Разбить каждую строку и поместить первый элемент в массив
foreach ($lines as $_oneline) {
$pieces = explode(" ", $_oneline);
$presented[] = $pieces[0];
}
// Ну и вывести количество строк в журнал
safe_logger($handle, sprintf("Read %d records from file %s", count($presented), $_sarglist),"");
}
В общем-то, ничего особенного в этой части нет. Файл читается в массив целиком средствами PHP, хотя с таким же успехом это можно было бы сделать через построчное чтение.
// Открыть Sarg userlist для пополнения
$add = fopen($_sarglist, "a+")
or safe_logger($handle, sprintf("Sorry, I could not open file %s for writing", $_sarglist), "DIE");
for ($i = 0; $i < $info["count"]; $i++) {
// Если логин отсутствует в файле Sarg userlist
if (!in_array($info[$i]["samaccountname"], $presented)) {
// Вывести строку с данными по отсутствующему логину
$oneadd = sprintf("%s \t%s\n", $info[$i]["samaccountname"], _from_utf8($info[$i]["displayname"]));
fwrite($add, $oneadd);
// Подсчитать количество добавленных строк
$_processed++;
}
}
Вот, собственно, и основной цикл. Перед этим только файл Sarg userlist открывается для дополнения. Программа в цикле перебирает все элементы массива, полученного после ldap_get_entries(). Для PHP нет необходимости «хитрого» преобразования массива в хэш, потому что массивы в PHP – по сути хэши. Поэтому просто функцией in_array() проверяется наличие такого элемента в массиве уже присутствующих в файле Sarg userlist логинов, и, если такого нет, выбирается логин и отображаемое имя и формируется новая строка файла.
Немного о структуре области данных, в которую будут помещены прочитанные из AD записи. Поскольку в PHP массивы – это вовсе не массивы в привычном понимании, где данные в памяти идут друг за другом, а нечто настолько свободное, насколько себе можно представить композитный объект данных, доступ к данным делается очень просто. Формально возвращаемые данные имеют структуру двумерного массива, в котором элементами первого уровня являются отдельные записи, прочитанные из AD, а элементами второго уровня – массивы с ключами, равными именам атрибутов, и значениями, равными прочитанным из AD значениям. Поэтому конструкция $info[$i] даст нам ссылку на i-тый элемент массива данных, а $info[$i]["displayname"] даст значение атрибута displayName i-того элемента массива.
// Записать результат в файл журнала
if ($_processed)
safe_logger($handle, sprintf("Added %d records in file %s", $_processed, $_sarglist), "");
ldap_unbind($ldapconn);
fclose($add);
fclose($handle);
Ну и завершить работу – записать, что сделано, все закрыть, ото всего отключиться.
Заключение
Несомненно, использование Perl или PHP для доступа к данным AD значительно упрощает программирование. Если только хорошо представлять себе, как организована область данных, в которой размещаются считанные из AD записи, да и от изучения языка запросов никуда не деться. Но это несравнимо с возможностями, которые получаешь для обработки данных – ведь представленные скрипты выполняют всего лишь простейшие преобразования.
- http://openoffice.mirahost.ru – сайт, где выложены скрипты.
- Колисниченко Д.Н. Самоучитель PHP5 – СПб.: Наука и Техника, 2007. – 640 с., ил.
- Кристиансен Т., Торкингтон Н. Perl: Библиотека программиста. – СПб:Питер, 2001. – 736 с.:ил. ISBN 5-8046-0094-X.