As the number of Windows Terminalservers … excuse me … Remote Desktop Session Hosts (RDSH) grew more and more, so did the time consumed for keeping them up to date every month.
Though SCCM brings some basic functionality to update servers in groups, I decided to use PowerShell only. This way, the whole process is performed in one script with almost no limitations in length and complexity which makes it pretty flexible and easy to extend. In our case I installed it as a scheduled task on a scripting server.
The script is provided as-is with no guarantee at all. If you’re not sure what you’re doing, just don’t do it. Or at least try it on some testing systems first. š
Detailed steps
- Read servers to be serviced from a CSV file
- Set systems into drain mode so that no new users can connect
- Wait at least 10 hours until active users have disconnected
- Activate maintenance mode in SCOM
- Trigger the installation of new Windows Updates
- Wait 2 hours for the update installation to complete
- Make sure there are no active user sessions remaining
- Reboot the servers
- Check if all systems are back online
- End the SCOM maintenance window
- End drain mode so that new users can connect again
- Set Update Cycle +1 to make sure the next group will be services tomorrow
- Send an e-mail with a detailed status report
Requirements to use the script
- RD session hosts based on Windows Server 2016 and above
- A comma-separated CSV containing the hostnames of the RDSH grouped into up to 5 update cycles (less is always possible, more requires some decent editings to the script) in the below format:
Cycle1,Cycle2,Cycle3,Cycle4,Cycle5
RDSH01.contoso.com,RDSH03.contoso.com,RDSH05.contoso.com,RDSH06.contoso.com,
RDSH02.contoso.com,RDSH04.contoso.com,,, - An active RD deployment with at least one RD connection broker (though this script is designed to work with high availabiliy deployments)
- A domain user with the required privileges (configure “New connections allowed” in the RD deployment, reboot servers, add maintenance windows to SCOM, send e-Mails)
- A relaying mailserver (this is not really required but the whole thing is much more fun when you get to work and see the work that has been done for you)
Adjustments
- The maintenance window used to install updates and reboot servers starts at 2:00 am and should last for about 3 hours. You can change it in the main script (
Sleep-until "02:00:00 am"
). - If you want to see what this script does, just provide a CSV with testing servers and add the “-testing” switch when running the script. It will go into a fast forward where all waiting phases are cut short to 15 minutes (instead of waiting for the night for example). The switch changes only the timing, it is NOT comparable to a WhatIf-Switch. The servers WILL for example be rebooted!
The script
<# .SYNOPSIS Set systems in drain mode, install software updates an reboot .DESCRIPTION Use this script to set specified systems into drain mode, wait for user sessions to be closed, install software updates, reboot computers and set back active. .PARAMETER Connectionbroker FQDN of a remote desktop connection broker. ANY connection broker in the correct deployment is OK, the active one in a HA deployment gets detected automatically. .PARAMETER UpdateCycle Number of the update cycle (wave) to be performed. Allowed to be between 1 and 5. If empty, the script tries to determine the number from a script, if that fails it uses 1. .PARAMETER MailFrom Sender e-mail addresss for the status report e-mail. .PARAMETER MailTo Recipient e-mail addresss for the status report e-mail. .PARAMETER MailServer FQDN of the SMTP server to relay the status report e-mail. .PARAMETER SCOMServer FQDN of the SCOM server which will be connected to set maintenance windows. .PARAMETER CSVPath Path to the CSV file which contains the FQDNs of all servers to be updates grouped into update cycles. .PARAMETER sLogPath Path to the logfile into which output is to be written. .PARAMETER ActiveCyclePath Path to the file containing the number of the Update Cycle to be performed (reads the cycle to perform, writes an increment when completed). .PARAMETER Testing If testing is true, the wait-times are minimized. This is NOT a WhatiIf, all actions will be performed! .NOTES Name: Update-RDSessionHosts.ps1 Author: Peter Stork Created: 2020-12-10 Version: 1.0 History: 2020-12-21: Added ActiveCyclePath to simplify scheduled task management and testing-switch. 2021-01-18: Added connectivity test for PowerShell remote ports. .EXAMPLE Update-RDSessionHosts.ps1 -ConnectionBroker = "connectionbroker01.contoso.com" -UpdateCycle = 1 -MailFrom "TSmaintenance@contoso.com" -MailTo "masterchief@contoso.com" -MailServer "exchange.contoso.com" -ScomServer = "scom01.contoso.com" -CsvPath = "C:\Scripts\UpdateCycles.csv" -SLogPath = "C:\Scripts\Update-RDSessionhosts.log" #> ####################### Variables ######################### param( [string]$connectionbroker = "connectionbroker01.contoso.com", [int]$updateCycle, [string]$mailFrom = "TSmaintenance@contoso.com", [string]$mailTo = "masterchief@contoso.com", [string]$mailServer = "exchange.contoso.com", [string]$scomServer = "scom01.contoso.com", [string]$csvPath = "C:\ScheduledTasks\ServerMaintenance\UpdateCycles.csv", [string]$slogPath = "C:\ScheduledTasks\ServerMaintenance\Update-RDSessionhosts.log", [string]$activeCyclePath = "C:\ScheduledTasks\ServerMaintenance\ActiveCycle.txt", [switch]$testing ) #Get the active connection broker (in case of working in a HA environment) $activeCb = (Get-RDConnectionBrokerHighAvailability -ConnectionBroker $connectionbroker).ActiveManagementServer # Create arrays $rdServers = @() $Script:ActiveSessions = @() # Declare script-wide variables $Script:LogOutput = '' # Define update cycle. If parameter has not been set at script execution, the ActiveCycle is taken from the file (incremented on each execution). if ($updateCycle -eq 0) { $updateCycle = Get-Content $activeCyclePath } ####################### Functions ######################### function Log-Output { <# .SYNOPSIS Redirect output to console, logfile and e-mail .DESCRIPTION Use this script to redirect output to 3 targets (console, logfile, email) at once. .PARAMETER message The text to be postet in all 3 outputs. .PARAMETER el If set, creates an empty line before the log entry. .PARAMETER logPath Path to the target logfile. .NOTES Name: Log-Output Author: Peter Stork Created: 2020-12-03 Version: 0.1 History: 2020-12-04: Added -el switch for better overview .EXAMPLE Log-Output -message "Status: SNAFU" -el -logPath C:\Temp\mylogfile.txt #> param ( [Parameter(Mandatory=$true, ValueFromPipeline=$true, Position = 1)][string]$message, [Parameter(Mandatory=$false, Position = 2)][switch]$el, [Parameter(Mandatory=$false, Position = 3)][string]$logPath = $slogPath ) # Create an empty line upfront if -el is set. if ($el) { Out-File -FilePath $logPath -InputObject (".`r`n" + (Get-Date).ToString() + " " + $message) -Append Write-Host ("`r`n" + (Get-Date).ToString() + " " + $message) $Script:logOutput += ("</br>" + (Get-Date).ToString() + " " + $message + "</br>") } # Standard logging when -el not set else { Out-File -FilePath $logPath -InputObject ((Get-Date).ToString() + " " + $message) -Append Write-Host ((Get-Date).ToString() + " " + $message) $Script:logOutput += ((Get-Date).ToString() + " " + $message + "</br>") } } function Specify-Servers { <# .SYNOPSIS Load servers from a CSV into specified update cycles .DESCRIPTION Use this script transform a CSV into arrays for further use in the update-script. .PARAMETER csvPath Path to the csv file containing servers per cycle. .PARAMETER updateCycle Number of the update cycle to activate. Must be between 1 and 5 .NOTES Name: Specify-Servers Author: Peter Stork Created: 2020-12-03 Version: 0.1 History: - .EXAMPLE Specify-Servers -csvPath "C:\Admin\Scripts\UpdateCycles.csv" -updateCycle 1 #> param ( [parameter(Mandatory=$true)][string]$csvPath, [parameter(Mandatory=$true)][int]$updateCycle ) #Initialize lokal variables as arrays $rds1 = @() $rds2 = @() $rds3 = @() $rds4 = @() $rds5 = @() # Import Update Cycles from CSV file, leave out blank entries Import-Csv $csvPath | ForEach-Object { if($_.Cycle1 -ne '') {$rds1 += $_.Cycle1} if($_.Cycle2 -ne '') {$rds2 += $_.Cycle2} if($_.Cycle3 -ne '') {$rds3 += $_.Cycle3} if($_.Cycle4 -ne '') {$rds4 += $_.Cycle4} if($_.Cycle5 -ne '') {$rds5 += $_.Cycle5} } # Activate servers from the chosen cycle. switch($updateCycle) { 1 {$rdServers = $rds1; Log-Output ("Active Update Cycle: " + $updateCycle)} 2 {$rdServers = $rds2; Log-Output ("Active Update Cycle: " + $updateCycle)} 3 {$rdServers = $rds3; Log-Output ("Active Update Cycle: " + $updateCycle)} 4 {$rdServers = $rds4; Log-Output ("Active Update Cycle: " + $updateCycle)} 5 {$rdServers = $rds5; Log-Output ("Active Update Cycle: " + $updateCycle)} default {Clear-Variable RDServers; Log-Output "No valid cycle chosen. Please enter a value for Updatecycle between 1 and 5."} } Log-Output ("Servers to be updated: " + $rdServers) Return($rdServers) } function Set-Drainmode { <# .SYNOPSIS Sets Remote Desktop Session Hosts (RDSH) in drain mode. .DESCRIPTION Use this script to set specified systems into drain mode, which means that no new RD sessions are accepted and the session count will slowly decrease to 0. It is recommended to have a GPO in place that automatically disconnects sessions based on time limits. .PARAMETER RDServers String-Array containing a list of servers on which the drain mode is to be altered. .PARAMETER Drainmode Choose to activate ($true) or disable ($false) drain mode on the defined servers. .NOTES Name: Set-Drainmode Author: Peter Stork Created: 2020-12-02 Version: 0.1 History: - .EXAMPLE Activate Drain Mode on two servers Set-Drainmode -RDServers @("sessionhost01.contoso.com,sessionhost02.contoso.com" -Drainmode $true #> param( [Parameter(Mandatory=$true, Position = 1)][string[]]$rdServers, [Parameter(Mandatory=$true, Position = 2)][bool]$drainMode ) foreach ($rds in $rdServers) { if($drainMode -eq $true) { Log-Output ("Turning ON drain mode for RDSH " + $rds) Set-RDSessionHost -SessionHost $rds -ConnectionBroker $activeCB -NewConnectionAllowed No } else { Log-Output ("Turning OFF drain mode for RDSH " + $rds) Set-RDSessionHost -SessionHost $rds -ConnectionBroker $activeCB -NewConnectionAllowed Yes } } } function Sleep-Until($future_time) # Sleeps until a specified time. Only works within one day. # Original Source: https://gallery.technet.microsoft.com/scriptcenter/Sleeppause-until-a-given-5c6bc7fa { if ([String]$future_time -as [DateTime]) { if ($(get-date $future_time) -gt $(get-date)) { $sec = [system.math]::ceiling($($(get-date $future_time) - $(get-date)).totalseconds) start-sleep -seconds $sec } else { Log-Output "You must specify a date/time in the future" return } } else { Log-Output "Incorrect date/time format" } } function Trigger-AvailableSupInstall # Triggers target computers to run "Install All (Updates)" in the SCCM Software Center. # Original Source: https://timmyit.com/2016/08/01/sccm-and-powershell-force-install-of-software-updates-thats-available-on-client-through-wmi/ { Param( [String][Parameter(Mandatory=$true, Position=1)] $computername, [String][Parameter(Mandatory=$true, Position=2)] $supName) Begin { $appEvalState0 = "0" $appEvalState1 = "1" $applicationClass = [WmiClass]"root\ccm\clientSDK:CCM_SoftwareUpdatesManager" } Process { If ($supName -Like "All" -or $supName -like "all") { Foreach ($computer in $computername) { $application = (Get-WmiObject -Namespace "root\ccm\clientSDK" -Class CCM_SoftwareUpdate -ComputerName $computer | Where-Object { $_.EvaluationState -like "*$($appEvalState0)*" -or $_.EvaluationState -like "*$($AppEvalState1)*"}) Invoke-WmiMethod -Class CCM_SoftwareUpdatesManager -Name InstallUpdates -ArgumentList (,$application) -Namespace root\ccm\clientsdk -ComputerName $computer } } Else { Foreach ($computer in $computername) { $application = (Get-WmiObject -Namespace "root\ccm\clientSDK" -Class CCM_SoftwareUpdate -ComputerName $computer | Where-Object { $_.EvaluationState -like "*$($appEvalState)*" -and $_.Name -like "*$($SupName)*"}) Invoke-WmiMethod -Class CCM_SoftwareUpdatesManager -Name InstallUpdates -ArgumentList (,$application) -Namespace root\ccm\clientsdk -ComputerName $computer } } } End {} } function Get-ActiveSessions { <# .SYNOPSIS Gets the active sessions on specified RD Session Hosts .DESCRIPTION Use this script to get both number of and details on active sessions on specified RD Session Hosts and display them .PARAMETER ActiveCB String containing the active management server (active[!] RD connection broker). .PARAMETER RDServers String-Array containing the Remote Desktop Session Hosts to be queried for sessions. .NOTES Name: Get-ActiveSessions Author: Peter Stork Created: 2020-12-02 Version: 0.1 History: - .EXAMPLE Get active sessions for two session hosts: Get-ActiveSessions -ActiveCB RDCB01.contoso.com -RDServers @("RDSH01.contoso.com", "RDSH02.contoso.com") #> param( [Parameter(Mandatory=$true, Position = 1)][string]$activeCb, [Parameter(Mandatory=$true, Position = 2)][string[]]$rdServers ) # Get the active session details from the active connection broker $Script:ActiveSessions = @(Get-RDUserSession -ConnectionBroker $activeCB | select CollectionName,HostServer,Username,CreateTime,DisconnectTime | where {$rdServers -like $_.HostServer}) # Show the results (count + details) Log-Output ("Active Session Count: " + $Script:ActiveSessions.Count) Log-Output "Active Session Details can be found below in the log and at the end of the email report." Write-Host ($Script:ActiveSessions | ft | Out-String) # Prepare information to be sent via e-mail (count + details) $Script:Mail_ActiveSessions = ` "************************************************************</br>" ` + "There are " + $Script:ActiveSessions.Count + " active sessions. Details are shown below: </br>" ` + "************************************************************</br>.</br>.</br>." ` + ($Script:ActiveSessions | ConvertTo-Html -Fragment -Property CollectionName,HostServer,Username,CreateTime,DisconnectTime | Out-String) } function Set-MaintenanceMode { <# .SYNOPSIS Configures the SCOM maintenance mode. .DESCRIPTION Use this script to configure (activate with specified duration or deactivate) the SCOM maintenance mode for a specified group of servers. .PARAMETER servers String-Array containing the servers to be put into or out of maintenance mode. .PARAMETER scomServer The FQDN of the SCOM server which will be used to configure the maintenance mode. .PARAMETER maintenanceMode $true if you want to activate MM, $false if you want to deactivate it. .PARAMETER minutes Duration of the maintenance window to be set in minutes. Must be 5 or greater. .NOTES Name: Set-MaintenanceMode Author: Peter Stork Created: 2020-12-03 Version: 0.1 History: 2020-12-04: Added handling for pre-existing MM entriesChanged logging to use Log-Output function .EXAMPLE Activate maintenance mode Set-MaintenanceMode -servers @("RDSH01.contoso.com", "RDSH02.contoso.com") -scomServer "scom01.contoso.com" -maintenanceMode $true -minutes 30 Deactivate maintenance mode Set-MaintenanceMode -servers @("RDSH01.contoso.com", "RDSH02.contoso.com") -scomServer "scom01.contoso.com" -maintenanceMode $false #> param( [Parameter(Mandatory=$true)][string[]]$servers, [Parameter(Mandatory=$true)][string]$scomServer, [Parameter(Mandatory=$true)][bool]$maintenanceMode, [int]$minutes ) # Make sure all required information exists if ($maintenanceMode -eq $true -and $minutes -LT 5) { Log-Output "To create a new maintance window, use the -minutes switch to enter a duration of at least 5 minutes." } else { # Perform the below scriptblock on the SCOM Server which holds and alters the information about maintenance windows $result = Invoke-Command -ComputerName $scomServer -ScriptBlock { Import-Module OperationsManager $return = @() $instanceclass= Get-SCOMClass -Name Microsoft.Windows.Computer # "$Using:" forwards local variables into the remotely invoked session foreach($server in $Using:servers) { # $instance contains the computer object from SCOM that will be altered $instance = Get-SCOMClassInstance -Class $instanceClass | Where-Object {$_.DisplayName -match $server} # If Maintenance Mode (MM) needs to be activated, get the server object ($instance) and create a new MM for it with the actual time plus duration ($minutes) if ($Using:maintenanceMode -eq $true) { # Check if there already is an MM entry. If so, remove it. if ($instance.InMaintenanceMode -eq $true) { $mmEntry = Get-SCOMMaintenanceMode -Instance $instance $endTime = (Get-Date) $return += ("Removing old maintenance mode entry for " + $instance + " - Comment: " + $mmEntry) Set-SCOMMaintenanceMode -MaintenanceModeEntry $MMEntry -EndTime $endTime -Comment "Preparing new maintenance windows for update deployment." Start-Sleep 3 } # Create a new maintenance mode entry $endTime = ((Get-Date).AddMinutes($Using:minutes)) $return += ("Activating maintenance mode for " + $instance + " - Endtime: " + $endTime) Start-SCOMMaintenanceMode -Instance $instance -EndTime $endTime -Reason "PlannedOther" -Comment "Windows Update Deployment via automation script." } # If MM needs to be deactivated, get existing MM entries for the selected server object ($instance) and set its endTime to "Now" to let it end instantly else { $mmEntry = Get-SCOMMaintenanceMode -Instance $instance $endTime = (Get-Date) $return += ("Deactivating maintenance mode for " + $instance + " - MMEntry: " + $mmEntry) Set-SCOMMaintenanceMode -MaintenanceModeEntry $MMEntry -EndTime $endTime -Comment "Windows Update Deployment ended." } } $return } } foreach ($r in $result) { Log-Output $r } } ############################### Main script ############################## # Start a new chapter in the logfile Log-Output "****************************** Starting new script execution ******************************" # Mode-Info if ($testing) { Log-Output "***** Running in TESTING mode. Waiting times will be shortened to about 15 minutes each. Stop the script this is not what you want." -el Start-Sleep 10 } else { Log-Output "***** Running in PRODUCTION mode. Original timing will be used." -el } # Import information on servers which shall be updated Log-Output "*** Main script: Importing server information." -el $rdServers = Specify-Servers -csvPath $csvPath -updateCycle $updateCycle # Test connectivity Log-Output "*** Main script: Checking remote connectivity." -el $offlineCount = 0 $offlineServers ="" foreach($rds in $rdServers) { if (Test-NetConnection -ComputerName $rds -Port 5985 -InformationLevel Quiet) { Log-Output ("Successfully connected to " + $rds + " on Port 5985") } else { Log-Output ("FAILED to connect to " + $rds + " on Port 5985") $offlineCount = $offlineCount + 1 $offlineServers = $offlineServers + $rds + "`r`n" } } if($offlineCount -ge 1) { Send-MailMessage -From $mailFrom -To $mailTo -Bodyashtml ("Hosts offline: </br>" + $offlineServers) -Subject "Update-RDSessionhosts - WARNING: Server(s) offline - Stopping" -SmtpServer $MailServer exit } else { Log-Output "All servers could be connected from remote. Continuing." } # Turn on drain mode on servers Log-Output "*** Main script: Turning on drain mode." -el Set-Drainmode -RDServers $rdServers -DrainMode $true # Wait until the end of the day to continue. Not yet possible to wait for the next day. Log-Output "*** Main script: Sleeping until the maintenance window begins." -el if ($testing) { # Shorter waiting times in test mode. start-sleep 900 } else { Sleep-Until "11:55:00 pm" Start-Sleep 900 Sleep-until "02:00:00 am" } # Set SCOM maintenance mode Log-Output "*** Main script: Activating SCOM maintenance mode." -el Set-MaintenanceMode -servers $rdServers -scomServer $scomServer -maintenanceMode $true -minutes 240 # Install updates Log-Output "*** Main script: Triggering update installation." -el foreach ($RDS in $rdServers) { Trigger-AvailableSupInstall -Computername $rds -SupName all } # Wait for 2 hours for the updates to complete Log-Output "*** Main script: Waiting for updates to complete." -el if ($testing) { # Shorter waiting times in test mode. start-sleep 900 } else { Start-Sleep 7200 } #Check number of active sessions on servers to make sure no active users will be disconnected Log-Output "*** Main script: Checking for active sessions." -el Get-ActiveSessions -ActiveCB $activeCB -RDS $rdServers if ($ActiveSessions.Count -ge 3) { Log-Output ($ActiveSessions.Count + " active sessions found on update servers. Stopping script execution.</br>.</br>.</br>.") Send-MailMessage -From $MailFrom -To $MailTo -Bodyashtml ("ATTENTION - ACTIVE SESSIONS FOUND - STOPPING</br>.</br>.</br>." + $logOutput + "</br>.</br>" + $Mail_ActiveSessions) -Subject "Update-RDSessionhosts - WARNING: Active sessions found, stopping" -SmtpServer $MailServer exit } else { Log-Output "Less than 4 active sessions found. Continuing." } # Reboot the servers Log-Output "*** Main script: Rebooting Servers." -el foreach ($rds in $rdServers) { Log-Output ("Sending forced reboot request to server " + $rds) Restart-Computer -ComputerName $rds -Force } # Wait 15 minutes to give servers enough time to reboot Log-Output "*** Main script: Waiting for servers to reboot." -el Start-Sleep 900 # Check if all servers are back online Log-Output "*** Main script: Checking if servers are back online." -el $offlineCount = 0 $offlineServers = '' foreach($rds in $rdServers) { if (Test-Connection -BufferSize 32 -Count 1 -ComputerName $rds -Quiet) { Log-Output ("The remote computer " + $rds + " is Online") } else { Log-Output ("The remote computer " + $rds + " is Offline") $offlineCount = $offlineCount + 1 $offlineServers = $offlineServers + $rds + "`r`n" } } if($offlineCount -ge 1) { Send-MailMessage -From $mailFrom -To $mailTo -Bodyashtml ("Hosts offline: </br>" + $offlineServers) -Subject "Update-RDSessionhosts - WARNING: Server(s) offline - Stopping" -SmtpServer $MailServer exit } else { Log-Output "All servers are online. Continuing." } # Set SCOM maintenance mode Log-Output "*** Main script: Deactivating SCOM maintenance mode." -el Set-MaintenanceMode -servers $rdServers -scomServer $scomServer -maintenanceMode $false # Switch drain modes on servers (turn DM off for servers which just installed updates, turn DM off for servers which will be working normal again) Log-Output "*** Main script: Turning off drain mode." -el Set-Drainmode -RDServers $rdServers -DrainMode $false # Increment ActiveCycle to prepare the next script execution. if ($updateCycle -le 4) { # If cycle less or equal 4, increment by one to get to the next cycle. Out-File -FilePath $activeCyclePath -InputObject ($updateCycle + 1) -Encoding ascii -Force Log-Output ("*** Main script: Writing ActiveCycle. Next cycle will be: " + ($updateCycle + 1)) -el } else { # If updateCycle is 5, set ActiveCycle to 1 to start a new cycle. Out-File -FilePath $activeCyclePath -InputObject 1 -Encoding ascii -Force Log-Output ("*** Main script: Writing ActiveCycle. Next cycle will be: 1") -el } # Send status report Log-Output "*** Main script: Sending status report. Goodbye." -el Send-MailMessage -From $mailFrom -To $mailTo -Bodyashtml ($LogOutput) -Subject "Update-RDSessionhosts - Info: Script execution successfully ended." -SmtpServer $MailServer
In case you have assigned the parameters at the beginning of the script to your needs, a scheduled task may look like this:
- Program/script:
C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe
- Add arguments (optional):
-ExecutionPolicy Unrestricted -WindowStyle Hidden -File "C:\ScheduledTasks\ServerMaintenance\Update-RDSessionHosts.ps1" -csvPath "C:\ScheduledTasks\ServerMaintenance\UpdateCycles_Prod.csv" -slogPath "C:\ScheduledTasks\ServerMaintenance\Update-RDSessionHosts_Prod.log"
Credits and sources:
- Christoph S.
- wingwaa (Technet Gallery)
- Timmy Andersson (TimmyIT.com)
- Patrick Squire (resdevops.com)