# 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