Pull to refresh

Синхронизируем контакты между лесами

PowerShell
В ходе деятельности любой организации, у нее возникают и исчезают временные союзники, она дружит и разрывает дружбу с партнерами, она реорганизуется, поглощает другие организации и наоборот, разделяется на несколько. С точки зрения ИТ часто возникает необходимость в создании общих ресурсов, доверительных отношений между лесами Active Directory, в налаживании vpn-туннелей сеть-сеть и прочих «объединительных» процедурах. Ниже я рассмотрю одну такую процедуру, копирование контактных сведений о пользователях чужого доверенного домена.

Бывает, пользователи ругаются, что у них «не доходят письма». Почему так? Да потому, что отправили на один адрес, а актуален другой, ведь девочка вышла замуж, сменила фамилию, а добрые админы не сохранили предыдущий адрес электронной почты. Иногда сотрудники ошибаются, сказав в телефонную трубку: «Эс!», а не «эс как доллар!», а их собеседник на той стороне ошибается, набрав латинскую букву «C». Пользователи могут написать .com вместо .ru и даже .ru вместо .com, и когда пользователей достаточно много, такие ошибки превращаются в нескончаемый поток жалоб. Так как всякого рода глупостям нужно противопоставлять умение, возникает необходимость иметь у себя максимально актуальную адресную книгу всех партнеров своей организации.

О структуре

В нашей организации в качестве почтовой системы используется Exchange server, в качестве клиентского ПО для передачи электронной почты Outlook, поэтому для того, чтобы наши сотрудники видели в своей адресной книге контактные сведения партнеров, достаточно создать соответствующие объекты контактов в Active Directory. Наши же партнеры используют то, что им нравится, одни сидят на Lotus Domino, другие крутят sendmail, третьи работают с Kerio Mail Server, но для них всех неизменно одно: у каждого пользователя Active Directory в атрибут mail занесен актуальный адрес электронной почты. Уж как они это делают, не спрашивайте, как-то делают и все. А коли так, то это можно использовать, превратив «их пользователей» в «наши контакты»:



Благодаря доверительным отношениям, мы можем читать их пользователей, как своих (конечно, если при доверии не включена выборочная проверка подлинности, в этом случае чуть сложнее), а уж право писать в свой домен мы имеем.
Вообще для процедуры экспорта-импорта можно использовать, например, Forefront Identity Manager, но, во-первых, мы люди экономные и не выбрасываем деньги на ветер, а во-вторых, мы не склонны лупить по комарам из хотвайзера, поэтому обойдемся другим ресурсом — PowerShell’ом, причем для совместимости без навески ADPowerShell, .NET классов System.DirectoryServices.DirectoryEntry и System.DirectoryServices.DirectorySearcher нам будет более чем достаточно.

Методика

После поиска всех необходимых пользователей в Active Directory, мы копируем их свойства в память, заполняя массив. PowerShell позволяет добавлять к существующим объектам свойства буквально «на лету», с помощью командлета Add-Member, чем мы и воспользуемся. А в результате получим хорошо структурированный список, в котором, к тому же, впоследствии легко будет осуществлять поиск, используя другой командлет, Where-Object. Также мы копируем в память все контакты, которые лежат у нас в контейнере внешних контактов, и сравниваем два списка. Результатом сравнения станут три массива: новые контакты, контакты, которые требуется удалить, и контакты, у которых лишь поменялись какие-то свойства. Понятное дело, что после первого запуска все найденные контакты будут новыми.
После того, как мы импортировали контакты впервые, нам необходимо заботиться об их актуальности, причем не способом «удалить все, залить по новой», а менять лишь те поля, которые изменились. Почему так? Да потому что, во-первых, сам Exchange штука тонкая и ему на формирование адресной книги требуется время, а во-вторых, при выходе из строя RID мастера при попытках пакетного создания каких-либо объектов в Active Directory могут произойти отвратительные вещи. Мы знаем, что каждый объект Active Directory использует атрибут objectGUID для того, чтобы хранить в нем уникальный идентификатор объекта, он задается при создании системой и более никогда не меняется, его мы и будем запоминать, преобразовывать в шестнадцатеричную строку и хранить в поле info контакта. Впрочем, если info у кого-то используется для других целей, он может хранить идентификатор в любом другом атрибуте, их, поверьте, хватает.
Также следует не забыть про атрибут userCertificate, для объектов класса «контакт» он хотя и не виден в оснастке Active Directory Users and Computers, но Outlook его использует и отправляет/получает шифрованные/подписанные электронные письма. Работать это дело, конечно, будет, только если одна организация доверяет сертификатам другой организации, но, впрочем, этот вопрос лежит за рамками данного топика. Еще один атрибут, на который следует обратить внимание, называется otherTelephone. Он интересен тем, что является многострочным (multivalued), и поэтому у нас с ним особые отношения. На самом деле таких атрибутов много, но в скрипте используется лишь один, поэтому создание процедуры специально для извлечения/записи многострочных атрибутов есть эдакий простор модификации скрипта в будущем.
Ну и, конечно, мы помним о безопасности. Лучше делегировать создание, изменение, удаление объектов класса «контакт» лишь определенной учетной записи и лишь в определенном контейнере, и в этом случае потенциальный злоумышленник, получив контроль над машиной со скриптом, не сможет сделать ничего особо серьезного. А теперь скрипт с комментариями:


# Извлечение контактов из доверенного домена
# Егор Иванов

# Функция пишет лог в файл

function Write-LogFile([string]$logFileName)
{
    Process
    {
        $_
        $dt = Get-Date
        $str = $dt.DateTime + " " + $_
        $str | Out-File -FilePath $logFileName -Append
    }
}

# Вспомогательная функция, которая сравнивает два объекта
# типа ArrayList, нужна для проверки идентичности многострочных
# параметров, как строковых, так и массивов байт

function Compare-ArrayLists([System.Collections.ArrayList] $ListA, [System.Collections.ArrayList] $ListB)
{
    if ($ListA.Count -ne $ListB.Count)
    {
        return $false
    }
    else
    {
        $CompListA = New-Object System.Collections.ArrayList($null)
        $CompListB = New-Object System.Collections.ArrayList($null)
        for ($i=0;$i -lt $ListA.Count;$i++)
        {
            if ($ListA[$i].GetType() -ne [String])
            {
                $rc = $CompListA.Add([System.BitConverter]::ToString($ListA[$i]))
            }
            else
            {
                $rc = $CompListA.Add($ListA[$i])
            }
            if ($ListB[$i].GetType() -ne [String])
            {
                $rc = $CompListB.Add([System.BitConverter]::ToString($ListB[$i]))
            }
            else
            {
                $rc = $CompListB.Add($ListB[$i])
            }

        }        
        for ($i=0;$i -lt $CompListA.Count;$i++)
        {
            if ($CompListB.IndexOf($CompListA[$i]) -lt 0) {return $false}
        }
        return $true        
    }
}

# Эта функция загружает контакты из домена, или чужого (пользователей), или своего (контакты). Параметры:
# Имя домена
# Имя подразделения
# Флаг, указывающий, что домен организации Exchange
# Указаталь на массив записей
# Флаг, показывающий что грузить: пользователей или контакты

function Load-FromDomain([string] $DomainName, [string] $UnitName, [bool]$flagExchangeDomain, [ref]$A_Entries, [bool]$flagContacts)
{
    if (!$flagContacts)
    {
        if (!$flagExchangeDomain)
        {
            # Фильтр для LDAP, у меня грузятся все контакты, которые имеют заполненные
            # поля почты или компании. В хвостике !userAccountControl:1.2.840.113556.1.4.803:=2,
            # если это раскомментить, то также будет проходить проверка на заблокированность
            # пользователя, но это не всегда оправдано (например, учетка почтового ящика оборудования или места в AD отключена)
            $strFilter = "(&(objectClass=user)(!objectClass=computer)(mail=*)(company=*))"#(!userAccountControl:1.2.840.113556.1.4.803:=2)
        }
        else
        {
            # В Exchange-организации существует атрибут msExchHideFromAddressLists, скрывающий ящик из адресной книги
            $strFilter = "(&(objectClass=user)(!objectClass=computer)(mail=*)(company=*)(!msExchHideFromAddressLists=TRUE))" #(!userAccountControl:1.2.840.113556.1.4.803:=2)
        }
    }
    else
    {
        $strFilter = "(&(objectClass=contact))"        
    }
    $objDomain = New-Object System.DirectoryServices.DirectoryEntry("LDAP://"+$DomainName+"/"+$UnitName)
    $objSearcher = New-Object System.DirectoryServices.DirectorySearcher
    $objSearcher.SearchRoot = $objDomain
    $objSearcher.PageSize = 1000
    $objSearcher.Filter = $strFilter
    $objSearcher.SearchScope = "Subtree"
    
    # Это важная строка, здесь перечислины атрибуты, которые следует копировать,
    # сюда можно добавить что-нибудь или же отсюда убрать. 
    
	$colProplist = "employeeID", "employeeType", "objectGUID", "CN", "givenName", "name",
			            "sn", "legacyExchangeDN", "displayName", "mail",
			            "wWWHomePage", "l", "postalCode", "initials", "physicalDeliveryOfficeName",
			            "st", "streetAddress", "ipPhone", "title", "mobile", "department",
			            "pager", "homePhone", "facsimileTelephoneNumber", "userAccountControl",
			            "distinguishedName", "company",
			            "Description", "otherTelephone", "telephoneNumber", "userCertificate"
    if ($flagExchangeDomain)
    {
        $colProplist += "mailNickname"
        $colProplist += "msExchHideFromAddressLists"
    }
    if ($flagContacts)
    {
        $colProplist += "info"
    }
    foreach ($i in $colPropList)
    {
        $rc = $objSearcher.PropertiesToLoad.Add($i)
    }
    $colResults = $objSearcher.FindAll()
    $colResults.Count
    foreach ($objResult in $colResults)
    {
        $objItem = $objResult.Properties

        # В зависимости от того, контакты мы тянем или пользователей, мы выбираем, из какого атрибута
        # извлекать уникальный идентификатор, чтобы по нему потом сравнивать записи. 
    
        $Entry = New-Object -TypeName System.Object
        if (!$flagContacts)
        {
            $Entry | Add-Member -type NoteProperty -name "GUID" -Value ([System.BitConverter]::ToString($objItem.objectguid[0])).Replace('-','')
        }
        else
        {
            $Entry | Add-Member -type NoteProperty -name "GUID" -Value ([string]$objItem.info)
        }
        
        # Составляем список всех атрибутов, но исключаем сертификаты и другие телефоны, т.к. эти атрибуты многострочные.
        # Можно было, конечно, сделать что-нибудь универсальное, чтобы работало и с многострочными атрибутами,
        # но так как я использую всего два, то решил для них просто сделать особую обработку
        
        $UserProperties = $colProplist | Where-Object {($_ -ne "objectGUID") -and ($_ -ne "userCertificate") -and ($_ -ne "otherTelephone")}        
        
        foreach ($UserProperty in $UserProperties)
        {
            if ($objItem.Item($UserProperty) -ne $null)
            {
                $Entry | Add-Member -type NoteProperty -name $UserProperty -Value ([string]$objItem.Item($UserProperty))
            }
        }
        
        # Обработка для сертификатов, и ниже обработка для других телефонных номеров
        
        if ($objItem.usercertificate -ne $null)
        {
            $Certificates = New-Object System.Collections.ArrayList($null)
            foreach ($Certificate in $objItem.usercertificate)
            {               
                $rc = $Certificates.Add($Certificate)
            }
            $Entry | Add-Member -type NoteProperty -name "userCertificate" -Value ($Certificates)
        }
        
        if ($objItem.othertelephone -ne $null)
        {
            $Telephones = New-Object System.Collections.ArrayList($null)
            foreach ($Telephone in $objItem.othertelephone)
            {               
                $rc = $Telephones.Add($Telephone)
            }
            $Entry | Add-Member -type NoteProperty -name "otherTelephone" -Value ($Telephones)
        }
        $A_Entries.Value += $Entry            

    }
    
}

# Функции закончились, скрипт начинает работу здесь.
# Первым делом создаются пока нулевые массивы для хранения наших пользователей и контактов, по порядку:
# Массив всех польхователей
# Массив всех контактов
# Контакты к добавлению
# Контакты к изменению
# Контакты к удалению

$A_Users = $A_Contacts = $A_NewContacts = $A_ChangedContacts = $A_ContactsToDelete = @()

$LogFileName = "./GetUserContacts.log"
$flagExchangeOrganization = $false

# Здесь мы разберем командную строку и извлечем данные
# Параметры командной строки должны быть такими:
# ("<имя пользователя>","<пароль>") ("<свой домен>","<подразделение для контактов>","<организация exchange>") ("<чужой домен1>","<чужое подразделение>","<организация Exchange>") ("<чужой домен2>"...
# например
# ("admin@litware.inc","password") ("litware.inc","ou=contacts,dc=litware,dc=inc",$true) ("contoso.com","dc=contoso,dc=com",$false)

if ($args.Count -lt 3)
{
    break
}
$UserName = $args[0][0]
$Password = $args[0][1]

$UserName | Write-LogFile $LogFileName
$Password | Write-LogFile $LogFileName

$Domain = $args[1][0]
$ContactsOU = $args[1][1]
$flagExchangeOrganization = $args[1][2]

$Domain | Write-LogFile $LogFileName
$ContactsOU | Write-LogFile $LogFileName
$flagExchangeOrganization | Write-LogFile $LogFileName

Load-FromDomain $Domain $ContactsOU $flagExchangeOrganization ([ref]$A_Contacts) $true

for ($i=2;$i -lt $args.Count;$i++)
{
    $SrcDomain = $args[$i][0]
    $SrcOU = $args[$i][1]
    $SrcExchangeFlag = $args[$i][2]
    
    $SrcDomain | Write-LogFile $LogFileName
    $SrcOU | Write-LogFile $LogFileName
    $SrcExchangeFlag | Write-LogFile $LogFileName
    
    Load-FromDomain $SrcDomain $SrcOU $SrcExchangeFlag ([ref]$A_Users) $false
}

# Пробегаем всех пользователей, сравниваем с существующими контактами,
# если пользователь не находится, добавляем его в список контактов,
# которые следует добавить. Если пользователь находится, но у него
# изменяется какой-либо атрибут, то заносим пользователь и этот атрибут
# или атрибуты в массив с изменениями.

foreach ($User in $A_Users)
{
    $Contact = $A_Contacts | Where-Object {$_.GUID -eq $User.GUID}
    if ($Contact -eq $null)
    {
        $A_NewContacts += $User
        $A_NewContacts[$A_NewContacts.Length-1].distinguishedName = ""
    }
    else
    {
        $flagContactAdded = $false                
        $UserProperties = ($User | Get-member -MemberType NoteProperty | Where-Object {($_.Name -ne "distinguishedName") -and ($_.Name -ne "mailNickname")})
        foreach ($UserProperty in $UserProperties)
        {
            if ($Contact.($UserProperty.Name) -ne $null)
            {
                if ($User.($UserProperty.Name).GetType() -ne [System.Collections.ArrayList])
                {                    
                    if ($User.($UserProperty.Name) -ne $Contact.($UserProperty.Name))
                    {
                        if (!$flagContactAdded)
                        {
                            $NewEntry = New-Object -TypeName System.Object
                            $NewEntry | Add-Member -type NoteProperty -name "distinguishedName" -Value ($Contact.distinguishedname)
                            $flagContactAdded = $true
                        }
                        $NewEntry | Add-Member -type NoteProperty -name ($UserProperty.Name) -Value $User.($UserProperty.Name)
                    }
                }
                else
                {
                    if (!(Compare-ArrayLists $User.($UserProperty.Name) $Contact.($UserProperty.Name)))
                    {
                        if (!$flagContactAdded)
                        {
                            $NewEntry = New-Object -TypeName System.Object
                            $NewEntry | Add-Member -type NoteProperty -name "distinguishedName" -Value ($Contact.distinguishedname)
                            $flagContactAdded = $true
                        }
                        $NewEntry | Add-Member -type NoteProperty -name ($UserProperty.Name) -Value $User.($UserProperty.Name)
                    
                    }
                }
            }       
            
        }        
        if ($flagContactAdded)
        {
            $A_ChangedContacts += $NewEntry
        }
    }
}

# Теперь пробегаем по контактам, заносим в список контакты к удалению

foreach ($Contact in $A_Contacts)
{
    $User = $A_Users | Where-Object {$_.GUID -eq $Contact.GUID}
    if ($User -eq $null)
    {
        $A_ContactsToDelete += $Contact
    }

}

# $A_NewContacts
# $A_ChangedContacts
# $A_ContactsToDelete

# Удаляем ненужное

foreach ($Contact in $A_ContactsToDelete)
{
    $ContactsOUDN = "LDAP://" + $Domain + "/" + $ContactsOU
    $objContactsOU = new-object System.DirectoryServices.DirectoryEntry($ContactsOUDN, $Username, $Password, [System.DirectoryServices.AuthenticationTypes]::Secure)    
    $objContactsOU.Delete("contact", $Contact.distinguishedName.Split(",")[0])
    "Удален" + $Contact.name | Write-LogFile $LogFileName
}

# Создаем нужное

foreach ($Contact in $A_NewContacts)
{
    $ContactsOUDN = "LDAP://" + $Domain + "/" + $ContactsOU
    $objContactsOU = new-object System.DirectoryServices.DirectoryEntry($ContactsOUDN, $Username, $Password, [System.DirectoryServices.AuthenticationTypes]::Secure)    
    $NewContact = $objContactsOU.Children.Add("CN="+$Contact.CN,"contact")
    $NewContactProperties = ($Contact | Get-member -MemberType NoteProperty | Where-Object {($_.Name -ne "distinguishedName") `
                            -and ($_.Name -ne "GUID") -and ($_.Name -ne "CN") -and ($_.Name -ne "otherTelephone")`
                            -and ($_.Name -ne "name") -and ($_.Name -ne "userAccountControl") -and ($_.Name -ne "userCertificate")})
    if ($NewContactProperties -ne $null)
    {
        foreach ($NewContactProperty in $NewContactProperties)
        {
            $NewContact.Put($NewContactProperty.Name,$Contact.($NewContactProperty.Name))        
        }
    }
    if ($Contact.mail -ne $null)
    {
        if ($flagExchangeOrganization)
        {
            $NewContact.Put("targetAddress", "SMTP:" + $Contact.mail)
            $NewContact.Put("mailNickname", $Contact.mail.Split("@")[0])
            $NewContact.Put("msExchPoliciesExcluded", "{26491CFC-9E50-4857-861B-0CB8DF22B5D7}")
        }
        $NewContact.Put("proxyAddresses", "SMTP:" + $Contact.mail)
        
    }
    if ($Contact.userCertificate -ne $null)
    {
        $NewContact.PutEx(2, "userCertificate", [Array]$Contact.userCertificate)
    }
    if ($Contact.otherTelephone -ne $null)
    {
        $NewContact.PutEx(2, "otherTelephone", [Array]$Contact.otherTelephone)        
    }

    $NewContact.Put("info",$Contact.GUID)
    $NewContact.SetInfo()
    "Создан " + $Contact.name | Write-LogFile $LogFileName
}

# И, наконец, изменяем то, что изменилось.
# Здесь помимо особой обработки многострочных телефонов и сертификатов, есть и реакция
# на изменение имени контакта. Дело в том, что просто так свойство CN (canonical name)
# переписать нельзя, для его изменения следует вызвать специальный метод Rename()

foreach ($Contact in $A_ChangedContacts)
{
    $ContactDN = "LDAP://" + $Domain + "/" + $Contact.distinguishedName
    $ChangedContact = new-object System.DirectoryServices.DirectoryEntry($ContactDN, $Username, $Password, [System.DirectoryServices.AuthenticationTypes]::Secure)
    $ChangedContactProperties = ($Contact | Get-member -MemberType NoteProperty | Where-Object {($_.Name -ne "distinguishedName") `
                            -and ($_.Name -ne "GUID") -and ($_.Name -ne "CN") -and ($_.Name -ne "otherTelephone") `
                            -and ($_.Name -ne "name") -and ($_.Name -ne "userAccountControl") -and ($_.Name -ne "userCertificate")})    
    if ($ChangedContactProperties -ne $null)
    {
        foreach ($ChangedContactProperty in $ChangedContactProperties)
        {
            $ChangedContact.Put($ChangedContactProperty.Name,$Contact.($ChangedContactProperty.Name))
            "Изменен атрибут " + $ChangedContactProperty.Name + " контакта " + $Contact.distinguishedName | Write-LogFile $LogFileName           
        }
    }
    if ($Contact.userCertificate -ne $null)
    {
        $ChangedContact.PutEx(1, "userCertificate", 0)
        $ChangedContact.PutEx(2, "userCertificate", [Array]$Contact.userCertificate)
        "Изменен атрибут userCertificate контакта " + $Contact.distinguishedName | Write-LogFile $LogFileName           
    }
    if ($Contact.otherTelephone -ne $null)
    {
        $ChangedContact.PutEx(1, "otherTelephone", 0)
        $ChangedContact.PutEx(2, "otherTelephone", [Array]$Contact.otherTelephone)
        "Изменен атрибут otherTelephone контакта " + $Contact.distinguishedName | Write-LogFile $LogFileName           
    }
    if ($Contact.CN -ne $null)
    {
        "Контакт " + $ChangedContact.distinguishedName + " переименован в " + $Contact.CN | Write-LogFile $LogFileName       
        $ChangedContact.Rename("CN="+$Contact.CN)
    }
    $ChangedContact.SetInfo()
}


Далее нам остается лишь сформировать командную строку и составить расписание в шедулере для выполнения скрипта. Ну а если нашим партнерам потребуются наши контакты, то мы, безусловно, этим скриптом можем с ними поделиться.
Tags:powershellactive directoryexchange
Hubs: PowerShell
Total votes 19: ↑14 and ↓5 +9
Views8.6K

Popular right now

Основы вёрстки сайтов
June 28, 202120,000 ₽Loftschool
Node.js: серверный JavaScript
June 28, 202127,000 ₽Loftschool
Веб-дизайнер
June 28, 202183,000 ₽GeekBrains
SMM-менеджер
June 28, 202196,900 ₽Нетология
Backend разработчик
June 28, 202137,000 ₽Loftschool

Top of the last 24 hours