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