Public/sessions/Connect-RdpSession.ps1
|
#Requires -Version 5.1 function Connect-RdpSession { <# .SYNOPSIS Establishes a remote control (shadow) connection to an active RDP session .DESCRIPTION Connects to an active RDP session on a remote computer using mstsc.exe shadow mode to observe or interactively control the user's session. Session existence is verified via Invoke-QwinstaQuery (private helper wrapping qwinsta.exe) before the shadow window is opened. v1.2.0 fix: removed reliance on Win32_TSSession (class unavailable in root\cimv2\TerminalServices) and Win32_TerminalService.RemoteControl() (method does not exist). Both are replaced by qwinsta.exe and mstsc.exe /shadow, which are the documented Windows mechanisms for RDP session shadowing. Group Policy "Set rules for remote control of RDS user sessions" must permit shadowing on the target server. Press Ctrl+* (numpad asterisk) to exit shadow mode. .PARAMETER ComputerName The remote computer hosting the target RDP session. Defaults to the local machine. Accepts pipeline input by property name. .PARAMETER SessionID The numeric session ID to shadow. Retrieve this value with Get-RdpSession. Accepts pipeline input by value and by property name. .PARAMETER ControlMode Shadow interaction mode. Control (default): full keyboard and mouse input forwarded to the session. View: read-only observation -- no input is sent to the session. .PARAMETER NoUserPrompt Passes /noConsentPrompt to mstsc.exe, suppressing the consent dialog on the target session. Requires the matching Group Policy setting to be configured. .PARAMETER Credential Runs mstsc.exe under the specified account via Start-Process -Credential. If omitted, the current user context is used. Note that the mstsc process must be able to display a window on the current desktop. .EXAMPLE Connect-RdpSession -SessionID 2 -ComputerName 'ecrmut-ad-02' Shadows session 2 on ecrmut-ad-02 in interactive control mode. The user receives a consent prompt (default behavior). .EXAMPLE Get-RdpSession -ComputerName 'APP01' | Where-Object { $_.UserName -eq 'adm-fsallet' } | Connect-RdpSession -ControlMode View Finds the session for adm-fsallet via pipeline and connects in view-only mode. .EXAMPLE Connect-RdpSession -SessionID 3 -ComputerName 'WEB01' -NoUserPrompt -WhatIf Dry-run: shows what would happen without opening the shadow window. .EXAMPLE Connect-RdpSession -SessionID 5 -ControlMode View -Credential $adminCred Opens a view-only shadow of session 5, with mstsc.exe running as $adminCred. .NOTES Author: Franck SALLET Version: 1.2.0 Last Modified: 2026-03-11 Requires: PowerShell 5.1+, mstsc.exe, qwinsta.exe Permissions: Local Administrator on target machine Changelog v1.2.0: - [FIX] Replaced Win32_TSSession CIM check with qwinsta.exe session lookup. Win32_TSSession does not exist in root\cimv2\TerminalServices; -ErrorAction SilentlyContinue was silently hiding the error, causing every session lookup to return null (false negative). - [FIX] Replaced Win32_TerminalService.RemoteControl() with mstsc.exe /shadow, the documented Windows shadow mechanism. Win32_TerminalService has no RemoteControl() instance method. - [FIX] Extracted qwinsta call into private Invoke-QwinstaQuery to isolate $LASTEXITCODE dependency and enable reliable unit testing. - [KEEP] Credential now forwarded to Start-Process -Credential. .LINK https://docs.microsoft.com/en-us/windows-server/remote/remote-desktop-services/rds-remote-control #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')] [OutputType([PSCustomObject])] param( [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)] [ValidateNotNullOrEmpty()] [Alias('CN', 'Name', 'DNSHostName')] [string]$ComputerName = $env:COMPUTERNAME, [Parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] [ValidateRange(0, 65536)] [int]$SessionID, [Parameter(Mandatory = $false)] [ValidateSet('Control', 'View')] [string]$ControlMode = 'Control', [Parameter(Mandatory = $false)] [switch]$NoUserPrompt, [Parameter(Mandatory = $false)] [ValidateNotNull()] [System.Management.Automation.PSCredential]$Credential ) begin { Write-Verbose "[$($MyInvocation.MyCommand)] Starting -- PowerShell $($PSVersionTable.PSVersion)" $script:mstscPath = Join-Path -Path $env:SystemRoot -ChildPath 'System32\mstsc.exe' if (-not (Test-Path -Path $script:mstscPath -PathType Leaf)) { throw "[$($MyInvocation.MyCommand)] mstsc.exe not found at: $script:mstscPath" } } process { Write-Verbose "[$($MyInvocation.MyCommand)] Processing session ID $SessionID on $ComputerName" # ------------------------------------------------------------------- # Step 1 -- Verify session exists via Invoke-QwinstaQuery # ------------------------------------------------------------------- # qwinsta output format (header + data lines): # [>]SessionName [UserName] ID State [Type] [Device] # The ID column is always a standalone integer token. # We skip the header line and search each data line for a token that # matches the requested session ID exactly. # ------------------------------------------------------------------- Write-Verbose "[$($MyInvocation.MyCommand)] Verifying session $SessionID on $ComputerName via qwinsta" $qwinstaResult = Invoke-QwinstaQuery -ServerName $ComputerName if ($qwinstaResult.ExitCode -ne 0) { Write-Error ("[$($MyInvocation.MyCommand)] qwinsta failed on $ComputerName " + "(exit $($qwinstaResult.ExitCode)). Verify network connectivity and permissions.") return } $sessionFound = $false $targetIdToken = $SessionID.ToString() foreach ($qwinstaLine in ($qwinstaResult.Output | Select-Object -Skip 1)) { # Strip the leading '>' marker that qwinsta uses for the current session $lineText = ($qwinstaLine -replace '^>', ' ').Trim() $tokens = $lineText -split '\s+' foreach ($lineToken in $tokens) { if ($lineToken -eq $targetIdToken) { $sessionFound = $true break } } if ($sessionFound) { break } } if (-not $sessionFound) { Write-Error "[$($MyInvocation.MyCommand)] Session ID $SessionID not found on $ComputerName" return } Write-Verbose "[$($MyInvocation.MyCommand)] Session $SessionID confirmed on $ComputerName" # ------------------------------------------------------------------- # Step 2 -- Build mstsc.exe /shadow argument list # ------------------------------------------------------------------- $mstscArgList = [System.Collections.Generic.List[string]]::new() $mstscArgList.Add("/shadow:$SessionID") $mstscArgList.Add("/v:$ComputerName") if ($ControlMode -eq 'Control') { $mstscArgList.Add('/control') } if ($NoUserPrompt) { $mstscArgList.Add('/noConsentPrompt') } $actionDescription = if ($ControlMode -eq 'Control') { 'Take interactive control of RDP session (SHADOW MODE)' } else { 'Observe RDP session in view-only mode (SHADOW MODE)' } # ------------------------------------------------------------------- # Step 3 -- Launch shadow session via mstsc.exe # ------------------------------------------------------------------- if ($PSCmdlet.ShouldProcess("$ComputerName - Session $SessionID", $actionDescription)) { Write-Verbose "[$($MyInvocation.MyCommand)] Launching: mstsc.exe $($mstscArgList -join ' ')" $startParams = @{ FilePath = $script:mstscPath ArgumentList = $mstscArgList.ToArray() Wait = $true PassThru = $true ErrorAction = 'Stop' } if ($PSBoundParameters.ContainsKey('Credential')) { $startParams['Credential'] = $Credential } try { $mstscProcess = Start-Process @startParams $exitSuccess = ($mstscProcess.ExitCode -eq 0) $resultMessage = if ($exitSuccess) { '[OK] Shadow session ended normally' } else { "[WARN] mstsc.exe exited with code $($mstscProcess.ExitCode)" } [PSCustomObject]@{ PSTypeName = 'PSWinOps.RdpSessionAction' ComputerName = $ComputerName SessionID = $SessionID Action = 'Shadow' ControlMode = $ControlMode Success = $exitSuccess ExitCode = $mstscProcess.ExitCode Message = $resultMessage Timestamp = Get-Date } if ($exitSuccess) { Write-Information -MessageData "[OK] Shadow session ended for session $SessionID on $ComputerName" -InformationAction Continue Write-Information -MessageData '[INFO] Use Ctrl+* (numpad asterisk) next time to exit shadow mode early' -InformationAction Continue } else { Write-Warning "[$($MyInvocation.MyCommand)] $resultMessage" } } catch { Write-Error ("[$($MyInvocation.MyCommand)] Failed to launch mstsc.exe for " + "shadow session $SessionID on $ComputerName -- $_") } } } end { Write-Verbose "[$($MyInvocation.MyCommand)] Completed" } } |