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.
    Accepts a single computer name only — mstsc.exe shadow mode opens one
    interactive window per call. To shadow sessions on multiple machines,
    pipe objects from Get-RdpSession individually.
 
.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 'SERVER01'
    Shadows session 2 on SERVER01 in interactive control mode.
    The user receives a consent prompt (default behavior).
 
.EXAMPLE
    Get-RdpSession -ComputerName 'APP01' |
        Where-Object { $_.UserName -eq 'admin-jdoe' } |
        Connect-RdpSession -ControlMode View
    Finds the session for admin-jdoe 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.
 
.OUTPUTS
PSWinOps.RdpSessionAction
    Connection action result with session details and status.
 
.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('PSWinOps.RdpSessionAction')]
    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 -Format 'o'
                }

                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"
    }
}