Для управления серверами Exchange через DSC написан специальный модуль xExchange. В нынешнем виде (1.19.0.0) он работает отлично, но не всегда. Например ресурс xExchMaintenanceMode работает не совсем так как ожидается. Если вывести сервер в режим обслуживания, то с точки зрения xExchMaintenanceMode он не будет выведен в режим обслуживания.

Дело тут вот в чём. xExchMaintenanceMode использует следующую команду для вывода сервера-члена DAG в режим обслуживания (строки 207/211 файла MSFT_xExchMaintenanceMode.psm1):

$startDagServerMaintenanceScript -serverName $env:COMPUTERNAME -Verbose

В результате выполнения этой команды узел отказоустойчивого кластера не ставится на паузу. При этом, одно из условий успешного выполнения конфигурации – состояние узла кластера не принимает значение “Up” (строки 370-374)

 if ($maintenanceModeStatus.ClusterNode.State -eq "Up")
{
    Write-Verbose "Cluster node has a status of Up"
    return $false
}

Чтобы это условие выполнилось необходимо в команде вывода сервера в режим обслуживания добавить ключ -PauseClusterNode в строки 207/211 файла MSFT_xExchMaintenanceMode.psm1.

Однако, самые опытные могут сразу же обратиться на страницу с проблемами ресурса xExchange и найти запись под номером 209, в которой как раз и говорится о наличии этой проблемы 🙂

Ссылки:
PowerShell/xExchange
Problem with xExchMaintenanceMode #209

При использовании push-метода применения конфигураций на серверы существует одна проблема – DSC на целевой системе не проверяет наличие необходимых ресурсов DSC. Это может привести к тому, что конфигурация применится не полностью. Что нас конечно же не устраивает. Поэтому необходимо обеспечить наличие всех необходимых ресурсов DSC, которые используются в конфигурации. Тут возможны два варианты:

  • Использование pull-сервера для применения конфигурации
  • Ручная установка необходимых модулей

Первый вариант мы пока опустим. А вот второй вариант рассмотрим подробнее. Любой модуль с ресурсами DSC имеет вполне определённую структуру и ставится по одному из путей указанных в переменной $env:PSModulePath. Модули обычно поставляются в виде архива, который содержит всю необходимую структуру папок со всеми необходимыми файлами. Задача сводится к следующему – необходимо на целевой сервер скопировать архив, содержащий файлы модуля и распаковать его по пути, указанному в переменной $env:PSModulePath. Задачу можно выполнить средствами DSC, используя ресурсы File (для копирования) и Archive (для распаковки архива). Файл конфигурации получается примерно следующего вида:

Configuration InstallDSCRes
{
    param ($modulePath = ($env:PSModulePath -split ';' |
    ? {$_ -match 'Program Files\\WindowsPowerShell'}),
    $Server = @('someserver'))
    Node $Server
    {
        File DSCResFile
        {
            SourcePath = "\\share.server\Distr\DscRes_1.0.0.0.zip"
            DestinationPath = "c:\Distr"
            Ensure = "Present"
            Type = "File"
            Checksum = "SHA-256"
            Force = $true
        }
        Archive UnzipModule
        {
            DependsOn = "[File]DSCResFile"
            Path = "c:\Distr\DscRes_1.0.0.0.zip"
            Destination = $modulePath
            Ensure = "Present"
        }
    }
}
InstallDSCRes -OutputPath C:\Configs\dsc

Затем запускаем применение конфигурации:

Start-DscConfiguration -Path C:\Configs\dsc -Wait -Verbose

Ссылки:
Resource authoring checklist
Use DSC to Install Windows PowerShell DSC Resource Kit Bits

Задача: настроить управление конфигурацией серверов Exchange через DSC.

Для решения задачи энтузиастами был написан соответствующий модуль для DSC, который называется xExchange. Модуль требует для выполнения передачи в конфигурации пароля, который используется для запуска удалённой сессии WinRM для подключения к серверу Exchange. Чтобы не стать героем интернет-баек не стоит такие данные передавать в открытом виде, благо DSC даёт механизм шифрования паролей с использованием сертификата. Схема описана в официальной документации тут. Проблема в том, что в случае если Exchange устанавливается на Windows Server 2012 R2 (не важно какой версии – 2013 или 2016) эта схема не работает.

Давайте разбираться почему.

Шифрование пароля в модуле PSDesiredStateConfiguration реализовано через функцию Get-EncryptedPassword. Полный листинг функции можно посмотреть в файле PSDesiredStateConfiguration.psm1. В кратце, базовая версия DSC в составе WMF 4.0, который идёт в комплекте с Windows Server 2012 R2 (и единственное сочетание, которое поддерживается в связке Windows Server 2012 R2/Exchange 2013/16) реализуется так:

# Cast the public key correctly
$rsaProvider = [System.Security.Cryptography.RSACryptoServiceProvider]$cert.PublicKey.Key

# Convert to a byte array
$keybytes = [System.Text.Encoding]::UNICODE.GetBytes($value)

# Add a null terminator to the byte array
$keybytes += 0
$keybytes += 0

# Encrypt using the public key
$encbytes = $rsaProvider.Encrypt($keybytes, $false)

# Reverse bytes for unmanaged decryption
[Array]::Reverse($encbytes)

# Return a string
[Convert]::ToBase64String($encbytes)

А вот как это реализуется в обновлённой версии DSC, которая идёт в комплекте с WMF 5.0/5.1:

# Encrypt using the public key
$encMsg =Protect-CmsMessage -To $CmsMessageRecipient -Content $Value

# Reverse bytes for unmanaged decryption
#[Array]::Reverse($encbytes)

#$encMsg = $encMsg -replace '-----BEGIN CMS-----',''
#$encMsg = $encMsg -replace "`n",''
#$encMsg = $encMsg -replace '-----END CMS-----',''

return $encMsg

Найдите 10 отличий. Всё это приводит к тому, что mof-файл сгенерированный на сервере Windows Server 2016 (WMF 5.1) содержит пароль, который не может расшифровать DSC который идёт с WMF 4.0 на Windows Server 2012 R2.

Вывод: все mof-файлы для серверов Exchange установленных на Windows Server 2012 R2 необходимо генерировать на сервере Windows Server 2012 R2 с идущим в комплекте фрэймворком WMF 4.0.

Во времена Exchange 2010, если было необходимо найти и удалить письмо из почтовых ящиков пользователей использовался мега-командлет швейцарский нож Search-Mailbox, который позволял найти письма в нескольких почтовых ящиках, скопировать их куда надо и затем удалить. Этот командлет отлично работал при поиске и удалении, если необходимо было просмотреть несколько сотен почтовых ящиков. Если же ящиков было больше тысячи – начинались проблемы. Например, вот такие:

Единственное решение проблемы – разбивать ящики на группы и запускать поиск по группам меньшего размера. Например, как описано тут:

Get-Mailbox -Database  databasename | Search-Mailbox -SearchQuery 
'(from:spam@spamorg.com) AND (sent:1/1/2008..11/22/2012)' -DeleteContent -Force

Или даже так:

$allmbxinyourorg = Get-Mailbox -ResultSize unlimited
Foreach ($mbx in $allmbxinyourorg) {
Search-Mailbox -identity $mbx -SearchQuery '(from:spam@spamorg.com) 
AND (sent:1/1/2008..11/22/2012)' -DeleteContent –Force
}

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

В Exchange 2013 была попытка уйти от этой проблемы с помощью командлета New-MailboxSearch. Но, к сожалению, он не позволял удалять письма, только найти и скопировать в ящик/выгрузить в pst-файл.

В Exchange 2016 сделана очередная попытка дать инструмент для быстрого поиска и удаления писем. Это командлеты New-ComplianceSearch и New-ComplianceSearchAction. Попытка получилась так себе, но местами очень даже ничего. Подробно процесс описан в следующих документах:

Use Compliance Search to search all mailboxes in Exchange 2016
Search for and delete messages in Exchange 2016

Но есть проблема – Compliance Search не умеет удалять письма. В общем эти документы стоит прочитать и забыть. Вот как нужно делать поиск и удаление в настоящей почтовой организации:

  • Запускаем Compliance Search:
New-ComplianceSearch -Name Search1 -ExchangeLocation all -ContentMatchQuery 'from:"@corp.com"'
Start-ComplianceSearch -Identity Search1
  • Получаем список почтовых ящиков, в которых есть нужные нам письма:
$search = Get-ComplianceSearch –Identity Search1
$results = $search.SuccessResults
$mailboxes = @()
$lines = $results -split '[\r\n]+'
foreach ($line in $lines)
{
    if ($line -match 'Location: (\S+),.+Item count: (\d+)' -and $matches[2] -gt 0)
    {
        $mailboxes += $matches[1]
    }
}
  • Запускаем удаление писем из ящиков п.2:
$mailboxes | Get-Mailbox| Search-Mailbox -SearchQuery 'from:"@corp.com"' -DeleteContent -Force
  • Подчищаем следы:
Remove-ComplianceSearch –Identity Search1
Remove-MailboxSearch –Identity Search1-shadow

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

Есть, правда, и в этой бочке мёда ложка дёгтя:

  • Результат работы Compliance Search будет выглядеть в виде 1000 почтовых ящиков, отсортированных по количеству удовлетворяющих условию поиска писем. Если ящиков, в которых находится требуемое письмо больше 1000, то процедуру придётся повторить. До тех пор, пока не будут удалены все нужные письма.

Полезные ссылки:
Exchange Massive Search and Destroy & Quota control
Use Compliance Search to search all mailboxes in Exchange 2016
Search for and delete messages in Exchange 2016
Advanced Query Syntax
QueryString (QueryStringType)

Задача: нужно сменить расширение всех файлов в определённой директории, скажем с .txt на .log.

Задача имеет простое решение в PoSh:

Get-ChildItem -Path <SomePath> -Filter '*.txt' | Rename-Item -NewName {$_.name 
-replace '.txt','.log'}

Полезные ссылки:
Use PowerShell to Rename Files in Bulk

Задача: имеется список guid-ов объектов в AD, для которых необходимо получить некоторый набор свойств. Объекты могут быть как пользователями, так и группами или контактами. Искать нужно внутри многодоменного леса, объекты могут быть внутри любого домена в лесе. Найти объект, вне зависимости от его типа, можно через командлет Get-ADObject:

Get-ADObject $guid

Небольшое затруднение возникает, если необходимо делать поиск по всем доменам одного леса. Очевидное решение в лоб – взять список доменов и в каждом из них провести поиск:

$Domains = (Get-ADForest).Domains

ForEach ($Domain in $Domains)
{
$Search = Get-ADObject $guid -Server (Get-ADDomain $Domain).PDCEmulator -ErrorAction Stop
}

Работать будет, но с ошибками. Очевидно, что по-конкретному guid-у объект будет возвращаться только из одного домена. Поиск в остальных доменах вернёт ошибку, так как такого объекта там не будет. Поэтому имеет смысл внести в скрипт try-catch:

$Domains = (Get-ADForest).Domains
$Results = @()
ForEach ($Domain in $Domains)
{
  try
  {
    $Search = Get-ADObject $guid -Server (Get-ADDomain $Domain).PDCEmulator -ErrorAction Stop
    $Found = $true
  }
  catch
  {
    $Found = $false
  }
  If ($Found)
  {
    $Results += New-Object PSObject -Property @{
    guid = $guid
    Domain = $Domain
    DistinguishedName = $Search.DistinguishedName
    ObjectClass = $Search.ObjectClass
    Found = $Found
    }
  }
}

Добавляем загрузку из txt-файла списка guid-ов:

$guids = Import-Csv -Path C:\guids.txt
$Domains = (Get-ADForest).Domains
$Results = @()
ForEach ($guid in $guids)
{
  ForEach ($Domain in $Domains)
  {
    try
    {
      $Search = Get-ADObject $guid -Server (Get-ADDomain $Domain).PDCEmulator -ErrorAction Stop
      $Found = $true
    }
    catch
    {
      $Found = $false
    }
    If ($Found)
    {
      $Results += New-Object PSObject -Property @{
      guid = $guid
      Domain = $Domain
      DistinguishedName = $Search.DistinguishedName
      ObjectClass = $Search.ObjectClass
      Found = $Found
      }
    }
  }
}
$Results | Where { $_.Found } | Select guid,Domain,DistinguishedName,ObjectClass,Found

Задача решена и можно запускать скрипт и ждать. Если доменов у нас с десяток, объектов для поиска больше сотни, то ждать придётся долго. Что с эти делать? Искать в глобальном каталоге! Правда, стоит помнить, что он содержит не все аттрибуты объектов. Для поиска в глобальном каталоге используем тот же Get-ADObject, но в ключе -Server нужно будет указать любой ближайший глобальный каталог, по-умолчанию доступный при обращении к порту 3268:

$LocalSite = (Get-ADDomainController -Discover).Site
$NewTargetGC = Get-ADDomainController -Discover -Service GlobalCatalog -SiteName $LocalSite
if (!$NewTargetGC)
{ $NewTargetGC = Get-ADDomainController -Discover -Service GlobalCatalog -NextClosestSite }
$NewTargetGCHostName = $NewTargetGC.HostName
$LocalGC = "$NewTargetGCHostName" + ":3268"

Try-catch при таком поиске нам будет нужен разве что для обработки поиска несуществующих guid-ов. Скрипт немного упростится:

$guids = Import-Csv -Path C:\guids.txt
$Results = @()
$LocalSite = (Get-ADDomainController -Discover).Site
$NewTargetGC = Get-ADDomainController -Discover -Service GlobalCatalog -SiteName $LocalSite
if (!$NewTargetGC)
{ $NewTargetGC = Get-ADDomainController -Discover -Service GlobalCatalog -NextClosestSite }
$NewTargetGCHostName = $NewTargetGC.HostName
$LocalGC = "$NewTargetGCHostName" + ":3268"
ForEach ($guid in $guids) 
{
  Try 
  {
    $Search = Get-ADObject -Identity $guid.guid -Server $LocalGC
  }
  Catch {
  }
  $Results += New-Object PSObject -Property @{
  guid = $guid.guid
  DistinguishedName = $Search.DistinguishedName
  ObjectClass = $Search.ObjectClass
  }
}
$Results | Select guid,DistinguishedName,ObjectClass

Скрипт при этом отрабатывать будет на пару порядков быстрее.

Полезные ссылки:
Powershell Code: Discover a Local Global Catalog for Forest Queries
PowerShell Code: Find User in Active Directory Forest
Get-ADDomainController
Get-ADObject

e2010Ранее я вскользь касался момента с переносом общих папок из базы на одном сервере в базу на другом сервере. Использование скрипта MoveAllReplicas.ps1 помогает в большинстве случаев. Но иногда, возникает ситуация, когда конкретная реплика общей папки не удаляется из базы общих папок. То есть в свойствах общей папки в репликах отсутствует некоторая база общих папок, но де-факто реплика из этой базы по какой-то причине не удалилась. Если таких папок немного, то можно их выгрузить в pst-файл и папку пересоздать. Если таких папок много – то приходится искать другие механизмы. Самый простой – попробовать добавить реплику общей папку в проблемную базу и удалить её через скрипт RemoveReplicaFromPFRecursive.ps1. Проще всего это сделать через PowerShell:

#$PFs - содержит список неудалившихся реплик
$PFs = Get-PublicFolderStatistics -Server pfserver -ResultSize unlimited | Get-PublicFolder
#$PFDB - база, которую вычищаем
$PFDB = Get-PublicFolderDatabase -Server pfserver

foreach ($PF in PFs) {
#Получаем список реплик и добавляем в них проблемную базу
$repl = Get-PublicFolder -Identity $PF.Identity -Server pfserver
$repl.Replicas+= $PFDB.Identity
Set-PublicFolder -Identity $PF.Identity -Replicas $repl.Replicas

#Обновляем содержимое проблемных реплик
Update-PublicFolder -Identity $PF.Identity -Server pfserver;
}

По итогам выполнения скрипта запускаем процедуру удаления реплики:

.RemoveReplicaFromPFRecursive.ps1 -TopPublicFolder  -ServerToRemove pfserver

После этого ждём, пока отработает этот скрипт. Можно отслеживать процесс относительно интерактивно. Для этого необходимо повысить уровень логгирования для баз общих папок на проблемном сервере:

Set-EventLogLevel 'psserverMSExchangeIS9001 PublicReplication*' -Level High

События в логе с номером 3005 будут сигнализировать о том реплика общей папки была удалена из базы общих папок на проблемном сервере:

Log Name:      Application
Source:        MSExchangeIS Public Store
Event ID:      3005
Task Category: Replication AD Updates
Level:         Information
Keywords:      Classic
User:          N/A
Computer:      psserver
Description:
A replica of public folder (6-6891E5C6D7) IPM_SUBTREESome Folder
was removed.
Database PF-DATABASE.

PowerShell LogoИлья Сазонов недавно жаловался на проблему массового удаления большого количества объектов из папки RecoverableItems. Решил он эту проблему с помощью редактора MFCMapi. Но, задача так же имеет решение через EWS. При этом необходимо держать в уме Throttling Policy, которые, возможно, необходимо будет подправить для учётной записи, из под которой мы будем удалять объекты. Не растекаясь долго мысью по древу просто выложу скрипт, который удаляет объекты из папки RecoverableItems ящика с адресом user.name@domain.com.

Import-Module -Name "C:Program FilesMicrosoftExchangeWeb Services2.0Microsoft.Exchange.WebServices.dll"
$FromMailbox = "user.name@domain.com"

$service = New-Object Microsoft.Exchange.WebServices.Data.ExchangeService([Microsoft.Exchange.WebServices.Data.ExchangeVersion]::Exchange2010_SP2)
# Impersonation
$service.ImpersonatedUserId = New-Object Microsoft.Exchange.WebServices.Data.ImpersonatedUserId([Microsoft.Exchange.WebServices.Data.ConnectingIdType]::SmtpAddress,$FromMailbox)
# Адрес CAS-сервера получаем через службу автообнаружения
$service.AutodiscoverUrl($FromMailbox)

# Размер страницы вывода (количество объектов, возвращаемых за один раз)
$pageSize = 50
$Offset = 0
do {
$ItemView = new-object Microsoft.Exchange.WebServices.Data.ItemView($pageSize,$Offset,[Microsoft.Exchange.WebServices.Data.OffsetBasePoint]::Beginning)
# Получаем объекты из папки RecoverableItemsRoot, параметры вывода берём из переменной $ItemView
$FindItems = $service.FindItems([Microsoft.Exchange.WebServices.Data.WellKnownFolderName]::RecoverableItemsRoot, $ItemView)
foreach ($Item in $FindItems.Items){
# Операция жёсткого удаления объекта
$Item.Delete([Microsoft.Exchange.WebServices.Data.DeleteMode]::HardDelete) }
$Offset += $pageSize }
# Продолжаем перебор объектов, пока остаются непросмотренные
while ($FindItems.MoreAvailable)

Полезные ссылки:
EWS throttling in Exchange
WellKnownFolderName enumeration
ExchangeService.FindItems method (FolderId, ViewBase)
DeleteMode enumeration
ItemView constructor (Int32, Int32, OffsetBasePoint)
OffsetBasePoint enumeration
Configuring Exchange Impersonation

PowerShell LogoВ Outlook пользователь на конкретные папки может достаточно гибко раздавать права доступа другим пользователям организации. Обычно этот сценарий используется руководителями, которые делегируют часть прав на свой ящик секретарю. И в некоторый момент может понадобиться сделать простейший аудит прав. Права на конкретную папку в почтовом ящике пользователя можно получить с командлетом Get-MailboxFolderPermission. Например:

[PS] C:>Get-MailboxFolderPermission stbul:calendar | fl User, AccessRights

User         : Default
AccessRights : {AvailabilityOnly}

User         : Anonymous
AccessRights : {None}

Главная сложность в том, что в качестве основного параметра в этот командлет необходимо передавать имя/идентификатор папки. И в ответ мы получаем не конкретные значения, а объекты, которые нам интересны не полностью, а только некоторые их свойства (User и AccessRights). То есть необходимо получить полный список папок ящика пользователя и по каждой из них получить набор пар User-AccessRights. Полный список папок ящика пользователя проще всего получить через командлет Get-MailboxFolderStatistics, который вернёт как имена папок, так и их идентификаторы (которые использовать проще, чем имена папок). Итоговый скрипт выглядит примерно так:

. 'C:Program FilesMicrosoftExchange ServerV14binRemoteExchange.ps1'
Connect-ExchangeServer -auto sleep 5

$Mailbox = Get-Mailbox mailbox.name
$MbxStat = Get-MailboxFolderStatistics $Mailbox

foreach ($item in $MbxStat){
    $folder = $Mailbox.Name + ":" + $item.FolderId
    $FolderPerms = Get-MailboxFolderPermission $folder
    foreach ($FolderPerm in $FolderPerms){
        Write-Host $item.FolderPath " | " $FolderPerm.User " | " $FolderPerm.AccessRights }
}

Полезные ссылки:
Get-MailboxFolderPermission
Get-MailboxFolderStatistics

PowerShell LogoПериодически приходится писать крипты, автоматизирующие те или иные действия администратора. Иногда это бывает обычный сбор информации. Иногда это внесение изменений в конфигурацию/учётные записи, связанные с наступлением тех или иных событий. Тонкость возникает там, где необходимо работать с объектами Exchange. обычно, для этого достаточно в скрипте импортировать оснастку PS Microsoft.Exchange.Management.PowerShell.E2010 (Excahnge 2010):

Add-PSSnapin Microsoft.Exchange.Management.PowerShell.E2010

Однако, в случае, когда нам в скрипте необходимо использовать командлет Get-MailboxStatistics получаем следующую ошибку:

PS C:> Get-Mailbox user | Get-MailboxStatistics
Get-MailboxStatistics : Failed to commit the change on object "MB-DATABASE" because access is denied.
At line:1 char:45
+ Get-Mailbox buldakov | Get-MailboxStatistics <<<<
+ CategoryInfo          : NotSpecified: (0:Int32) [Get-MailboxStatistics], MapiAccessDeniedException
+ FullyQualifiedErrorId : 46BEBAF0,Microsoft.Exchange.Management.MapiTasks.GetMailboxStatistics

Проверка прав для учётки, из под которой запускается скрипт показала, что права на запуск есть. Тем более странной стала выглядеть ситуация.

PS C:>Get-ManagementRoleEntry "*Get-MailboxStatistics" | ? {$_.Role -like "Mail  Recipients"} | fl Name,Role
Name : Get-MailboxStatistics
Role : Mail Recipients

PS C:>Get-ManagementRoleAssignment -Role "Mail Recipients" -GetEffectiveUsers | ? {$_.EffectiveUserName -like "svc_Name"} | fl Role,EffectiveUserName
Role              : Mail Recipients
EffectiveUserName : svc_Name

Нашёлся следующий workaround. Для подключения к Exchange используем стандартный скрипт RemoteExchange.ps1:

. 'C:Program FilesMicrosoftExchange ServerV14binRemoteExchange.ps1'
Connect-ExchangeServer -auto

Точку с пробелом в начале ставить обязательно.