PowerRemoteDesktop_Viewer.psm1
<#-------------------------------------------------------------------------------
Power Remote Desktop In loving memory of my father. Thanks for all you've done. you will remain in my heart forever. .Developer Jean-Pierre LESUEUR (@DarkCoderSc) https://www.twitter.com/darkcodersc https://github.com/DarkCoderSc www.phrozen.io jplesueur@phrozen.io PHROZEN .License Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ .Why - Prove PowerShell is as "PowerFul" as compiled language. - Improve my PowerShell skills. - Because Remote Desktop Powershell Scripts doesn't exists so far. .Important This PowerShell Application is not yet marked as Stable / Final. It is not recommended to use it in a production environment at this time. Wait for final 1.0 version. .Disclaimer We are doing our best to prepare the content of this app. However, PHROZEN SASU and / or Jean-Pierre LESUEUR cannot warranty the expressions and suggestions of the contents, as well as its accuracy. In addition, to the extent permitted by the law, PHROZEN SASU and / or Jean-Pierre LESUEUR shall not be responsible for any losses and/or damages due to the usage of the information on our app. By using our app, you hereby consent to our disclaimer and agree to its terms. Any links contained in our app may lead to external sites are provided for convenience only. Any information or statements that appeared in these sites or app are not sponsored, endorsed, or otherwise approved by PHROZEN SASU and / or Jean-Pierre LESUEUR. For these external sites, PHROZEN SASU and / or Jean-Pierre LESUEUR cannot be held liable for the availability of, or the content located on or through it. Plus, any losses or damages occurred from using these contents or the internet generally. -------------------------------------------------------------------------------#> Add-Type -Assembly System.Windows.Forms Add-Type -MemberDefinition '[DllImport("User32.dll")] public static extern bool SetProcessDPIAware();' -Name User32 -Namespace W; $global:PowerRemoteDesktopVersion = "1.0.5.beta.6" $global:HostSyncHash = [HashTable]::Synchronized(@{ host = $host ClipboardText = (Get-Clipboard -Raw) }) # Last until PowerShell session is closed $global:EphemeralTrustedServers = @() # Local storage definitions $global:LocalStoragePath = "HKCU:\SOFTWARE\PowerRemoteDesktop_Viewer" $global:LocalStoragePath_TrustedServers = -join($global:LocalStoragePath, "\TrustedServers") enum ClipboardMode { Disabled = 1 Receive = 2 Send = 3 Both = 4 } enum TransportMode { Raw = 1 Base64 = 2 } function Write-Banner { <# .SYNOPSIS Output cool information about current PowerShell module to terminal. #> Write-Host "" Write-Host "Power Remote Desktop - Version " -NoNewLine Write-Host $global:PowerRemoteDesktopVersion -ForegroundColor Cyan Write-Host "Jean-Pierre LESUEUR (" -NoNewLine Write-Host "@DarkCoderSc" -NoNewLine -ForegroundColor Green Write-Host ") " -NoNewLine Write-Host "#" -NoNewLine -ForegroundColor Blue Write-Host "#" -NoNewLine -ForegroundColor White Write-Host "#" -ForegroundColor Red Write-Host "https://" -NoNewLine -ForegroundColor Green Write-Host "www.github.com/darkcodersc" Write-Host "https://" -NoNewLine -ForegroundColor Green Write-Host "www.phrozen.io" Write-Host "" Write-Host "License: Apache License (Version 2.0, January 2004)" Write-Host "https://" -NoNewLine -ForegroundColor Green Write-Host "www.apache.org/licenses/" Write-Host "" } function Get-BooleanAnswer { <# .SYNOPSIS As user to make a boolean choice. Return True if Y and False if N. #> while ($true) { $choice = Read-Host "[Y] Yes [N] No (Default is ""N"")" if (-not $choice) { $choice = "N" } switch ($choice) { "Y" { return $true } "N" { return $false } default { Write-Host "Invalid Answer, available options are ""Y , N""" -ForegroundColor Red } } } } function New-RegistryStorage { <# .SYNOPSIS Create required registry keys for storing persistent data between viewer sessions. .DESCRIPTION Users doesn't share this storage. If you really wish to, replace HKCU by HKLM (Requires Admin Privilege) #> try { if (-not (Test-Path -Path $global:LocalStoragePath)) { Write-Verbose "Create local storage root at ""${global:LocalStoragePath}""..." New-Item -Path $global:LocalStoragePath } if (-not (Test-Path -Path $global:LocalStoragePath_TrustedServers)) { Write-Verbose "Create local storage child: ""${global:LocalStoragePath}""..." New-Item -Path $global:LocalStoragePath_TrustedServers } } catch { Write-Verbose "Could not write server fingerprint to local storage with error: ""$($_)""" } } function Write-ServerFingerprintToLocalStorage { <# .SYNOPSIS Write a trusted server certificate fingerprint to our local storage. .PARAMETER Fingerprint The server certificate fingerprint to store. #> param ( [Parameter(Mandatory=$True)] [string] $Fingerprint ) New-RegistryStorage # Value is stored as a JSON Object to be easily upgraded and extended in future. $value = New-Object -TypeName PSCustomObject -Property @{ FirstSeen = (Get-Date).ToString() } New-ItemProperty -Path $global:LocalStoragePath_TrustedServers -Name $Fingerprint -PropertyType "String" -Value ($value | ConvertTo-Json -Compress) -ErrorAction Ignore } function Remove-TrustedServer { <# .SYNOPSIS Remove trusted server from local storage. .PARAMETER Fingerprint Server certificate to remove from trusted server list. #> param ( [Parameter(Mandatory=$True)] [string] $Fingerprint ) if (-not (Test-ServerFingerprintFromLocalStorage -Fingerprint $Fingerprint)) { throw "Could not find fingerprint on trusted server list." } Write-Host "You are about to permanently delete trusted server -> """ -NoNewline Write-Host $Fingerprint -NoNewLine -ForegroundColor Green Write-Host """" Write-Host "Are you sure ?" if (Get-BooleanAnswer) { Remove-ItemProperty -Path $global:LocalStoragePath_TrustedServers -Name $Fingerprint Write-Host "Server successfully untrusted." } } function Get-TrustedServers { <# .SYNOPSIS Return a list of trusted servers fingerprints from local storage. #> $list = @() Get-Item -Path $global:LocalStoragePath_TrustedServers -ErrorAction Ignore | Select-Object -ExpandProperty Property | ForEach-Object { try { $list += New-Object -TypeName PSCustomObject -Property @{ Fingerprint = $_ Detail = (Get-ItemPropertyValue -Path $global:LocalStoragePath_TrustedServers -Name $_) | ConvertFrom-Json } } catch { } } return $list } function Clear-TrustedServers { <# .SYNOPSIS Remove all trusted servers from local storage. #> $trustedServers = Get-TrustedServers if (@($trustedServers).Length -eq 0) { throw "No trusted servers so far." } Write-Host "You are about to permanently delete $(@(trustedServers).Length) trusted servers." Write-Host "Are you sure ?" if (Get-BooleanAnswer) { Remove-Item -Path $global:LocalStoragePath_TrustedServers -Force -Verbose Write-Host "Servers successfully untrusted." } } function Test-ServerFingerprintFromLocalStorage { <# .SYNOPSIS Check if a server certificate fingerprint was saved to local storage. .PARAMETER Fingerprint The server certificate fingerprint to check. #> param ( [Parameter(Mandatory=$True)] [string] $Fingerprint ) return (Get-ItemProperty -Path $global:LocalStoragePath_TrustedServers -Name $Fingerprint -ErrorAction Ignore) } function Get-SHA512FromString { <# .SYNOPSIS Return the SHA512 value from string. .PARAMETER String A String to hash. .EXAMPLE Get-SHA512FromString -String "Hello, World" #> param ( [Parameter(Mandatory=$True)] [string] $String ) $buffer = [IO.MemoryStream]::new([byte[]][char[]]$String) return (Get-FileHash -InputStream $buffer -Algorithm SHA512).Hash } function Resolve-AuthenticationChallenge { <# .SYNOPSIS Algorithm to solve the server challenge during password authentication. .PARAMETER SecurePassword Registered password string for server authentication. .PARAMETER Candidate Random string used to solve the challenge. This string is public and is set across network by server. Each time a new connection is requested to server, a new candidate is generated. .EXAMPLE Resolve-AuthenticationChallenge -SecurePassword "s3cr3t!" -Candidate "rKcjdh154@]=Ldc" #> param ( [Parameter(Mandatory=$True)] [SecureString] $SecurePassword, [Parameter(Mandatory=$True)] [string] $Candidate ) $BSTR = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($SecurePassword) try { $solution = -join($Candidate, ":", [Runtime.InteropServices.Marshal]::PtrToStringBSTR($BSTR)) for ([int] $i = 0; $i -le 1000; $i++) { $solution = Get-SHA512FromString -String $solution } return $solution } finally { [Runtime.InteropServices.Marshal]::FreeBSTR($BSTR) } } class ClientIO { [string] $RemoteAddress [int] $RemotePort [bool] $TLSv1_3 [System.Net.Sockets.TcpClient] $Client = $null [System.Net.Security.SslStream] $SSLStream = $null [System.IO.StreamWriter] $Writer = $null [System.IO.StreamReader] $Reader = $null ClientIO( <# .SYNOPSIS Class constructor. .PARAMETER RemoteAddress IP/HOST of remote server. .PARAMETER RemotePort Remote server port. .PARAMETER TLSv1_3 Define whether or not SSL/TLS v1.3 must be used. #> [string] $RemoteAddress = "127.0.0.1", [int] $RemotePort = 2801, [bool] $TLSv1_3 = $false ) { $this.RemoteAddress = $RemoteAddress $this.RemotePort = $RemotePort $this.TLSv1_3 = $TLSv1_3 } [void]Connect() { <# .SYNOPSIS Open a new connection to remote server. Create required streams and open a new secure connection with peer. #> Write-Verbose "Connect: ""$($this.RemoteAddress):$($this.RemotePort)...""" $this.Client = New-Object System.Net.Sockets.TcpClient($this.RemoteAddress, $this.RemotePort) Write-Verbose "Connected." if ($this.TLSv1_3) { $TLSVersion = [System.Security.Authentication.SslProtocols]::TLS13 } else { $TLSVersion = [System.Security.Authentication.SslProtocols]::TLS12 } Write-Verbose "Establish an encrypted tunnel using: ${TLSVersion}..." $this.SSLStream = New-object System.Net.Security.SslStream( $this.Client.GetStream(), $false, { param( $Sendr, $Certificate, $Chain, $Policy ) if ( (Test-ServerFingerprintFromLocalStorage -Fingerprint $Certificate.Thumbprint) -or $global:EphemeralTrustedServers -contains $Certificate.Thumbprint ) { Write-Verbose "Fingerprint already known and trusted: ""$($Certificate.Thumbprint)""" return $true } else { Write-Verbose "@Remote Server Certificate:" Write-Verbose $Certificate Write-Verbose "---" Write-Host "Untrusted Server Certificate Fingerprint: """ -NoNewLine Write-Host $Certificate.Thumbprint -NoNewline -ForegroundColor Green Write-Host """" while ($true) { Write-Host "`r`nDo you want to trust current server ?" $choice = Read-Host "[A] Always [Y] Yes [N] No [?] Help (Default is ""N"")" if (-not $choice) { $choice = "N" } switch ($choice) { "?" { Write-Host "" Write-Host "[" -NoNewLine Write-Host "A" -NoNewLine -ForegroundColor Cyan Write-Host "] Always trust current server (Persistent between PowerShell Instances)" Write-Host "[" -NoNewLine Write-Host "Y" -NoNewLine -ForegroundColor Cyan Write-Host "] Trust current server during current PowerShell Instance lifetime (Temporary)." Write-Host "[" -NoNewLine Write-Host "N" -NoNewLine -ForegroundColor Cyan Write-Host "] Don't trust current server. Connection is aborted (Recommeneded if you don't recognize server fingerprint)." Write-Host "[" -NoNewLine Write-Host "?" -NoNewLine -ForegroundColor Cyan Write-Host "] Current help output." Write-Host "" } "A" { Write-ServerFingerprintToLocalStorage -Fingerprint $Certificate.Thumbprint return $true } "Y" { $global:EphemeralTrustedServers += $Certificate.Thumbprint return $true } "N" { return $false } default { Write-Host "Invalid Answer, available options are ""A , Y , N , H""" -ForegroundColor Red } } } } } ) $this.SSLStream.AuthenticateAsClient( "PowerRemoteDesktop", $null, $TLSVersion, $null ) if (-not $this.SSLStream.IsEncrypted) { throw "Could not establish a secure communication channel with remote server." } $this.SSLStream.WriteTimeout = 5000 $this.Writer = New-Object System.IO.StreamWriter($this.SSLStream) $this.Writer.AutoFlush = $true $this.Reader = New-Object System.IO.StreamReader($this.SSLStream) Write-Verbose "Encrypted tunnel opened and ready for use." } [void]Authentify([SecureString] $SecurePassword) { <# .SYNOPSIS Handle authentication process with remote server. .PARAMETER Password Password used for authentication with server. .EXAMPLE .Authentify("s3cr3t!") #> Write-Verbose "Authentify with remote server (Challenged-Based Authentication)..." $candidate = $this.Reader.ReadLine() $challengeSolution = Resolve-AuthenticationChallenge -Candidate $candidate -SecurePassword $SecurePassword Write-Verbose "@Challenge:" Write-Verbose "Candidate: ""${candidate}""" Write-Verbose "Solution: ""${challengeSolution}""" Write-Verbose "---" $this.Writer.WriteLine($challengeSolution) $result = $this.Reader.ReadLine() if ($result -eq "OK.") { Write-Verbose "Solution accepted. Authentication success." } else { throw "Solution declined. Authentication failed." } } [void]Hello([string] $SessionId) { <# .SYNOPSIS This method must be called after Password-Authentication to finalise an established connection with server. .PARAMETER SessionId A String containing the Session Id. #> Write-Verbose "Say Hello..." $this.Writer.WriteLine($SessionId) $result = $this.Reader.ReadLine() if ($result -eq "HELLO.") { Write-Verbose "Server Hello back." } else { throw "Could not finalise connection with remote server. Session Id is wrong or was terminated." } } [PSCustomObject]Hello(){ <# .SYNOPSIS This method must be called after Password-Authentication to finalise an established connection with server. .DESCRIPTION This method is called when no session is already present. Server will send several informations including a new session id the store. TODO: Instead of PSCustomObject, create a specific class ? #> Write-Verbose "Say Hello..." $jsonObject = $this.Reader.ReadLine() Write-Verbose "@SessionInformation:" Write-Verbose $jsonObject Write-Verbose "---" $sessionInformation = $jsonObject | ConvertFrom-Json if ( (-not ($sessionInformation.PSobject.Properties.name -contains "MachineName")) -or (-not ($sessionInformation.PSobject.Properties.name -contains "Username")) -or (-not ($sessionInformation.PSobject.Properties.name -contains "WindowsVersion")) -or (-not ($sessionInformation.PSobject.Properties.name -contains "SessionId")) -or (-not ($sessionInformation.PSobject.Properties.name -contains "TransportMode")) -or (-not ($sessionInformation.PSobject.Properties.name -contains "Version")) -or (-not ($sessionInformation.PSobject.Properties.name -contains "Screens")) -or (-not ($sessionInformation.PSobject.Properties.name -contains "ViewOnly")) ) { throw "Invalid session information data." } if ($sessionInformation.Version -ne $global:PowerRemoteDesktopVersion) { throw "Server and Viewer version mismatch.`r`n` Local: ""${global:PowerRemoteDesktopVersion}""`r`n` Remote: ""$($sessionInformation.Version)""`r`n` You cannot use two different version between Viewer and Server." } if ($sessionInformation.ViewOnly) { Write-Host "You are only authorized to view remote desktop (Mouse / Keyboard not authorized)" -ForegroundColor Cyan } # Check if remote server have multiple screens $selectedScreen = $null if ($sessionInformation.Screens.Length -gt 1) { Write-Verbose "Remote Server have $($sessionInformation.Screens.Length) Screens." Write-Host "Remote Server have " -NoNewLine Write-Host $($sessionInformation.Screens.Length) -NoNewLine -ForegroundColor Green Write-Host " Screens:`r`n" foreach ($screen in $sessionInformation.Screens) { Write-Host $screen.Id -NoNewLine -ForegroundColor Cyan Write-Host " - $($screen.Name)" -NoNewLine if ($screen.Primary) { Write-Host " (" -NoNewLine Write-Host "Primary" -NoNewLine -ForegroundColor Cyan Write-Host ")" -NoNewLine } Write-Host "" } while ($true) { $choice = Read-Host "`r`nPlease choose which screen index to capture (Default: Primary)" if (-not $choice) { # Select-Object -First 1 should also grab the Primary Screen (Since it is ordered). $selectedScreen = $sessionInformation.Screens | Where-Object -FilterScript { $_.Primary -eq $true } } else { if (-not $choice -is [int]) { Write-Host "You must enter a valid index (integer), starting at 1." continue } $selectedScreen = $sessionInformation.Screens | Where-Object -FilterScript { $_.Id -eq $choice } if (-not $selectedScreen) { Write-Host "Invalid choice, please choose an existing screen index." -ForegroundColor Red } } if ($selectedScreen) { $this.Writer.WriteLine($selectedScreen.Name) break } } } else { $selectedScreen = $sessionInformation.Screens | Select-Object -First 1 } if (-not $selectedScreen) { throw "No screen to capture." } Write-Verbose "@SelectedScreen:" Write-Verbose $selectedScreen Write-Verbose "---" $sessionInformation | Add-Member -MemberType NoteProperty -Name "Screen" -Value $selectedScreen return $sessionInformation } [void]Close() { <# .SYNOPSIS Release Streams and Connections. #> if ($this.Writer) { $this.Writer.Close() } if ($this.Reader) { $this.Reader.Close() } if ($this.SSLStream) { $this.SSLStream.Close() } if ($this.Client) { $this.Client.Close() } } } class ViewerSession { [PSCustomObject] $SessionInformation = $null [string] $ServerAddress = "127.0.0.1" [string] $ServerPort = 2801 [SecureString] $SecurePassword = $null [bool] $TLSv1_3 = $false [ClientIO] $ClientDesktop = $null [ClientIO] $ClientEvents = $null ViewerSession( [string] $ServerAddress, [int] $ServerPort, [SecureString] $SecurePassword, [bool] $TLSv1_3 ) { <# .SYNOPSIS Create a new viewer session object. .DESCRIPTION This object will contain session information including active connection objects (ClientIO) .PARAMETER ServerAddress Remote Server Address. .PARAMETER ServerPort Remote Server Port. .PARAMETER SecureString Password used during server authentication. .PARAMETER TLSv1_3 Define whether or not client must use SSL/TLS v1.3 to communicate with remote server. Recommended if possible. #> # TODO: Check if ServerAddress is a valid host. # Or: System.Management.Automation.Runspaces.MaxPort (High(Word)) if ($ServerPort -lt 0 -and $ServerPort -gt 65535) { throw "Invalid TCP Port (0-65535)" } $this.ServerAddress = $ServerAddress $this.ServerPort = $ServerPort $this.SecurePassword = $SecurePassword $this.TLSv1_3 = $TLSv1_3 } [void] OpenSession() { <# .SYNOPSIS Establish a new complete session with remote server. .DESCRIPTION This method handle both session handshake and Password-Authentication. #> Write-Verbose "Open new session with remote server: ""$($this.ServerAddress):$($this.ServerPort)""..." if ($this.SessionInformation) { throw "An session already exists. Close existing session first." } Write-Verbose "Establish first contact with remote server..." $this.ClientDesktop = [ClientIO]::New($this.ServerAddress, $this.ServerPort, $this.TLSv1_3) try { $this.ClientDesktop.Connect() $this.ClientDesktop.Authentify($this.SecurePassword) $this.SessionInformation = $this.ClientDesktop.Hello() if (-not $this.SessionInformation) { throw "Session cannot be null." } Write-Verbose "Open secondary tunnel for input control..." $this.ClientEvents = [ClientIO]::new($this.ServerAddress, $this.ServerPort, $this.TLSv1_3) $this.ClientEvents.Connect() $this.ClientEvents.Authentify($this.SecurePassword) $this.ClientEvents.Hello($this.SessionInformation.SessionId) Write-Verbose "New session successfully established with remote server." Write-Verbose "Session Id: $($this.SessionInformation.SessionId)" } catch { $this.CloseSession() throw "Open Session Error. Detail: ""$($_)""" } } [void] CloseSession() { <# .SYNOPSIS Close an existing session with remote server. Terminate active connections and reset session informations. #> Write-Verbose "Close existing session..." if ($this.ClientDesktop) { $this.ClientDesktop.Close() } if ($this.ClientEvents) { $this.ClientEvents.Close() } $this.ClientDesktop = $null $this.ClientEvents = $null $this.SessionInformation = $null Write-Verbose "Session closed." } } $global:VirtualDesktopUpdaterScriptBlock = { enum TransportMode { Raw = 1 Base64 = 2 } function Invoke-SmoothResize { <# .SYNOPSIS Output a resized version of input bitmap. The resize quality is quite fair. .PARAMETER OriginalImage Input bitmap to resize. .PARAMETER NewWidth Define the width of new bitmap version. .PARAMETER NewHeight Define the height of new bitmap version. .EXAMPLE Invoke-SmoothResize -OriginalImage $myImage -NewWidth 1920 -NewHeight 1024 #> param ( [Parameter(Mandatory=$true)] [System.Drawing.Bitmap] $OriginalImage, [Parameter(Mandatory=$true)] [int] $NewWidth, [Parameter(Mandatory=$true)] [int] $NewHeight ) try { $bitmap = New-Object -TypeName System.Drawing.Bitmap -ArgumentList $NewWidth, $NewHeight $resizedImage = [System.Drawing.Graphics]::FromImage($bitmap) $resizedImage.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::HighQuality $resizedImage.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality $resizedImage.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic $resizedImage.CompositingQuality = [System.Drawing.Drawing2D.CompositingQuality]::HighQuality $resizedImage.DrawImage($OriginalImage, 0, 0, $bitmap.Width, $bitmap.Height) return $bitmap } finally { if ($OriginalImage) { $OriginalImage.Dispose() } if ($resizedImage) { $resizedImage.Dispose() } } } try { $packetSize = 4096 while ($true) { $stream = New-Object System.IO.MemoryStream try { switch ([TransportMode] $Param.TransportMode) { ([TransportMode]::Raw) { $buffer = New-Object -TypeName byte[] -ArgumentList 4 # SizeOf(Int32) $Param.Client.SSLStream.Read($buffer, 0, $buffer.Length) [int32] $totalBufferSize = [BitConverter]::ToInt32($buffer, 0) $stream.SetLength($totalBufferSize) $stream.position = 0 $totalBytesRead = 0 $buffer = New-Object -TypeName Byte[] -ArgumentList $packetSize do { $bufferSize = $totalBufferSize - $totalBytesRead if ($bufferSize -gt $packetSize) { $bufferSize = $packetSize } else { # Save some memory operations for creating objects. # Usually, bellow code is call when last chunk is being sent. $buffer = New-Object -TypeName byte[] -ArgumentList $bufferSize } $Param.Client.SSLStream.Read($buffer, 0, $bufferSize) $null = $stream.Write($buffer, 0, $buffer.Length) $totalBytesRead += $bufferSize } until ($totalBytesRead -eq $totalBufferSize) } ([TransportMode]::Base64) { [byte[]] $buffer = [System.Convert]::FromBase64String(($Param.Client.Reader.ReadLine())) $stream.Write($buffer, 0, $buffer.Length) } } $stream.Position = 0 if ($Param.RequireResize) { #$image = [System.Drawing.Image]::FromStream($stream) $bitmap = New-Object -TypeName System.Drawing.Bitmap -ArgumentList $stream $Param.VirtualDesktopSyncHash.VirtualDesktop.Picture.Image = Invoke-SmoothResize -OriginalImage $bitmap -NewWidth $Param.VirtualDesktopWidth -NewHeight $Param.VirtualDesktopHeight } else { $Param.VirtualDesktopSyncHash.VirtualDesktop.Picture.Image = [System.Drawing.Image]::FromStream($stream) } } catch { break } finally { $stream.Close() } } } finally { $Param.VirtualDesktopSyncHash.VirtualDesktop.Form.Close() } } $global:IngressEventScriptBlock = { enum CursorType { IDC_APPSTARTING IDC_ARROW IDC_CROSS IDC_HAND IDC_HELP IDC_IBEAM IDC_ICON IDC_NO IDC_SIZE IDC_SIZEALL IDC_SIZENESW IDC_SIZENS IDC_SIZENWSE IDC_SIZEWE IDC_UPARROW IDC_WAIT } enum InputEvent { KeepAlive = 0x1 MouseCursorUpdated = 0x2 ClipboardUpdated = 0x3 } enum ClipboardMode { Disabled = 1 Receive = 2 Send = 3 Both = 4 } while ($true) { try { $jsonEvent = $Param.Client.Reader.ReadLine() } catch { break } try { $aEvent = $jsonEvent | ConvertFrom-Json } catch { continue } if (-not ($aEvent.PSobject.Properties.name -match "Id")) { continue } switch ([InputEvent] $aEvent.Id) { # Remote Global Mouse Cursor State Changed (Icon) ([InputEvent]::MouseCursorUpdated) { if (-not ($aEvent.PSobject.Properties.name -match "Cursor")) { continue } $cursor = [System.Windows.Forms.Cursors]::Arrow switch ([CursorType] $aEvent.Cursor) { ([CursorType]::IDC_APPSTARTING) { $cursor = [System.Windows.Forms.Cursors]::AppStarting } ([CursorType]::IDC_CROSS) { $cursor = [System.Windows.Forms.Cursors]::Cross } ([CursorType]::IDC_HAND) { $cursor = [System.Windows.Forms.Cursors]::Hand } ([CursorType]::IDC_HELP) { $cursor = [System.Windows.Forms.Cursors]::Help } ([CursorType]::IDC_IBEAM) { $cursor = [System.Windows.Forms.Cursors]::IBeam } ([CursorType]::IDC_NO) { $cursor = [System.Windows.Forms.Cursors]::No } ([CursorType]::IDC_SIZENESW) { $cursor = [System.Windows.Forms.Cursors]::SizeNESW } ([CursorType]::IDC_SIZENS) { $cursor = [System.Windows.Forms.Cursors]::SizeNS } ([CursorType]::IDC_SIZENWSE) { $cursor = [System.Windows.Forms.Cursors]::SizeNWSE } ([CursorType]::IDC_SIZEWE) { $cursor = [System.Windows.Forms.Cursors]::SizeWE } ([CursorType]::IDC_UPARROW) { $cursor = [System.Windows.Forms.Cursors]::UpArrow } ([CursorType]::IDC_WAIT) { $cursor = [System.Windows.Forms.Cursors]::WaitCursor } {( $_ -eq ([CursorType]::IDC_SIZE) -or $_ -eq ([CursorType]::IDC_SIZEALL) )} { $cursor = [System.Windows.Forms.Cursors]::SizeAll } } try { $Param.VirtualDesktopSyncHash.VirtualDesktop.Picture.Cursor = $cursor } catch {} break } ([InputEvent]::ClipboardUpdated) { if ($Param.Clipboard -eq ([ClipboardMode]::Disabled) -or $Param.Clipboard -eq ([ClipboardMode]::Send)) { continue } if (-not ($aEvent.PSobject.Properties.name -match "Text")) { continue } $HostSyncHash.ClipboardText = $aEvent.Text Set-Clipboard -Value $aEvent.Text } } } } $global:EgressEventScriptBlock = { enum OutputEvent { # 0x1 0x2 0x3 are at another place (GUI Thread) KeepAlive = 0x4 ClipboardUpdated = 0x5 } enum ClipboardMode { Disabled = 1 Receive = 2 Send = 3 Both = 4 } function Send-Event { <# .SYNOPSIS Send an event to remote peer. .PARAMETER AEvent Define what kind of event to send. .PARAMETER Data An optional object containing additional information about the event. #> param ( [Parameter(Mandatory=$True)] [OutputEvent] $AEvent, [PSCustomObject] $Data = $null ) try { if (-not $Data) { $Data = New-Object -TypeName PSCustomObject -Property @{ Id = $AEvent } } else { $Data | Add-Member -MemberType NoteProperty -Name "Id" -Value $AEvent } $Param.OutputEventSyncHash.Writer.WriteLine(($Data | ConvertTo-Json -Compress)) return $true } catch { return $false } } $stopWatch = [System.Diagnostics.Stopwatch]::StartNew() while ($true) { # Events that occurs every seconds needs to be placed bellow. # If no event has occured during this second we send a Keep-Alive signal to # remote peer and detect a potential socket disconnection. if ($stopWatch.ElapsedMilliseconds -ge 1000) { try { $eventTriggered = $false if ($Param.Clipboard -eq ([ClipboardMode]::Both) -or $Param.Clipboard -eq ([ClipboardMode]::Send)) { # IDEA: Check for existing clipboard change event or implement a custom clipboard # change detector using "WM_CLIPBOARDUPDATE" for example (WITHOUT INLINE C#) # It is not very important but it would avoid calling "Get-Clipboard" every seconds. $currentClipboard = (Get-Clipboard -Raw) if ($currentClipboard -and $currentClipboard -cne $HostSyncHash.ClipboardText) { $data = New-Object -TypeName PSCustomObject -Property @{ Text = $currentClipboard } if (-not (Send-Event -AEvent ([OutputEvent]::ClipboardUpdated) -Data $data)) { break } $HostSyncHash.ClipboardText = $currentClipboard $eventTriggered = $true } } # Send a Keep-Alive if during this second iteration nothing happened. if (-not $eventTriggered) { if (-not (Send-Event -AEvent ([OutputEvent]::KeepAlive))) { break } } } finally { $stopWatch.Restart() } } } } function New-VirtualDesktopForm { <# .SYNOPSIS Create new WinForms Components to handle Virtual Desktop. .DESCRIPTION This function first create a new Windows Form then create a new child component (PaintBox) to display remote desktop frames. It returns a PowerShell object containing both Form and PaintBox. .PARAMETER Width Width of new form. .PARAMETER Height Height of new form. .PARAMETER Caption Caption of new form. .EXAMPLE New-VirtualDesktopForm -Caption "New Desktop Form" -Width 1200 -Height 800 #> param ( [int] $Width = 1200, [int] $Height = 800, [string] $Caption = "PowerRemoteDesktop Viewer" ) $form = New-Object System.Windows.Forms.Form $form.Width = $Width $form.Height = $Height $form.BackColor = [System.Drawing.Color]::Black $form.Text = $Caption $form.KeyPreview = $true # Necessary to capture keystrokes. $form.FormBorderStyle = [System.Windows.Forms.FormBorderStyle]::FixedSingle $form.MaximizeBox = $false $pictureBox = New-Object System.Windows.Forms.PictureBox $pictureBox.Dock = [System.Windows.Forms.DockStyle]::Fill $form.Controls.Add($pictureBox) return New-Object PSCustomObject -Property @{ Form = $form Picture = $pictureBox } } function New-RunSpace { <# .SYNOPSIS Create a new PowerShell Runspace. .DESCRIPTION Notice: the $host variable is used for debugging purpose to write on caller PowerShell Terminal. .PARAMETER ScriptBlock A PowerShell block of code to be evaluated on the new Runspace. .PARAMETER Param Optional extra parameters to be attached to Runspace. .EXAMPLE New-RunSpace -Client $newClient -ScriptBlock { Start-Sleep -Seconds 10 } #> param( [Parameter(Mandatory=$True)] [ScriptBlock] $ScriptBlock, [PSCustomObject] $Param = $null ) $runspace = [RunspaceFactory]::CreateRunspace() $runspace.ThreadOptions = "ReuseThread" $runspace.ApartmentState = "STA" $runspace.Open() if ($Param) { $runspace.SessionStateProxy.SetVariable("Param", $Param) } $runspace.SessionStateProxy.SetVariable("HostSyncHash", $global:HostSyncHash) $powershell = [PowerShell]::Create().AddScript($ScriptBlock) $powershell.Runspace = $runspace $asyncResult = $powershell.BeginInvoke() return New-Object PSCustomObject -Property @{ Runspace = $runspace PowerShell = $powershell AsyncResult = $asyncResult } } function Invoke-RemoteDesktopViewer { <# .SYNOPSIS Open a new Remote Desktop Session to remote Server. .PARAMETER ServerAddress Remote Server Address. .PARAMETER ServerPort Remote Server Port. .PARAMETER SecurePassword SecureString Password object used to authenticate with remote server (Recommended) Call "ConvertTo-SecureString -String "YouPasswordHere" -AsPlainText -Force" on this parameter to convert a plain-text String to SecureString. See example section. .PARAMETER Password Plain-Text Password used to authenticate with remote server (Not recommended, use SecurePassword instead) .PARAMETER TLSv1_3 Define whether or not client must use SSL/TLS v1.3 to communicate with remote server. Recommended if possible. .PARAMETER DisableVerbosity Disable verbosity (not recommended) .PARAMETER Clipboard Define clipboard synchronization rules: - "Disabled": Completely disable clipboard synchronization. - "Receive": Update local clipboard with remote clipboard only. - "Send": Send local clipboard to remote peer. - "Both": Clipboards are fully synchronized between Viewer and Server. .EXAMPLE Invoke-RemoteDesktopViewer -ServerAddress "192.168.0.10" -ServerPort "2801" -SecurePassword (ConvertTo-SecureString -String "s3cr3t!" -AsPlainText -Force) Invoke-RemoteDesktopViewer -ServerAddress "192.168.0.10" -ServerPort "2801" -Password "s3cr3t!" Invoke-RemoteDesktopViewer -ServerAddress "127.0.0.1" -ServerPort "2801" -Password "Just4TestingLocally!" #> param ( [string] $ServerAddress = "127.0.0.1", [int] $ServerPort = 2801, [switch] $TLSv1_3, [SecureString] $SecurePassword, [String] $Password, [switch] $DisableVerbosity, [ClipboardMode] $Clipboard = [ClipboardMode]::Both ) [System.Collections.Generic.List[PSCustomObject]]$runspaces = @() $oldErrorActionPreference = $ErrorActionPreference $oldVerbosePreference = $VerbosePreference try { $ErrorActionPreference = "stop" if (-not $DisableVerbosity) { $VerbosePreference = "continue" } else { $VerbosePreference = "SilentlyContinue" } $VerbosePreference = "continue" Write-Banner $null = [W.User32]::SetProcessDPIAware() Write-Verbose "Server address: ""${ServerAddress}:${ServerPort}""" if (-not $SecurePassword -and -not $Password) { throw "You must specify either a SecurePassword or Password parameter used during server authentication." } if ($Password -and -not $SecurePassword) { $SecurePassword = (ConvertTo-SecureString -String $Password -AsPlainText -Force) Remove-Variable -Name "Password" -ErrorAction SilentlyContinue } $session = [ViewerSession]::New($ServerAddress, $ServerPort, $SecurePassword, $TLSv1_3) try { $session.OpenSession() Write-Verbose "Create WinForms Environment..." $virtualDesktopSyncHash = [HashTable]::Synchronized(@{ VirtualDesktop = New-VirtualDesktopForm }) $virtualDesktopSyncHash.VirtualDesktop.Form.Text = [string]::Format( "Power Remote Desktop: {0}/{1} - {2}", $session.SessionInformation.Username, $session.SessionInformation.MachineName, $session.SessionInformation.WindowsVersion ) # Prepare Virtual Desktop $locationResolutionInformation = [System.Windows.Forms.Screen]::PrimaryScreen $screenRect = $virtualDesktopSyncHash.VirtualDesktop.Form.RectangleToScreen($virtualDesktopSyncHash.VirtualDesktop.Form.ClientRectangle) $captionHeight = $screenRect.Top - $virtualDesktopSyncHash.VirtualDesktop.Form.Top $localScreenWidth = $locationResolutionInformation.WorkingArea.Width $localScreenHeight = $locationResolutionInformation.WorkingArea.Height $localScreenHeight -= $captionHeight $requireResize = ( ($localScreenWidth -le $session.SessionInformation.Screen.Width) -or ($localScreenHeight -le $session.SessionInformation.Screen.Height) ) $virtualDesktopWidth = 0 $virtualDesktopHeight = 0 $resizeRatio = 80 if ($requireResize) { $adjustVertically = $localScreenWidth -gt $localScreenHeight if ($adjustVertically) { $virtualDesktopWidth = [math]::Round(($localScreenWidth * $resizeRatio) / 100) $remoteResizedRatio = [math]::Round(($virtualDesktopWidth * 100) / $session.SessionInformation.Screen.Width) $virtualDesktopHeight = [math]::Round(($session.SessionInformation.Screen.Height * $remoteResizedRatio) / 100) } else { $virtualDesktopHeight = [math]::Round(($localScreenHeight * $resizeRatio) / 100) $remoteResizedRatio = [math]::Round(($virtualDesktopHeight * 100) / $session.SessionInformation.Screen.Height) $virtualDesktopWidth = [math]::Round(($session.SessionInformation.Screen.Width * $remoteResizedRatio) / 100) } } else { $virtualDesktopWidth = $session.SessionInformation.Screen.Width $virtualDesktopHeight = $session.SessionInformation.Screen.Height } # Size Virtual Desktop Form Window $virtualDesktopSyncHash.VirtualDesktop.Form.ClientSize = [System.Drawing.Size]::new($virtualDesktopWidth, $virtualDesktopHeight) # Center Virtual Desktop Form $virtualDesktopSyncHash.VirtualDesktop.Form.Location = [System.Drawing.Point]::new( (($localScreenWidth - $virtualDesktopSyncHash.VirtualDesktop.Form.Width) / 2), (($localScreenHeight - $virtualDesktopSyncHash.VirtualDesktop.Form.Height) / 2) ) # Create a thread-safe hashtable to send events to remote server. $outputEventSyncHash = [HashTable]::Synchronized(@{ Writer = $session.ClientEvents.Writer }) # WinForms Events (If enabled, I recommend to disable control when testing on local machine to avoid funny things) if (-not $session.SessionInformation.ViewOnly) { enum OutputEvent { Keyboard = 0x1 MouseClickMove = 0x2 MouseWheel = 0x3 } enum MouseState { Up = 0x1 Down = 0x2 Move = 0x3 } function New-MouseEvent { <# .SYNOPSIS Generate a new mouse event object to be sent to server. This event is used to simulate mouse move and clicks. .PARAMETER X The position of mouse in horizontal axis. .PARAMETER Y The position of mouse in vertical axis. .PARAMETER Type The type of mouse event (Example: Move, Click) .PARAMETER Button The pressed button on mouse (Example: Left, Right, Middle) .EXAMPLE New-MouseEvent -X 10 -Y 35 -Type "Up" -Button "Left" New-MouseEvent -X 10 -Y 35 -Type "Down" -Button "Left" New-MouseEvent -X 100 -Y 325 -Type "Move" #> param ( [Parameter(Mandatory=$true)] [int] $X, [Parameter(Mandatory=$true)] [int] $Y, [Parameter(Mandatory=$true)] [MouseState] $Type, [string] $Button = "None" ) return New-Object PSCustomObject -Property @{ Id = [OutputEvent]::MouseClickMove X = $X Y = $Y Button = $Button Type = $Type } } function New-KeyboardEvent { <# .SYNOPSIS Generate a new keyboard event object to be sent to server. This event is used to simulate keyboard strokes. .PARAMETER Keys Plain text keys to be simulated on remote computer. .EXAMPLE New-KeyboardEvent -Keys "Hello, World" New-KeyboardEvent -Keys "t" #> param ( [Parameter(Mandatory=$true)] [string] $Keys ) return New-Object PSCustomObject -Property @{ Id = [OutputEvent]::Keyboard Keys = $Keys } } function Send-VirtualMouse { <# .SYNOPSIS Transform the virtual mouse (the one in Virtual Desktop Form) coordinates to real remote desktop screen coordinates (especially when incomming desktop frames are resized) When event is generated, it is immediately sent to remote server. .PARAMETER X The position of virtual mouse in horizontal axis. .PARAMETER Y The position of virtual mouse in vertical axis. .PARAMETER Type The type of mouse event (Example: Move, Click) .PARAMETER Button The pressed button on mouse (Example: Left, Right, Middle) .EXAMPLE Send-VirtualMouse -X 10 -Y 20 -Type "Move" #> param ( [Parameter(Mandatory=$True)] [int] $X, [Parameter(Mandatory=$True)] [int] $Y, [Parameter(Mandatory=$True)] [MouseState] $Type, [string] $Button = "" ) if ($requireResize) { $X = ($X * 100) / $resizeRatio $Y = ($Y * 100) / $resizeRatio } $X += $session.SessionInformation.Screen.X $Y += $session.SessionInformation.Screen.Y $aEvent = (New-MouseEvent -X $X -Y $Y -Button $Button -Type $Type) $outputEventSyncHash.Writer.WriteLine(($aEvent | ConvertTo-Json -Compress)) } function Send-VirtualKeyboard { <# .SYNOPSIS Send to remote server key strokes to simulate. .PARAMETER KeyChain A string representing character(s) to simulate remotely. .EXAMPLE Send-VirtualKeyboard -KeyChain "Hello, World" Send-VirtualKeyboard -KeyChain "{LEFT}" #> param ( [Parameter(Mandatory=$True)] [string] $KeyChain ) $aEvent = (New-KeyboardEvent -Keys $KeyChain) $outputEventSyncHash.Writer.WriteLine(($aEvent | ConvertTo-Json -Compress)) } $virtualDesktopSyncHash.VirtualDesktop.Form.Add_KeyPress( { if ($_.KeyChar) { switch -CaseSensitive ([string]$_.KeyChar) { "{" { $result = "{{}" } "}" { $result = "{}}" } "+" { $result = "{+}" } "^" { $result = "{^}" } "%" { $result = "{%}" } "~" { $result = "{~}" } "(" { $result = "{(}" } ")" { $result = "{)}" } "[" { $result = "{[}" } "]" { $result = "{]}" } default { $result = $_ } } Send-VirtualKeyboard -KeyChain $result } } ) $virtualDesktopSyncHash.VirtualDesktop.Form.Add_KeyDown( { $result = "" switch ($_.KeyValue) { # F Keys 112 { $result = "{F1}" } 113 { $result = "{F2}" } 114 { $result = "{F3}" } 115 { $result = "{F4}" } 116 { $result = "{F5}" } 117 { $result = "{F6}" } 118 { $result = "{F7}" } 119 { $result = "{F8}" } 120 { $result = "{F9}" } 121 { $result = "{F10}" } 122 { $result = "{F11}" } 123 { $result = "{F12}" } 124 { $result = "{F13}" } 125 { $result = "{F14}" } 126 { $result = "{F15}" } 127 { $result = "{F16}" } # Arrows 37 { $result = "{LEFT}" } 38 { $result = "{UP}" } 39 { $result = "{RIGHT}" } 40 { $result = "{DOWN}" } # Misc 92 { $result = "{WIN}" } 27 { $result = "{ESC}"} 33 { $result = "{PGUP}" } 34 { $result = "{PGDW}" } 36 { $result = "{HOME}" } 46 { $result = "{DELETE}" } 35 { $result = "{END}" } # Add other keys bellow } if ($result) { Write-Verbose $result Send-VirtualKeyboard -KeyChain $result } } ) $virtualDesktopSyncHash.VirtualDesktop.Picture.Add_MouseDown( { Send-VirtualMouse -X $_.X -Y $_.Y -Button $_.Button -Type ([MouseState]::Down) } ) $virtualDesktopSyncHash.VirtualDesktop.Picture.Add_MouseUp( { Send-VirtualMouse -X $_.X -Y $_.Y -Button $_.Button -Type ([MouseState]::Up) } ) $virtualDesktopSyncHash.VirtualDesktop.Picture.Add_MouseMove( { Send-VirtualMouse -X $_.X -Y $_.Y -Button $_.Button -Type ([MouseState]::Move) } ) $virtualDesktopSyncHash.VirtualDesktop.Picture.Add_MouseWheel( { $aEvent = New-Object PSCustomObject -Property @{ Id = [OutputEvent]::MouseWheel Delta = $_.Delta } $outputEventSyncHash.Writer.WriteLine(($aEvent | ConvertTo-Json -Compress)) } ) } Write-Verbose "Create runspace for desktop streaming..." $param = New-Object -TypeName PSCustomObject -Property @{ Client = $session.ClientDesktop VirtualDesktopSyncHash = $virtualDesktopSyncHash VirtualDesktopWidth = $virtualDesktopWidth VirtualDesktopHeight = $virtualDesktopHeight RequireResize = $requireResize TransportMode = [TransportMode] $session.SessionInformation.TransportMode } $newRunspace = (New-RunSpace -ScriptBlock $global:VirtualDesktopUpdaterScriptBlock -Param $param) $runspaces.Add($newRunspace) Write-Verbose "Create runspace for incoming events..." $param = New-Object -TypeName PSCustomObject -Property @{ Client = $session.ClientEvents VirtualDesktopSyncHash = $virtualDesktopSyncHash Clipboard = $Clipboard } $newRunspace = (New-RunSpace -ScriptBlock $global:IngressEventScriptBlock -Param $param) $runspaces.Add($newRunspace) Write-Verbose "Create runspace for outgoing events..." $param = New-Object -TypeName PSCustomObject -Property @{ OutputEventSyncHash = $outputEventSyncHash Clipboard = $Clipboard } $newRunspace = (New-RunSpace -ScriptBlock $global:EgressEventScriptBlock -Param $param) $runspaces.Add($newRunspace) Write-Verbose "Done. Showing Virtual Desktop Form." $null = $virtualDesktopSyncHash.VirtualDesktop.Form.ShowDialog() } finally { Write-Verbose "Free environement." if ($session) { $session.CloseSession() $session = $null } Write-Verbose "Free runspaces..." foreach ($runspace in $runspaces) { $null = $runspace.PowerShell.EndInvoke($runspace.AsyncResult) $runspace.PowerShell.Runspace.Dispose() $runspace.PowerShell.Dispose() } $runspaces.Clear() if ($virtualDesktopSyncHash.VirtualDesktop) { $virtualDesktopSyncHash.VirtualDesktop.Form.Dispose() } } } finally { $ErrorActionPreference = $oldErrorActionPreference $VerbosePreference = $oldVerbosePreference } } try { Export-ModuleMember -Function Remove-TrustedServer Export-ModuleMember -Function Clear-TrustedServers Export-ModuleMember -Function Get-TrustedServers Export-ModuleMember -Function Invoke-RemoteDesktopViewer } catch {} |