Перейти к содержимому

Как сделать автоматическую сверка VDI-доступа: как найти и убрать «осиротевшие» назначения в VMware/Omnissa Horizon в full машинах

modul omnisa powershell

В инфраструктуре VDI со временем неизбежно накапливается расхождение между тем, кому назначен доступ по правам (AD-группа), и тем, кто фактически занимает виртуальную машину в пуле. Причины разные: сотрудник уволился и его убрали из группы, но машину за ним забыли отвязать; произошла ручная правка в консоли Horizon в обход процесса; группа синхронизировалась с задержкой. Итог один — «осиротевшая» машина числится занятой человеком, у которого по факту уже нет прав на неё, а легитимные пользователи из группы простаивают без ресурса.

Ниже — рабочее решение на PowerShell из двух скриптов, которое находит такие расхождения автоматически, ведёт лог с историей за 7 дней и умеет самостоятельно освобождать «бесхозные» машины для повторного назначения.

Что делает решение

Решение состоит из двух файлов:

  • Compare-ADGroup-HorizonPool.ps1 — основной скрипт. Сравнивает членов AD-группы (например, G-VDI Full AUP) со списком пользователей, за которыми в пуле Horizon (например, nvdi-fl-aup) закреплены машины (поле Assigned User). При обнаружении пользователя, который занимает машину, но не состоит в группе, — опционально снимает это закрепление, чтобы машину можно было отдать легитимному сотруднику.
  • Save-HVCredential.ps1 — вспомогательный скрипт для одноразовой настройки. Сохраняет учётные данные сервисной учётной записи в зашифрованном виде, чтобы основной скрипт можно было безопасно запускать по расписанию через Планировщик заданий Windows, без хранения пароля в открытом виде.

Главный результат — то, ради чего всё затевалось

Ключевая метрика, которую показывает скрипт при каждом запуске:

Пользователи, которые занимают машину пула, но отсутствуют в AD-группе.

Это именно та ситуация, которая создаёт риск: доступ к десктопу есть, а прав по группе — уже нет. Скрипт явно выделяет такие случаи в логе и в консоли (жёлтым цветом, отдельной таблицей), а не прячет их в общем списке.

Отдельно, но менее приоритетно, показывается обратная картина — кто состоит в группе, но не занимает ни одной машины (это сигнал, что пользователю пора выдать десктоп, а не проблема безопасности).

Автоматическое освобождение машины

Если включить режим $ReleaseUnauthorizedMachines = $true, скрипт не просто покажет расхождение, а снимет закрепление пользователя за машиной — то же самое действие, которое администратор выполняет вручную кнопкой Unassign User в консоли Horizon Admin. Технически это связка вызовов Horizon API:

  1. MachineService.Read() — чтение текущего состояния машины;
  2. setUser($null) — обнуление назначенного пользователя;
  3. MachineService.Update() — применение изменения.

Чтобы автоматическое освобождение не стало неприятным сюрпризом, в скрипте есть отдельный переключатель безопасности — $DryRun. Пока он включён ($true), скрипт только пишет в лог, что было бы сделано, ничего реально не меняя. Рекомендованный порядок внедрения: сначала прогнать пару раз с $DryRun = $true и убедиться, что список расхождений корректный, и только после этого переключить на боевой режим ($DryRun = $false).

Гибкая авторизация: тест и продакшн одним переключателем

Для подключения к Connection Server скрипт поддерживает два режима через переменную $AuthMode:

  • PlainText — логин, домен и пароль берутся напрямую из переменных в начале файла. Удобно для быстрых ручных тестов, но пароль хранится в открытом виде — не для боевого расписания.
  • CredentialFile — учётные данные читаются из файла, зашифрованного через Windows DPAPI (Export-Clixml). Такой файл создаётся один раз скриптом Save-HVCredential.ps1 и может быть расшифрован только той же учётной записью Windows на той же машине, где он был создан — это и есть штатный способ безопасно запускать скрипт из Планировщика заданий без хранения пароля в скрипте.

Переключение — это буквально одна строка в блоке переменных, никакой другой логики менять не нужно.

Что ещё внутри

  • Логирование с ротацией — каждый запуск создаёт отдельный файл лога с меткой времени, файлы старше 7 дней удаляются автоматически при следующем запуске.
  • Самостоятельная установка компонентов — при отсутствии модуля ActiveDirectory (RSAT) или Horizon PowerCLI helper-модуля (Omnissa.HV.Helper / Omnissa.Horizon.Helper / VMware.HV.Helper) скрипт может доустановить их сам: RSAT — через Install-WindowsFeature (сервер) или Add-WindowsCapability (клиент), Horizon-модуль — скачиванием актуального пакета с официального портала Omnissa для нужной версии Horizon.
  • Открытая сессия по завершении — скрипт намеренно не закрывает соединение с Horizon (Disconnect-HVServer не вызывается), чтобы в интерактивном режиме можно было продолжить работу с объектом $hvServerObj в той же консоли.

Как развернуть

  1. Один раз, под учётной записью, от имени которой будет работать задание в Планировщике, запустить Save-HVCredential.ps1 — он спросит пароль сервисной УЗ и сохранит его в зашифрованном виде.
  2. Ограничить NTFS-доступ к папке с зашифрованным файлом только этой учётной записью и администраторами.
  3. Создать задание в Планировщике заданий Windows, запускающее Compare-ADGroup-HorizonPool.ps1 от имени той же учётной записи, с $AuthMode = "CredentialFile".
  4. Первые несколько запусков держать $ReleaseUnauthorizedMachines = $true вместе с $DryRun = $true, чтобы убедиться в корректности списка расхождений, прежде чем разрешать скрипту реально освобождать машины.

Итог

Два небольших PowerShell-скрипта закрывают полный цикл: находят расхождение между правами доступа (AD-группа) и фактическим владением ресурсом (Horizon-пул), логируют его с историей, и — при готовности — сами устраняют, освобождая машину для повторного назначения. Решение спроектировано так, чтобы им можно было безопасно управлять на расстоянии одной переменной: тест или продакшн, отчёт или реальное действие, — без необходимости переписывать код под каждый сценарий.

Save-HVCredential.ps1

<#
.SYNOPSIS
    Однократная настройка: сохраняет учётные данные Horizon-администратора
    в зашифрованный файл для последующего использования в Compare-ADGroup-HorizonPool.ps1
    (в том числе при запуске через Планировщик заданий).

.DESCRIPTION
    Пароль шифруется через Windows DPAPI (Export-Clixml). Расшифровать файл
    сможет ТОЛЬКО та же учётная запись Windows на ТОЙ ЖЕ машине, под которой
    он был создан.

    ВАЖНО ДЛЯ TASK SCHEDULER:
    Запустите этот скрипт ОДИН РАЗ интерактивно, войдя в Windows под ТОЙ ЖЕ
    учётной записью, от имени которой будет запускаться задание в Планировщике,
    и на ТОЙ ЖЕ машине, где будет исполняться задание.
    Если задание в Task Scheduler настроено на "Run whether user is logged on or not"
    под сервисной УЗ — зайдите на сервер под этой УЗ (например, через RunAs / удалённый
    рабочий стол) и выполните этот скрипт от её имени.

.NOTES
    Запуск: powershell.exe -ExecutionPolicy Bypass -File .\Save-HVCredential.ps1
#>

# --- Куда сохранить зашифрованный файл (должен совпадать с $CredentialFilePath в основном скрипте) ---
$CredentialFilePath = "D:\Scripts\vdi voxys full\Secrets\hv-service-account.xml"
$HVUser              = "логин"     # логин Horizon-администратора (без домена)
$HVDomain            = "adminbd.ru"          # домен учётной записи

# --- Создание папки, если её нет ---
$folder = Split-Path $CredentialFilePath -Parent
if (-not (Test-Path $folder)) {
    New-Item -Path $folder -ItemType Directory -Force | Out-Null
}

Write-Host "Текущая учётная запись Windows: $env:USERDOMAIN\$env:USERNAME" -ForegroundColor Cyan
Write-Host "Файл будет расшифровываться ТОЛЬКО этой учётной записью на ЭТОЙ машине." -ForegroundColor Cyan
Write-Host ""

# --- Запрос пароля (вводится один раз, интерактивно, нигде не сохраняется в открытом виде) ---
$credential = Get-Credential -UserName "$HVDomain\$HVUser" -Message "Введите пароль учётной записи Horizon-администратора"

# --- Сохранение в зашифрованном виде ---
$credential | Export-Clixml -Path $CredentialFilePath -Force

Write-Host ""
Write-Host "Готово. Зашифрованные учётные данные сохранены: $CredentialFilePath" -ForegroundColor Green
Write-Host "Ограничьте доступ к этому файлу и папке через NTFS-права (только УЗ, под которой идёт задание, и администраторы)." -ForegroundColor Yellow

# --- Проверка, что файл читается обратно ---
try {
    $test = Import-Clixml -Path $CredentialFilePath
    Write-Host "Проверка чтения: OK, логин в файле = $($test.UserName)" -ForegroundColor Green
}
catch {
    Write-Host "Проверка чтения не удалась: $($_.Exception.Message)" -ForegroundColor Red
}

Compare-ADGroup-HorizonPool.ps1

<#
.SYNOPSIS
    Сравнение членов AD-группы "группа с уз " со списком пользователей,
    фактически занимающих (Assigned User) машины пула Horizon "fl-up".

.DESCRIPTION
    - При необходимости автоматически ставит недостающие компоненты:
        * модуль ActiveDirectory (RSAT)
        * Horizon PowerCLI helper-модуль (Omnissa.HV.Helper / Omnissa.Horizon.Helper / VMware.HV.Helper)
    - Подключается к Connection Server Horizon одним из двух способов ($AuthMode):
        * "CredentialFile" — зашифрованный файл (Export-Clixml/DPAPI), для боевого запуска
          из Планировщика заданий.
        * "PlainText"      — логин/пароль напрямую из переменных, для ручных тестовых прогонов.
    - Получает список пользователей AD-группы.
    - Получает список машин пула и их Assigned User.
    - ГЛАВНЫЙ РЕЗУЛЬТАТ: показывает пользователей, которые занимают машину пула,
      но НЕ состоят в AD-группе (то есть доступ к машине есть, а прав по группе — нет).
    - При включённом $ReleaseUnauthorizedMachines — освобождает (unassign) такие машины,
      чтобы их можно было переназначить легитимному пользователю.
    - Дополнительно показывает обратную ситуацию: кто в группе, но без машины.
    - Пишет подробный лог в файл, хранит логи 7 дней (старые удаляются).
    - Сессию Horizon в конце НЕ закрывает (Disconnect-HVServer не вызывается).

.NOTES
    Запуск: powershell.exe -ExecutionPolicy Bypass -File .\Compare-ADGroup-HorizonPool.ps1
#>

#region ====================== ПЕРЕМЕННЫЕ (редактировать здесь) ======================
# --- Подключение к Horizon ---
$HVServer            = "vd.adminbd.ru"          # FQDN/адрес Connection Server

# Режим авторизации:
#   "CredentialFile" -> читает зашифрованный файл (см. Save-HVCredential.ps1), для Task Scheduler
#   "PlainText"      -> логин/пароль из переменных ниже, для ручных тестовых прогонов
$AuthMode            = "CredentialFile"

# --- Используется, если $AuthMode = "CredentialFile" ---
$CredentialFilePath  = "D:\Scripts\vdi voxys full\Secrets\hv-service-account.xml"

# --- Используется, если $AuthMode = "PlainText" (только для теста!) ---
$HVUser              = "administrator"
$HVDomain            = "adminbd.ru"
$HVPassword          = "ЗАМЕНИТЕ_НА_ПАРОЛЬ"    # ВНИМАНИЕ: хранится в открытом виде — только для теста,
                                                # не используйте PlainText в боевом задании Task Scheduler

# --- Что сравниваем ---
$ADGroupName         = "Full AU"        # имя AD-группы
$PoolName             = "fl-au"          # имя пула Horizon

# --- Логирование ---
$LogFolder            = "C:\Scripts\Logs\up-compare"
$LogRetentionDays     = 7

# --- Автоустановка компонентов ---
$AutoInstallComponents = $true
$HVHelperDownloadUrl    = "https://github.com/euc-dev/horizon-powercli/releases/download/2406/horizon4powercli_2406.zip"
$ModuleInstallRoot       = "C:\Program Files\WindowsPowerShell\Modules"

# --- Освобождение машин пользователей, отсутствующих в AD-группе ---
# $ReleaseUnauthorizedMachines = $true  -> реально отвязывать пользователя от машины (Machine_Unassign)
# $DryRun = $true                       -> ничего не менять, только показать/залогировать, что было бы сделано
# Рекомендация: сначала прогнать с $ReleaseUnauthorizedMachines=$true и $DryRun=$true,
# убедиться в логе, что список верный, и только потом ставить $DryRun=$false.
$ReleaseUnauthorizedMachines = $false
$DryRun                       = $true
#endregion
# ==================================================================================

#region ====================== ЛОГИРОВАНИЕ ======================
if (-not (Test-Path $LogFolder)) {
    New-Item -Path $LogFolder -ItemType Directory -Force | Out-Null
}

$LogFile = Join-Path $LogFolder ("compare_{0}.log" -f (Get-Date -Format "yyyy-MM-dd_HH-mm-ss"))

function Write-Log {
    param(
        [string]$Message,
        [ValidateSet("INFO","WARN","ERROR")]
        [string]$Level = "INFO"
    )
    $line = "{0} [{1}] {2}" -f (Get-Date -Format "yyyy-MM-dd HH:mm:ss"), $Level, $Message
    Add-Content -Path $LogFile -Value $line -Encoding UTF8

    switch ($Level) {
        "WARN"  { Write-Host $line -ForegroundColor Yellow }
        "ERROR" { Write-Host $line -ForegroundColor Red }
        default { Write-Host $line }
    }
}

Get-ChildItem -Path $LogFolder -Filter "compare_*.log" -ErrorAction SilentlyContinue |
    Where-Object { $_.LastWriteTime -lt (Get-Date).AddDays(-$LogRetentionDays) } |
    ForEach-Object {
        Remove-Item $_.FullName -Force
        Write-Log "Удалён устаревший лог: $($_.Name)"
    }
#endregion

Write-Log "===== Запуск сравнения AD-группы '$ADGroupName' и пула Horizon '$PoolName' (AuthMode: $AuthMode, ReleaseUnauthorizedMachines: $ReleaseUnauthorizedMachines, DryRun: $DryRun) ====="

#region ====================== АВТОУСТАНОВКА КОМПОНЕНТОВ ======================
if (-not (Get-Module -ListAvailable -Name ActiveDirectory)) {
    Write-Log "Модуль ActiveDirectory не найден"
    if ($AutoInstallComponents) {
        try {
            $osInfo = Get-CimInstance -ClassName Win32_OperatingSystem
            if ($osInfo.ProductType -eq 1) {
                Write-Log "Клиентская ОС, ставим RSAT через Add-WindowsCapability"
                Add-WindowsCapability -Online -Name "Rsat.ActiveDirectory.DS-LDS.Tools~~~~0.0.1.0" -ErrorAction Stop | Out-Null
            }
            else {
                Write-Log "Серверная ОС, ставим RSAT-AD-PowerShell через Install-WindowsFeature"
                Install-WindowsFeature -Name RSAT-AD-PowerShell -ErrorAction Stop | Out-Null
            }
            Write-Log "Модуль ActiveDirectory установлен"
        }
        catch {
            Write-Log "Не удалось автоматически установить модуль ActiveDirectory: $($_.Exception.Message)" "ERROR"
            exit 1
        }
    }
    else {
        Write-Log "AutoInstallComponents = \$false, установите модуль ActiveDirectory вручную и перезапустите скрипт" "ERROR"
        exit 1
    }
}

$HVHelperCandidates = @("Omnissa.HV.Helper", "Omnissa.Horizon.Helper", "VMware.HV.Helper")
$foundHVHelper = $HVHelperCandidates | Where-Object { Get-Module -ListAvailable -Name $_ } | Select-Object -First 1

if (-not $foundHVHelper) {
    Write-Log "Horizon PowerCLI helper-модуль не найден (проверены: $($HVHelperCandidates -join ', '))"
    if ($AutoInstallComponents) {
        try {
            $tempZip     = Join-Path $env:TEMP "horizon4powercli.zip"
            $tempExtract = Join-Path $env:TEMP "horizon4powercli_extract"

            Write-Log "Скачивание модуля с $HVHelperDownloadUrl"
            Invoke-WebRequest -Uri $HVHelperDownloadUrl -OutFile $tempZip -UseBasicParsing -ErrorAction Stop

            if (Test-Path $tempExtract) { Remove-Item $tempExtract -Recurse -Force }
            Expand-Archive -Path $tempZip -DestinationPath $tempExtract -Force -ErrorAction Stop

            $manifest = Get-ChildItem -Path $tempExtract -Recurse -Filter "*.psd1" |
                Where-Object { $_.Name -match "Horizon|HV" } |
                Select-Object -First 1

            if (-not $manifest) {
                throw "В скачанном архиве не найден .psd1 манифест helper-модуля"
            }

            $moduleSourceFolder = $manifest.DirectoryName
            $moduleName         = $manifest.BaseName
            $targetPath         = Join-Path $ModuleInstallRoot $moduleName

            Write-Log "Найден модуль '$moduleName' в '$moduleSourceFolder', копируем в '$targetPath'"
            Copy-Item -Recurse -Path $moduleSourceFolder -Destination $targetPath -Force -ErrorAction Stop

            $foundHVHelper = $moduleName
            Write-Log "Horizon PowerCLI helper-модуль '$moduleName' установлен"
        }
        catch {
            Write-Log "Не удалось автоматически установить Horizon helper-модуль: $($_.Exception.Message)" "ERROR"
            exit 1
        }
    }
    else {
        Write-Log "AutoInstallComponents = \$false, установите Horizon helper-модуль вручную и перезапустите скрипт" "ERROR"
        exit 1
    }
}
else {
    Write-Log "Найден Horizon PowerCLI helper-модуль: $foundHVHelper"
}
#endregion

#region ====================== ЗАГРУЗКА МОДУЛЕЙ ======================
try {
    Import-Module ActiveDirectory -ErrorAction Stop
    Write-Log "Модуль ActiveDirectory загружен"
}
catch {
    Write-Log "Не удалось загрузить модуль ActiveDirectory: $($_.Exception.Message)" "ERROR"
    exit 1
}

try {
    Import-Module $foundHVHelper -ErrorAction Stop
    Write-Log "Модуль $foundHVHelper загружен"
}
catch {
    Write-Log "Не удалось загрузить модуль $foundHVHelper : $($_.Exception.Message)" "ERROR"
    exit 1
}
#endregion

#region ====================== ПОДКЛЮЧЕНИЕ К HORIZON ======================
try {
    switch ($AuthMode) {
        "CredentialFile" {
            if (-not (Test-Path $CredentialFilePath)) {
                throw "Файл с учётными данными не найден: $CredentialFilePath. Сначала запустите Save-HVCredential.ps1 под той же УЗ, от имени которой пойдёт это задание."
            }
            $HVCredential = Import-Clixml -Path $CredentialFilePath
            Write-Log "Учётные данные загружены из файла (логин: $($HVCredential.UserName))"
        }
        "PlainText" {
            $securePassword = ConvertTo-SecureString $HVPassword -AsPlainText -Force
            $HVCredential    = New-Object System.Management.Automation.PSCredential("$HVDomain\$HVUser", $securePassword)
            Write-Log "Учётные данные взяты из переменных PlainText (логин: $HVDomain\$HVUser) — тестовый режим"
        }
        default {
            throw "Неизвестное значение \$AuthMode = '$AuthMode'. Допустимо: 'CredentialFile' или 'PlainText'."
        }
    }

    $hvServerObj = Connect-HVServer -Server $HVServer -Credential $HVCredential -ErrorAction Stop
    Write-Log "Подключение к Connection Server '$HVServer' выполнено успешно"
}
catch {
    Write-Log "Ошибка подключения к Horizon: $($_.Exception.Message)" "ERROR"
    exit 1
}
#endregion

#region ====================== ЧЛЕНЫ AD-ГРУППЫ ======================
try {
    $adMembers = @(Get-ADGroupMember -Identity $ADGroupName -Recursive |
        Where-Object { $_.objectClass -eq "user" } |
        Get-ADUser -Properties SamAccountName |
        Select-Object -ExpandProperty SamAccountName)

    Write-Log "Найдено пользователей в AD-группе '$ADGroupName': $($adMembers.Count)"
}
catch {
    Write-Log "Ошибка получения членов AD-группы: $($_.Exception.Message)" "ERROR"
    exit 1
}
#endregion

#region ====================== МАШИНЫ ПУЛА И НАЗНАЧЕННЫЕ ПОЛЬЗОВАТЕЛИ ======================
try {
    $machines = @(Get-HVMachineSummary -PoolName $PoolName -HvServer $hvServerObj)

    $assignedUsers = @(foreach ($m in $machines) {
        $userName = $m.NamesData.UserName
        if ($userName) {
            [PSCustomObject]@{
                Machine    = $m.Base.Name
                DomainUser = $userName
                SamAccount = ($userName -split '\\')[-1]
                MachineId  = $m.Id
            }
        }
    })

    Write-Log "Найдено машин в пуле '$PoolName': $($machines.Count); с назначенным пользователем: $($assignedUsers.Count)"
}
catch {
    Write-Log "Ошибка получения машин пула: $($_.Exception.Message)" "ERROR"
    exit 1
}
#endregion

#region ====================== СРАВНЕНИЕ ======================
$assignedSam = @($assignedUsers.SamAccount | Sort-Object -Unique)

# ГЛАВНОЕ: занимают машину пула, но НЕ входят в AD-группу
# @(...) обязателен: Where-Object при ровно одном результате возвращает не массив,
# а одиночный объект без свойства .Count — из-за этого проверка ($notInGroup.Count -gt 0)
# молча проваливалась бы даже при реальном расхождении.
$notInGroup = @($assignedUsers | Where-Object { $adMembers -notcontains $_.SamAccount })

# Дополнительно: входят в AD-группу, но НЕ занимают ни одной машины пула
$notAssigned = @($adMembers | Where-Object { $assignedSam -notcontains $_ })

Write-Log "================================================================"
Write-Log "!!! ГЛАВНЫЙ РЕЗУЛЬТАТ !!!"
Write-Log "Пользователи, ЗАНИМАЮЩИЕ машину пула '$PoolName', но ОТСУТСТВУЮЩИЕ в группе '$ADGroupName':"
Write-Log "Всего таких: $($notInGroup.Count)"
Write-Log "================================================================"

if ($notInGroup.Count -eq 0) {
    Write-Log "  Расхождений нет — все пользователи с машинами состоят в группе."
}
else {
    foreach ($u in $notInGroup) {
        Write-Log ("  МАШИНА: {0,-16}  ПОЛЬЗОВАТЕЛЬ: {1}" -f $u.Machine, $u.DomainUser) "WARN"
    }
    Write-Host ""
    Write-Host "Таблица: пользователи с машиной, но вне группы '$ADGroupName'" -ForegroundColor Yellow
    $notInGroup | Sort-Object Machine | Format-Table Machine, DomainUser -AutoSize | Out-String | Write-Host
}

Write-Log "----------------------------------------------------------------"
Write-Log "Дополнительно: пользователи из группы '$ADGroupName', НЕ занимающие ни одной машины пула (всего: $($notAssigned.Count)):"
if ($notAssigned.Count -eq 0) {
    Write-Log "  (нет расхождений)"
}
else {
    foreach ($u in $notAssigned) {
        Write-Log "  $u"
    }
}
#endregion

#region ====================== ОСВОБОЖДЕНИЕ МАШИН (unassign) ======================
if ($notInGroup.Count -gt 0) {
    if ($ReleaseUnauthorizedMachines) {
        Write-Log "----------------------------------------------------------------"
        Write-Log "Освобождение машин у пользователей вне группы '$ADGroupName' (DryRun: $DryRun)"

        $machineService = New-Object VMware.Hv.MachineService

        foreach ($u in $notInGroup) {
            if ($DryRun) {
                Write-Log "  [DRY-RUN] Была бы освобождена машина '$($u.Machine)' от пользователя '$($u.DomainUser)'" "WARN"
                continue
            }

            try {
                # Правильный способ снять закрепление пользователя за машиной в dedicated-пуле:
                # прочитать MachineInfoHelper, обнулить пользователя через setUser($null),
                # применить изменение через Update. Отдельного метода "Unassign" в API нет.
                $machineInfoHelper = $machineService.Read($hvServerObj.ExtensionData, $u.MachineId)
                $machineInfoHelper.getBaseHelper().setUser($null)
                $machineService.Update($hvServerObj.ExtensionData, $machineInfoHelper)

                Write-Log "  Машина '$($u.Machine)' освобождена от пользователя '$($u.DomainUser)'" "WARN"
            }
            catch {
                Write-Log "  Не удалось освободить машину '$($u.Machine)' (пользователь '$($u.DomainUser)'): $($_.Exception.Message)" "ERROR"
            }
        }
    }
    else {
        Write-Log "ReleaseUnauthorizedMachines = \$false — машины НЕ освобождались, только показан список выше."
    }
}
#endregion

#region ====================== ЗАВЕРШЕНИЕ ======================
Write-Log "===== Сравнение завершено. Сессия Horizon оставлена открытой (\$hvServerObj). Лог: $LogFile ====="
#endregion

 

Similar Posts:

Добавить комментарий

Яндекс.Метрика