PowerShell: Automatically Log Off Idling Remote Desktop Sessions

$computernames=@(
  'LAX-RDSNODE01'
)
$idleDaysThreshold=3
$forcedLogoff=$true

# Obtain credentials being passed by Jenkins
$runasUser=$env:runasUser
$runasPassword=$env:runasPassword
$encryptedPassword=ConvertTo-SecureString $runasPassword -AsPlainText -Force
$runasCredentials=New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList $runasUser,$encryptedPassword

# Email relay parameters
$emailFrom='reports@kimconnect.com'
$emailTo='sysadmins@kimconnect.com','sysAdmins@dragoncoin.com'
$subject='Hyper-V Hosts Capacity Report'
$smtpRelayServer='relay02.dragoncoin.com'


function getSessionsInfo([string[]]$computernames=$env:computername,$runasCredentials){
  $results=@()
  function checkPorts($servers,$ports){
    function includePortQry{
        if (!(Get-Command portqry.exe -ErrorAction SilentlyContinue)){
            if (!(Get-Command choco.exe -ErrorAction SilentlyContinue)) {
            Set-ExecutionPolicy Bypass -Scope Process -Force; iex ((New-Object System.Net.WebClient).DownloadString('https://chocolatey.org/install.ps1'))}
            choco install portqry -y
            if (Get-Command portqry.exe -ErrorAction SilentlyContinue){return $true}else{return $false}
        }else{
            return $true
        }
    }
    $portQryExists=includePortQry
    if(!$portQryExists){
      write-warning "Unable to proceed without portqry"
      return $false
    }
    $results=@()
    foreach ($remoteComputer in $servers){
      #$ip=[System.Net.Dns]::GetHostAddresses($remoteComputer).IPAddressToString|select -first 1
      write-host "Now scanning $remoteComputer"
      $result=@()
      foreach ($item in $ports){
          $port=$item.port
          $protocol=$item.protocol        
          $reachable=if($port -ne 135){                
                  $command={
                      param($remoteComputer,$protocol,$port)
                      $command="portqry -n $remoteComputer -p $protocol -e $port|find ': LISTENING'"
                      invoke-expression $command
                  }
                  $jobId=(Start-Job $command -Args $remoteComputer,$protocol,$port).Id
                  $startCount=0;$waitSeconds=5
                  do{
                      $jobStatus=(get-job -id $jobId).State
                      if($jobStatus -eq 'Completed'){
                          $jobResult=receive-job $jobId
                      }else{
                          if($startCount++ -eq $waitSeconds){
                              $jobStatus='Completed'
                              $null=remove-job -id $jobId -force
                              $jobResult=$false
                          }else{
                              sleep 1
                          }                        
                      }
                  }until($jobStatus -eq 'Completed')
                  [bool]($jobResult)
              }else{
                  [bool](portqry -n $remoteComputer -p $protocol -e $port|find 'Total endpoints found: ')
              }       
          write-host "$port/$protocol : $reachable"
          $result+=[PSCustomObject]@{
              computername=$remoteComputer
              port=$port
              protocol=$protocol
              reachable=$reachable
          }
      }
      #$resultString=$results.GetEnumerator()|sort-object {[int]$_.Name}|out-string
      $results+=$result
      $resultString=$result|sort-object -property port|out-string
      write-host $resultString
    }
    return $results
  }
  function getDisconnectedSessionInfo($line,$computer){
           # convert multiple spaces into single space and split pieces into array
          $thisLine = $line.Trim() -Replace '\s+',' ' -Split '\s'
          $properties = @{
              UserName = $thisLine[0]
              ComputerName = $computer
              }
          $properties.SessionName = $null
          $properties.Id = $thisLine[1]
          $properties.State = $thisLine[2]                        
          $properties.IdleMinutes=.{[string]$x=$thisLine[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 = $thisLine[4..6] -join ' '
          $result=New-Object -TypeName PSCustomObject -Property $properties
          return $result
  }

  function getConnectedSessionInfo($line,$computer){
          $thisLine = $line.Trim() -Replace '\s+',' ' -Split '\s'
          $properties = @{
              UserName = $thisLine[0]
              ComputerName = $computer
              }
          $properties.SessionName = $thisLine[1]
          $properties.Id = $thisLine[2]
          $properties.State = $thisLine[3]
          $properties.IdleMinutes=.{$x=$thisLine[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;
                                   }               
                              '\:' {try{
                                      ([TimeSpan]::Parse($x)).totalMinutes
                                      }catch{
                                          "Invalid value: $x"
                                          }
                                      break
                                      }
                              default {$x}
                          }                                 
                      }
          $properties.LogonTime = $thisLine[5..($thisLine.GetUpperBound(0))] -join ' '
          $result=New-Object -TypeName PSCustomObject -Property $properties
          return $result
  }

  foreach ($computer in $computernames){
    if((checkPorts $computer @{protocol='tcp';port=135}).reachable){
      try {
        # Perusing legacy commandlets as there are no PowerShell equivalents at this time
        $sessions=if($runasCredentials){
            invoke-command -computername $computer -credential $runasCredentials {quser /server:$computer 2>&1 | Select-Object -Skip 1}
        }else{
            quser /server:$computer 2>&1 | Select-Object -Skip 1
        }
        ForEach ($session in $sessions) {               
            $result=$null
            $thatLine = $session.Trim() -Replace '\s+',' ' -Split '\s'
            if ($thatLine[2] -eq 'Disc'){
                $result=getDisconnectedSessionInfo $thatLine $computer
            }elseif(!$onlyDisconnected){
                $result=getConnectedSessionInfo $thatLine $computer
            }
            if($result){$results+=$result}
        }
      }catch{
          $results+=New-Object -TypeName PSCustomObject -Property @{
              ComputerName=$computer
              Error=$_.Exception.Message
          } | Select-Object -Property UserName,ComputerName,SessionName,Id,State,IdleMinutes,LogonTime,Error|sort -Property UserName
      }
    }else{
      $results+=New-Object -TypeName PSCustomObject -Property @{
        ComputerName=$computer
        Error=$_.Exception.Message
      } | Select-Object -Property UserName,ComputerName,SessionName,Id,State,IdleMinutes,LogonTime,Error|sort -Property UserName
    }
  }
  return $results
}

#getSessionsInfo $computernames $runasCredentials

function logOffRdpSession{
  param(
    $serverName=$env:computername,
    $username,
    $idleMinutes,
    $runasCredentials
  )
  $username=if($username -match '\\'){[regex]::match($username,'\\(.*)$').captures.groups.value[1]
    }else{
      $username.tostring()
    }
  $sessions=if(!$runasCredentials){
      qwinsta /server:$serverName
  }else{
      invoke-command -computername $servername -credential $runasCredentials -scriptblock {qwinsta /server:$env:computername}
  }
  $sessionId=.{
      $sessionMatch=$sessions|?{$_ -match $username}
      if($sessionMatch){
          $array=$sessionMatch -replace '(^\s+|\s+$)','' -replace '\s+',' ' -split ' '
          return $array|?{$_.tostring() -match '\d+'}
      }else{
          return $null  
      }
  }
  if($sessionId){
    if(!$runasCredentials){
        $sessionId|%{rwinsta $_ /SERVER:$serverName}
        $sessions=qwinsta /server:$serverName
        $newSessionId=.{
            $sessionMatch=$sessions|?{$_ -match $username}
            if($sessionMatch){
                $array=$sessionMatch -replace '(^\s+|\s+$)','' -replace '\s+',' ' -split ' '
                return $array[2]
            }else{
                return $null  
            }
        }
    }else{
        invoke-command -computername $servername -credential $runasCredentials -scriptblock{
            param($sessionId,$username)
            $sessionId|%{rwinsta $_ /SERVER:$env:computername}
            $sessions=qwinsta /server:$env:computername
            $newSessionId=.{
                $sessionMatch=$sessions|?{$_ -match $username}
                if($sessionMatch){
                    $array=$sessionMatch -replace '(^\s+|\s+$)','' -replace '\s+',' ' -split ' '
                    return $array[2]
                }else{
                    return $null  
                }
            }
        } -Args $sessionId,$username
    }
    if(!$newSessionId){
      write-host "$username RDP session ID $sessionId on $serverName has been forcefully disconnected due to its idling $idleMinutes minutes."
      return $true
    }else{
      write-warning "$username RDP session ID $sessionId still exists on $serverName"
      return $false
    }      
  }else{
    write-host "$username doesn't have an RDP session on $serverName"
    return $true
  }
}

# logOffRdpSession $computerName $username $runasCredentials

$allSessions=getSessionsInfo $computernames $runasCredentials
$targetSessions=$allSessions|?{[Int]$_.IdleMinutes -ge $idleDaysThreshold*1440}
#$logoffSessions=@()
if($forcedLogoff){
  foreach($session in $targetSessions){
    $result=logOffRdpSession $session.ComputerName $session.UserName $session.IdleMinutes $runasCredentials
    if($result){
      $targetSessions=$targetSessions|?{$_ -ne $session}
      #$logoffSessions+=$sessions
    }
  }
}
$sessionsToEmail=$targetSessions # future development: add a routine to check whether current report is identical to previous

if($null -ne $sessionsToEmail){
  $css="
  <style>
  .h1 {
      font-size: 18px;
      height: 40px;
      padding-top: 80px;
      margin: auto;
      text-align: center;
  }
  .h5 {
      font-size: 22px;
      text-align: center;
  }
  .th {text-align: center;}
  .table {
      padding:7px;
      border:#4e95f4 1px solid;
      background-color: white;
      margin-left: auto;
      margin-right: auto;
      width: 100%
      }
  .colgroup {}
  .th { background: #0046c3; color: #fff; padding: 5px 10px; }
  .td { font-size: 11px; padding: 5px 20px; color: #000;
        width: 1px;
        white-space: pre;
      }
  .tr { background: #b8d1f3;}
  .tr:nth-child(even) {
      background: #dae5f4;
      width: 1%;
      white-space: nowrap
  }
  .tr:nth-child(odd) {
      background: #b8d1f3;
      width: 1%;
      white-space: nowrap
  }
  pre code {
    background-color: #eee;
    border: 1px solid #999;
    display: block;
    padding: 20px;
  }
  </style>
  "
  $howToLogOffSessionCode="<pre><code>function logOffRdpSession"+${function:logOffRdpSession}+"}`r`n`r`nlogOffRdpSession SERVERNAME USERNAME</code></pre>"
  $sessionsReformatted=$sessionsToEmail|select ComputerName,UserName,@{n='sessionId';e={$_.id}},IdleMinutes,State,LogonTime
  $currentReport=$sessionsReformatted|ConvertTo-Html -Fragment|Out-String
  $currentReportHtml=$currentReport -replace '\<(?<item>\w+)\>','<${item} class=''${item}''>'
  $howToLogOffSessionCode=$howToLogOffSessionCode -replace '\<(?<item>\w+)\>','<${item} class=''${item}''>'
  $emailContent='<html><head>'+$css+"</head><body><h5 class='h5'>$subject</h5>"+$currentReportHtml+"<br><br><h1 class='h1'>Function to forcefully log off a session</h1>"+$howToLogOffSessionCode+'</body></html>'
  Send-MailMessage -From $emailFrom `
  -To $emailTo `
  -Subject $subject `
  -Body $emailContent `
  -BodyAsHtml `
  -SmtpServer $smtpRelayServer 
}else{
  write-host "No idle sessions to notify Admins."
}

Leave a Reply

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