Posted On August 20, 2020

PowerShell: Manage Remote Desktop Servers by Logging Off Idle Sessions That Have Certain Inactive Programs

kimconnect 0 comments
blog.KimConnect.com >> Codes , Windows >> PowerShell: Manage Remote Desktop Servers by Logging Off Idle Sessions That Have Certain Inactive Programs
# manageRdsSessions.ps1
# Sequence of workflow:
# a. Gather active and disconnected sessions on all Remote Desktop Servers detected on the domain
# b. Determine whether there are duplicate sessions. That is defined as sessions with the same userId on 2+ RDS nodes
# c. Send out probes (PowerShell jobs) to follow certain executables identified by ProcessId, ComputerName, and SessionId
# d. Automatically logoff idle sessions that are duplicates with certain executables with inactive statuses
#
# Require: module PsTerminalServices with these nice methods
# Disconnect-TSSession -  Disconnects any connected user from the session.
# Get-TSCurrentSession - Provides information about the session in which the current process is running.
# Get-TSProcess - Gets a list of processes running in a specific session or in all sessions.
# Get-TSServers - Enumerates all the terminal servers in a given domain.
# Get-TSSession - Lists the sessions on a given terminal server.
# Send-TSMessage - Displays a message box in the specified session Id.
# Stop-TSProcess - Terminates the process running in a specific session or in all sessions.
# Stop-TSSession - Logs the session off, disconnecting any user that might be connected.
#
# Usage:
# There are two options: tell program to logoff duplicate sessions if certain program is no longer consuming CPU, or just disconnect duplicate sessions without checking any programs
# a. manageRdsSessions -sessionIdleMinutesThreshold 60
# b. manageRdsSessions -sessionIdleMinutesThreshold 60 -exeName word.exe -minutesToDetermineProgramInactive 5

# User defined variables
$sessionIdleMinutesThreshold=60
$exeName='dynamics.exe'
$minutesToDetermineProgramInactive=5
 
function manageRdsSessions{
    param(
        [int]$sessionIdleMinutesThreshold=15,
        [string]$exeName='chrome.exe',
        [int]$minutesToDetermineProgramInactive=5
        )
    function includePsTerminalServices{
        $ErrorActionPreference='stop'
        $psTerminalServicesZip='https://github.com/imseandavis/PSTerminalServices/archive/master.zip'
 
        $null=Import-Module PSTerminalServices -ea SilentlyContinue
        if(!(Get-Module -Name PSTerminalServices -ea SilentlyContinue)){
            [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
            try{
                #if(!('NuGet' -in (get-packageprovider).Name)){
                #    Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force
                #    Set-PSRepository -Name PSGallery -InstallationPolicy Trusted
                #    }
                # Download the module from github            
                $tempDir='c:\temp'
                $extractFolderName='psTerminalServices'
                $zipDownload=$tempDir+"\$extractFolderName.zip"
                if(!(test-path $tempDir)){mkdir $tempDir -Force}
                Import-Module BitsTransfer    
                try{
                    Start-BitsTransfer -Source $psTerminalServicesZip -Destination $zipDownload
                }catch{
                    Write-Error $_
                    write-warning "Cannot continue without PSTerminalServices Module.`r`nPlease install that manually."
                    }
                # unzip
                $unzipFolder="$tempDir\$extractFolderName"
                Expand-Archive -LiteralPath $zipDownload -DestinationPath $unzipFolder
                $msiFile=(gci $unzipFolder -Recurse|?{$_.FullName -match 'msi$'}).FullName
                # install msi silently
                $timeStamp = get-date -Format yyyyMMddTHHmmss
                $logFile = '{0}-{1}.log' -f "$tempDir\$extractFolderName",$timeStamp
                $MSIArguments = @(
                    "/i"
                    ('"{0}"' -f $msiFile)
                    "/qn"
                    "/norestart"
                    "/L*v"
                    $logFile
                )
                Start-Process "msiexec.exe" -ArgumentList $MSIArguments -Wait -NoNewWindow
                Import-Module PSTerminalServices
                return $true
            }catch{
                Write-Error $_
                return $false
                }
        }else{return $true}
    }
 
    function checkInactiveProcess{
        param(
            [parameter(position=0)][string]$targetMachine=$env:computername,
            [parameter(position=1)][int]$processId,
            [parameter(position=2)][int]$minutesToDetermineInactive=5,
            [parameter(position=3)][int]$maxMinutesToDetermineInactive=30
            )
        $checkCPU="(get-process -computerName $targetMachine -Id $processId -EA SilentlyContinue).CPU"
        write-host "Checking CPU Usage of process ID $processId..."
        $previousCpuConsumption=invoke-expression $checkCPU -ea SilentlyContinue
        $inactivityMarkerReached=$false
        $inactiveTimer=[System.Diagnostics.Stopwatch]::StartNew()
        Do {            
            $currentCpuConsumption=invoke-expression $checkCPU -ea SilentlyContinue
            write-host "Minute $([math]::round($inactiveTimer.Elapsed.totalminutes,2))`t: CPU cycles consumed = $currentCpuConsumption"
            $cpuConsumptionChanged=$currentCpuConsumption -gt $previousCpuConsumption            
            if(!$currentCpuConsumption){
                write-host "Process ID $processId is currently NOT running." -ForegroundColor Green
                return $true
            }elseif($cpuConsumptionChanged){                
                if($totalMinutes -ge $maxMinutesToDetermineInactive){return $true}
                write-host "Activites are being detected. Countdown is restarting from $minutesToDetermineInactive" -ForegroundColor Yellow
                $totalMinutes+=$inactiveTimer.Elapsed.totalminutes
                $inactiveTimer.restart()
            }elseif($inactiveTimer.Elapsed.totalminutes -ge $minutesToDetermineInactive){
                write-host "$minutesToDetermineInactive minutes have passed without activities." -ForegroundColor Green
                return $true
            }               
            sleep -Seconds 6
            $previousCpuConsumption=$currentCpuConsumption
        } while (!$inactivityMarkerReached)
    } 

    function getSessionsInfo([string[]]$computername=$env:computername){
        $results=@()
        function getDisconnectedSessionInfo($thisLine,$computer){
            # convert multiple spaces into single space and split pieces into array
            $sessionArray = $thisLine.Trim() -Replace '\s+',' ' -Split '\s'
            $properties = @{
                UserName = $sessionArray[0]
                ComputerName = $computer
                }
            $properties.SessionName = $null
            $properties.SessionId = $sessionArray[1]
            $properties.State = $sessionArray[2]                        
            $properties.IdleMinutes=.{
                    [string]$x=$sessionArray[3]
                    switch -regex ($x){
                        '\.' {0;break}                   
                        '\+' {$dayMinutes=.{[void]($x -match '^(\d+)\+');[int]($matches[1])*1440}
                                $hourMinutes=.{[void]($x -match '\+(.*)$');([TimeSpan]::Parse($matches[1])).totalMinutes}
                                $dayMinutes+$hourMinutes
                                break;
                                }               
                        '\:' {try{
                                ([TimeSpan]::Parse($x)).totalMinutes
                                }catch{
                                    "Invalid value: $x"
                                    }
                                break
                                }
                        default {$x}
                    }                                  
                }
            $properties.LogonTime = $sessionArray[4..6] -join ' '
            $result=New-Object -TypeName PSCustomObject -Property $properties
            return $result
            }
            
        function getActiveSessionInfo($thisLine,$computer){
            $sessionArray = $thisLine.Trim() -Replace '\s+',' ' -Split '\s'
            $properties = @{
                UserName = $sessionArray[0]
                ComputerName = $computer
                }
            $properties.SessionName = $sessionArray[1]
            $properties.SessionId = $sessionArray[2]
            $properties.State = $sessionArray[3]
            $properties.IdleMinutes=.{
                    $x=$sessionArray[4]
                    switch -regex ($x){
                        '\.' {0;break}                   
                        '\+' {  $dayMinutes=.{[void]($x -match '^(\d+)\+');[int]($matches[1])*1440}
                                $hourMinutes=.{[void]($x -match '\+(.*)$');([TimeSpan]::Parse($matches[1])).totalMinutes}
                                $dayMinutes+$hourMinutes
                                break;
                                }               
                        '\:' {  $timeFragments=[regex]::match($x,'(\d+)\:(\d+)')
                                [Int16]$hours=$timeFragments.Groups[1].Value
                                [Int16]$minutes=$timeFragments.Groups[2].Value
                                $minutes + ($hours * 60)
                                break
                                }
                        default {$x}
                    }
                }
            $properties.LogonTime = $sessionArray[5..($sessionArray.GetUpperBound(0))] -join ' '
            $result=New-Object -TypeName PSCustomObject -Property $properties
            return $result
            }
 
        foreach ($computer in $computername){
            try {
                # Perusing legacy commandlets as there are no PowerShell equivalents at this time
                $sessions=quser /server:$computer 2>&1 | Select-Object -Skip 1
                ForEach ($line in $sessions) {               
                    $fragments = $line.Trim() -Replace '\s+',' ' -Split '\s'
                    $disconnectedSession=$fragments[2] -eq 'Disc' -or $fragments[3] -eq 'Disc'
                    if ($disconnectedSession){
                        $result=getDisconnectedSessionInfo $line $computer
                    }else{
                        $result=getActiveSessionInfo $line $computer
                    }
                    if($result){$results+=$result}
                }
            }catch{
                $results+=New-Object -TypeName PSCustomObject -Property @{
                    ComputerName=$computer
                    Error=$_.Exception.Message
                } | Select-Object -Property UserName,ComputerName,SessionName,SessionId,State,IdleMinutes,LogonTime,Error|sort -Property UserName
            }
        }
        return $results
    }
  
    if(!(includePsTerminalServices)){
        Write-Warning "Cannot continue without module PsTerminalServices"
        return $false;
        }
    $allRdsNodes=(Get-TSServers).ServerName
    #$allSessions=$allRdsNodes|%{Get-TSSession -computername $_} # This does not provide idle duration; hence, I've written a custom function for that
    $allSessions=getSessionsInfo $allRdsNodes
    $duplicateSessions=.{$uniques=$allSessions.UserName | select –unique
                        $duplicates=(Compare-object –referenceobject $uniques –differenceobject $allSessions.UserName).InputObject
                        $allSessions|?{$_.Username -in $duplicates}
                        }
    $targetSessions=$duplicateSessions|?{[int]$_.IdleMinutes -ge $sessionIdleMinutesThreshold}
 
    if(!$exeName){    
        foreach($session in $targetSessions){
            Stop-TSSession -ComputerName $session.ComputerName -Id $session.SessionId -Force
            }
    }else{
        $targetNodes=$targetSessions.ComputerName|select -Unique
        $targetUsers=$targetSessions.UserName|select -Unique
        if(!$targetUsers){
            write-host "These are NO duplicate sessions over idling minutes threshold of $sessionIdleMinutesThreshold" -ForegroundColor Green
            return $true
        }else{
            write-host "User(s) $($targetUsers -join ', ') is/are having duplicate sessions over idling minutes threshold of $sessionIdleMinutesThreshold minutes" -ForegroundColor Yellow
            }
        $runningProcesses=Get-WmiObject -ComputerName $targetNodes -Class Win32_Process -Filter "Name = '$exeName'"
        $targetProcesses=$runningProcesses|?{$_.GetOwner().User -in $targetUsers}
        if($targetProcesses){
        get-job|stop-job|remove-job
        foreach ($process in $targetProcesses){
            $processId=$process.ProcessId
            $computer=$process.PSComputerName
            $sessionId=$process.SessionId
            return invoke-command -AsJob -ComputerName $computer -scriptblock {
                param($checkInactiveProcess,$processId,$minutes,$sessionId)
                [bool]$processIsInactive=[ScriptBlock]::Create($checkInactiveProcess).Invoke($env:computername,$processId,$minutes)|select -First 1
                [PSCustomObject][ordered]@{                    
                    processId=$processId
                    sessionId=$sessionId
                    processIsInactive=$processIsInactive
                }
            } -Args ${function:checkInactiveProcess},$processId,$minutesToDetermineProgramInactive,$sessionId
        }
        $results=@()
        $allJobsCompleted=$false
        do{
            $completedJobs=get-job|?{$_.State -eq 'Completed'}
            if($completedJobs){
                foreach ($job in $completedJobs){
                    $jobResult=Receive-Job -id $job.id
                    write-host "$jobResult"
                    $results+=$jobResult
                    remove-job -id $job.id
                }
            }else{
                write-host '.' -NoNewline
                sleep 6
            }
            $allJobsCompleted=(get-job).count -eq 0
        }until($allJobsCompleted)
        
        if($results){
            $results|ForEach-Object{
                    if($_.ProcessIsInactive){
                        try{
                            Stop-TSProcess -ComputerName $($_.PSComputerName) -Id $($_.processId) -Force -ea -verbose Stop
                            Stop-TSSession -ComputerName $($_.PSComputerName) -Id $($_.sessionId) -Force -verbose
                        }catch{
                            write-warning $_
                            Continue
                        }
                    }else{
                        write-host "ComputerName $($_.PSComputerName) ProcessId $($_.processId) is currently Active! Thus, session $($_.sessionId)) may not be stopped." -ForegroundColor Yellow
                        }
                }
        }
    }else{
        write-host "User(s) $($targetUsers -join ', ') do(es) not have $exeName running in duplicate sessions. Thus, the program takes no actions at this time.`r`n$(($targetSessions|out-string).trim())" -ForegroundColor Yellow
        }
    }
}

manageRdsSessions $sessionIdleMinutesThreshold $exeName $minutesToDetermineProgramInactive

Leave a Reply

Your email address will not be published. Required fields are marked *

Related Post

PowerShell: Start IIS Sites & App Pools

Before running the below script, it's necessary to mark certain IIS App pools and websites…

How to Setup Snom Model M9r SIP Phone with ShoreTel

Ensure that ShoreTel requirements are met Each SIP extension needs 1 IP Phone and 1…

Mods to Zencart

Important Mods to Zencart OC GOLD® setup:   Zens Footer copyright info is moded at…