Скрипт обслуживания сервера RDS

15.07.2015

Для устойчивой работы терминального сервера необходимо проводить на нем регулярные чистки профилей пользователей, очереди печати, портов терминальных принтеров и прочих временных ресурсов.

При длительной работе RDS сервера на нем могут накапливаться:

  • профили пользователей в папке C:\Users, которые должны удаляться при выходе пользователя из системы, если включен механизм перемещаемых профилей (roaming profiles).
  • порты принтеров из терминальных сессий, которые не удаляются из системы при выходе пользователя из системы, но для нормальной работы RDS они не нужны.
  • задания очереди печати, которые не смогли достигнуть принтера назначения.
  • ветки реестра пользователей, которые указывают на расположение локального профиля, и, которые должны удаляться при выходе пользователя из системы, если включен механизм перемещаемых профилей.
  • задания на оптимизацию профилей пользователей, которые, впринципе, не нужны на терминальном сервере.

Для чистки терминального сервера предлагается Powershell-скрипт, который вычищает вышеперечисленные объекты из системы.

# ------------------------------------ DESCRIPTION ------------------------------------
# Скрипт для обслуживания терминального сервера Windows 2008/2012 RDS в режиме перемещаемых
# профилей пользователей.
# - удаление пользователей из доступа к RDP
# - принудительное завершение сеанса пользователей
# - удаление испорченных папок профилей (папки должны быть удалены при завершении сеанса
# пользователя)
# - удаление испорченных веток реестра профилей (ветки реестра должны быть удалены при завершении
# сеанса пользователя)
# - удаление задач по оптимизации профилей
# - остановка сервиса печати, удаление всех заданий печати, запуск DeleteInactivePortSilently.exe, запуск
# сервиса печати
# - добавление пользователей к доступу к RDP

# ------------------------------------- VARIABLES -------------------------------------
# Указание папки профилей
$ProfileFolder = "C:\Users"
# Указание служебных папок
$ExcludedProfiles = "Default", "Default User", "Public", "All Users", "Administrator"
# Указание ветки реестра профилей
$ProfileRegistrySection = "HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList"
# Указание служебных веток реестра
$ExcludedRegistrySections = "S-1-5-18", "S-1-5-19", "S-1-5-20"
# Указание доменной группы пользователей терминала
$DomainGroup = [ADSI]"WinNT://domain.com/RDS_Application_Users,group"
# Указание локальной группы удаленных пользователей
$LocalGroup = [ADSI]"WinNT://./Remote Desktop Users,group"

# -------------------------------------------------------------------------------------
# Получение пути к скрипту
$ScriptFolder = $MyInvocation.MyCommand.Path.SubString(0,($MyInvocation.MyCommand.Path.Length – `
 $MyInvocation.MyCommand.Name.Length))
# Формирование пути к лог-файлу
$LogFile = $ScriptFolder + `
 $MyInvocation.MyCommand.Name.SubString(0,($MyInvocation.MyCommand.Name.Length – 4)) + ".log"
# Создание лог-файла
Out-File -FilePath $LogFile
# Имя операционной системы
$OSName = (Get-WmiObject -class Win32_OperatingSystem).Caption

# Проверка наличия вспомогательных файлов и их распаковка
$ScriptTools = $ScriptFolder + `
 $MyInvocation.MyCommand.Name.SubString(0,($MyInvocation.MyCommand.Name.Length – 4)) + "\"
$ScriptToolsArchive = $ScriptFolder + $MyInvocation.MyCommand.Name.SubString(0, `
 ($MyInvocation.MyCommand.Name.Length - 4)) + ".zip"
If (Test-Path $ScriptToolsArchive -PathType Leaf) {
 $LogMessage = get-date -uformat "%d.%m.%Y %H:%M:%S"
 $LogMessage += "`t"
 $LogMessage += "Script tools are found in " + $ScriptToolsArchive + " archive.`r`n"
 $LogMessage += get-date -uformat "%d.%m.%Y %H:%M:%S"
 $LogMessage += "`t"
 $LogMessage += "Extracting the archive into " + $ScriptTools +"."
 $LogMessage | Out-File -FilePath $LogFile -Append

 New-Item -Path $ScriptTools -ItemType directory -Force
 $Shell = New-Object -com shell.application
 $ArchiveItem = $shell.NameSpace($ScriptToolsArchive)
 ForEach($Item in $ArchiveItem.items()) {
   $Shell.Namespace($ScriptTools).copyhere($Item, 0x14)
 }

 If (Test-Path "C:\Kits\7-Zip\7za.exe" -PathType Leaf) {
   [String]$CmdLine = "C:\Kits\7-Zip\7za.exe"
   [Array]$CmdLineArg = 'x', """$ScriptToolsArchive""",  "-o""$ScriptTools""", "-y"
   # Оператор & (ampersand) указывает, то необходимо выполнить внешнюю команду, указанную после него
   # подробнее тут https://technet.microsoft.com/en-us/library/ee176880.aspx
   $LogMessage = &$CmdLine $CmdLineArg
   $LogMessage += "`r`n"
   $LogMessage | Out-File -FilePath $LogFile -Append
 } else {
   $LogMessage = get-date -uformat "%d.%m.%Y %H:%M:%S"
   $LogMessage += "`t"
   $LogMessage += "C:\Kits\7-Zip\7za.exe is not found. Script execution can't be continue."
   $LogMessage | Out-File -FilePath $LogFile -Append
   Exit
 }
}  

Function Get-ComputerSessions {
 Param(
   [CmdletBinding()]
   [Parameter(ValueFromPipeline=$true,ValueFromPipelineByPropertyName=$true)]
   [String]$Computer
 )
 Process {
   $Report = quser /server:$Computer | Select-Object -Skip 1 | ForEach-Object {
     $CurrentLine = $_.Trim() -Replace '\s+',' ' -Split '\s'
     $HashProps = @{
       UserName = $CurrentLine[0]
       ComputerName = $Computer
     }
     # If session is disconnected, different fields will be selected
     If ($CurrentLine[2] -eq 'Disc') {
       $HashProps.SessionName = $null
       $HashProps.Id = $CurrentLine[1]
       $HashProps.State = $CurrentLine[2]
       $HashProps.IdleTime = $CurrentLine[3]
       $HashProps.LogonTime = $CurrentLine[4..6] -join ' '
     } Else {
       $HashProps.SessionName = $CurrentLine[1]
       $HashProps.Id = $CurrentLine[2]
       $HashProps.State = $CurrentLine[3]
       $HashProps.IdleTime = $CurrentLine[4]
       $HashProps.LogonTime = $CurrentLine[5..7] -join ' '
     }
     New-Object -TypeName PSCustomObject -Property $HashProps |
       Select-Object -Property UserName,ComputerName,SessionName,Id,State,IdleTime,LogonTime
   }
   Return $Report
 }
}

Function Process-DeleteProfileFolders {
 Param(
   [CmdletBinding()]
   [Parameter(ValueFromPipeline=$true,ValueFromPipelineByPropertyName=$true,Mandatory=$true)]
   [String]$ProfileFolder,
   [Parameter(ValueFromPipeline=$true,ValueFromPipelineByPropertyName=$true,Mandatory=$true)]
   [String[]]$ExcludedProfiles,
   [Parameter(ValueFromPipeline=$true,ValueFromPipelineByPropertyName=$true,Mandatory=$false)]
   [String]$LogFile
 )
 Process {
   $Report = $Nothing
   # Получение объектов из папки профилей
   $SubDirs= Get-ChildItem $ProfileFolder -Force
   # Обработка объектов из папки профилей
   ForEach ($Dir in $SubDirs) {
     $LogMessage = $Nothing
     # Проверка, что объект существуюет и это папка
     If (Test-Path $Dir.FullName -PathType Container) {
       # Проверка, что профиль - это не служебная папка
       $NotDeleteFlag = $False
       ForEach ($ExcludedProfile in $ExcludedProfiles) {
         If ($Dir.Name -eq $ExcludedProfile) {
           $NotDeleteFlag = $True
         }
       }
       # Удаление профиля
       If ($NotDeleteFlag -eq $False) {
         # Формирование события для лога
         $LogMessage = get-date -uformat "%d.%m.%Y %H:%M:%S"
         $LogMessage += "`t"
         $LogMessage += $Dir.FullName + " is deleted."
         # Удаление папки
         $CmdLine = "CMD /c RD /S /Q """ + $Dir.FullName + """"
         Invoke-Expression -command $CmdLine
       } Else {
         # Формирование события для лога
         $LogMessage = get-date -uformat "%d.%m.%Y %H:%M:%S"
         $LogMessage += "`t"
         $LogMessage += $Dir.FullName + " is skipped as a service folder."
       }
     } Else {
       # Формирование события для лога
       $LogMessage = get-date -uformat "%d.%m.%Y %H:%M:%S"
       $LogMessage += "`t"
       $LogMessage += $Dir.FullName + " is skipped as a file."
     }
     If ($LogFile -ne $Nothing) {
       $LogMessage | Out-File -FilePath $LogFile -Append
     } else {
       Write-Output $LogMessage
     }
   }
 }
}

Function Process-DeleteProfileRegistry {
 Param(
   [CmdletBinding()]
   [Parameter(ValueFromPipeline=$true,ValueFromPipelineByPropertyName=$true,Mandatory=$true)]
   [String]$ProfileRegistrySection,
   [Parameter(ValueFromPipeline=$true,ValueFromPipelineByPropertyName=$true,Mandatory=$true)]
   [String[]]$ExcludedRegistrySections,
   [Parameter(ValueFromPipeline=$true,ValueFromPipelineByPropertyName=$true,Mandatory=$false)]
   [String]$LogFile
 )
 Process {
   $Report = $Nothing
   # Преобразование пути реестра в Powershell путь
   $ProfileRegistrySection = $ProfileRegistrySection.Replace("HKEY_LOCAL_MACHINE","HKLM:")
   # Получение объектов из секции профилей реестра
   $Sections= Get-ChildItem $ProfileRegistrySection | Select Name
   ForEach ($Section in $Sections) {
     $LogMessage = $Nothing
     # Проверка, что профиль - это не служебная папка
     $NotDeleteFlag = $False
     ForEach ($ExcludedRegistrySection in $ExcludedRegistrySections) {
       If ($Section.Name.Contains($ExcludedRegistrySection)) {
         $NotDeleteFlag = $True
       }
     }
     # Удаление профиля из реестра
     If ($NotDeleteFlag -eq $False) {
       # Формирование события для лога
       $LogMessage = get-date -uformat "%d.%m.%Y %H:%M:%S"
       $LogMessage += "`t"
       $LogMessage += $Section.Name + " is deleted."
       # Удаление профиля
       Remove-Item -Path $Section -Force -Recurse
     } Else {
       # Формирование события для лога
       $LogMessage = get-date -uformat "%d.%m.%Y %H:%M:%S"
       $LogMessage += "`t"
       $LogMessage += $Section.Name + " is skipped as a service profile."
     }
     If ($LogFile -ne $Nothing) {
       $LogMessage | Out-File -FilePath $LogFile -Append
     } Else {
       Write-Output $LogMessage
     }
   }
 }
}

Function Process-ResetPrintSpooler {
 Param(
   [CmdletBinding()]
   [Parameter(ValueFromPipeline=$true,ValueFromPipelineByPropertyName=$true,Mandatory=$false)]
   [String]$LogFile
 )
 Process {
   $LogMessage = $Nothing
   $Error.Clear()
   # Остановка сервиса
   Try {
     Stop-Service -Name "Spooler" -Force
     $LogMessage += (get-date -uformat "%d.%m.%Y %H:%M:%S") + "`t" + `
       "Print Spooler service is stopped." + "`r`n"
   } Catch {
     $LogMessage += (get-date -uformat "%d.%m.%Y %H:%M:%S") + "`t" + $Error + "`r`n"
   }
   # Очистка заданий
   Try {
       Remove-Item -Path "C:\Windows\System32\spool\PRINTERS\*.*" -Force
       $LogMessage += (get-date -uformat "%d.%m.%Y %H:%M:%S") + "`t" + "Print queue is purged." + "`r`n"
   }
   Catch {
       $LogMessage += (get-date -uformat "%d.%m.%Y %H:%M:%S") + "`t" + $Error + "`r`n"
   }
   # Очистка неактивных портов принтеров терминального сервера
   $CmdLine = $ScriptTools + "DeleteInactivePortSilently.exe"
   $LogMessage += (get-date -uformat "%d.%m.%Y %H:%M:%S") + "`t" + `
     "Delete Inactive TS Ports by DeleteInactivePortSilently.exe command.`r`n" + "`r`n"
   $LogMessage += Invoke-Expression -command $CmdLine
   $LogMessage += "`r`n" + "`r`n"
   # Запуск сервиса
   Try {
     Start-Service -Name "Spooler"
     $LogMessage += (get-date -uformat "%d.%m.%Y %H:%M:%S") + "`t" + "Print Spooler service is started."
   } Catch {
     $LogMessage += (get-date -uformat "%d.%m.%Y %H:%M:%S") + "`t" + $Error
   }
   # Запись события в лог-файл
   If ($LogFile -ne $Nothing) {
     $LogMessage | Out-File -FilePath $LogFile -Append
   } Else {
     Write-Output $LogMessage
   }
 }
}

# Запрет на вход пользователей - удаление пользовательской доменной группы из локальной группы
# удаленного доступа
$LocalGroup.Remove($DomainGroup.Path)
$LogMessage = get-date -uformat "%d.%m.%Y %H:%M:%S"
$LogMessage += "`t"
$LogMessage += "The server is closed for users. " + $DomainGroup.name + " is removed from " + `
 $LocalGroup.Name + "."
$LogMessage | Out-File -FilePath $LogFile -Append

# Закрытие сессий текущих пользователей
$Sessions = Get-ComputerSessions -Computer localhost
ForEach ($Session in $Sessions) {
 logoff $Session.Id /server:localhost
 $LogMessage = get-date -uformat "%d.%m.%Y %H:%M:%S"
 $LogMessage += "`t"
 $LogMessage += $Session.UserName + " (" + $Session.Id + ") forced to logoff from the server."
 $LogMessage | Out-File -FilePath $LogFile -Append
}

# Ожидание закрытия сессий - 30 секунд
$LocalGroup.Add($DomainGroup.Path)
$LogMessage = get-date -uformat "%d.%m.%Y %H:%M:%S"
$LogMessage += "`t"
$LogMessage += "Waiting 30 seconds for users logoff."
$LogMessage | Out-File -FilePath $LogFile -Append
Start-Sleep -s 30

# Проверка текущих сессий на сервере
$Sessions = Get-ComputerSessions -Computer localhost
If ($Sessions.Count -gt 0) {
 $LogMessage = get-date -uformat "%d.%m.%Y %H:%M:%S"
 $LogMessage += "`t"
 $LogMessage += "Currently logged on users number is " + $Sessions.Count + "."
 $LogMessage | Out-File -FilePath $LogFile -Append

 $Sessions | FT ID, UserName, State, IdleTime, LogonTime | Out-File -FilePath $LogFile -Append

 $LogMessage = get-date -uformat "%d.%m.%Y %H:%M:%S"
 $LogMessage += "`t"
 $LogMessage += "The script processing is terminated, please reboot the server to release sessions."
 $LogMessage | Out-File -FilePath $LogFile -Append
} Else {

 $LogMessage = get-date -uformat "%d.%m.%Y %H:%M:%S"
 $LogMessage += "`t"
 $LogMessage += "Currently logged on users number is 0."
 $LogMessage | Out-File -FilePath $LogFile -Append

 # Удаление папок испорченных профилей (только для Windows 2008)
 If ($OSName.Contains("2008")) {
   Process-DeleteProfileFolders -ProfileFolder $ProfileFolder -ExcludedProfiles $ExcludedProfiles `
 -LogFile $LogFile
 } Else {
   $LogMessage = get-date -uformat "%d.%m.%Y %H:%M:%S"
   $LogMessage += "`t"
   $LogMessage += "The server operating system is not match for cleaning profile folders."
   $LogMessage | Out-File -FilePath $LogFile -Append
 }

 # Удаление ключей реестра испорченных профилей
 Process-DeleteProfileRegistry -ProfileRegistrySection $ProfileRegistrySection -ExcludedRegistrySections `
 $ExcludedRegistrySections -LogFile $LogFile

 # Удаление из планировщика задач заданий на оптимизацию кеша профилей пользователей
 $LocalGroup.Add($DomainGroup.Path)
 $LogMessage = get-date -uformat "%d.%m.%Y %H:%M:%S"
 $LogMessage += "`t"
 $LogMessage += "Delete ""Optimize Start Menu Cache Files"" scheduled tasks."
 $LogMessage | Out-File -FilePath $LogFile -Append
 Get-ScheduledTask | where {$_.taskname -like "Optimize Start Menu Cache Files*"} | `
 Unregister-ScheduledTask -Confirm:$false

 # Очистка очереди печати
 Process-ResetPrintSpooler -LogFile $LogFile

}

# Разрешение на вход пользователей - добавление пользовательской доменной группы в локальную
# группу удаленного доступа
$LocalGroup.Add($DomainGroup.Path)
$LogMessage = get-date -uformat "%d.%m.%Y %H:%M:%S"
$LogMessage += "`t"
$LogMessage += "The server is opened for users. " + $DomainGroup.name + " is added to " + `
 $LocalGroup.Name + "."
$LogMessage | Out-File -FilePath $LogFile -Append

# Удаление вспомогательных файлов
$LogMessage = get-date -uformat "%d.%m.%Y %H:%M:%S"
$LogMessage += "`t"
$LogMessage += "$ScriptTools auxiliary folder is deteted."
$CmdLine = "CMD /c RD /S /Q """ + $ScriptTools + """"
Invoke-Expression -command $CmdLine

Для установки этого скрипта на RDS сервер необходимо:

0. Убедиться, что сервер - это RDS-сервер с перемещаемыми пользовательскими профилями.

1. Создать папку C:\Scripts на сервере.

2. Скопировать в созданную папку файлы из архива, приложенного к этой статье.

3. В PowerShell-скрипте отредактировать раздел VARIABLES в соответствии со своими нуждами.

4. Разрешить на сервере запуск неподписанных скриптов с помощью команды

Set-ExecutionPolicy -ExecutionPolicy "RemoteSigned"

5. Установить в расписание сервера CMD-скрипт запуска Powershell-скрипта, который находится в папке C:\Scripts, от имени системы с повышением прав

powershell.exe "%~DP0Process-RDSServerMaintainance.ps1"

6. Выполнить скрипт вне расписания и проверить результат работы и Log-файл.

При небольших модификациях этот скрипт можно использовать для чистки Citrix XenApp серверов.

Архив скрипта и вспомогательных файлов можно скачать тут.

Виталий Бочкарев
Вложения