diff --git a/PowerRemoteDesktop_Server/PowerRemoteDesktop_Server.psd1 b/PowerRemoteDesktop_Server/PowerRemoteDesktop_Server.psd1 index 95aa3c2..95fd391 100644 Binary files a/PowerRemoteDesktop_Server/PowerRemoteDesktop_Server.psd1 and b/PowerRemoteDesktop_Server/PowerRemoteDesktop_Server.psd1 differ diff --git a/PowerRemoteDesktop_Server/PowerRemoteDesktop_Server.psm1 b/PowerRemoteDesktop_Server/PowerRemoteDesktop_Server.psm1 index dde06a9..d53614f 100644 --- a/PowerRemoteDesktop_Server/PowerRemoteDesktop_Server.psm1 +++ b/PowerRemoteDesktop_Server/PowerRemoteDesktop_Server.psm1 @@ -1,8 +1,6 @@ <#------------------------------------------------------------------------------- Power Remote Desktop - Version 1.0 beta 2 - REL: January 2022. In loving memory of my father. Thanks for all you've done. @@ -52,15 +50,27 @@ Add-Type -Assembly System.Windows.Forms Add-Type -Assembly System.Drawing -Add-Type -MemberDefinition '[DllImport("User32.dll")] public static extern bool SetProcessDPIAware();' -Name User32 -Namespace W; +Add-Type -MemberDefinition '[DllImport("User32.dll")] public static extern bool SetProcessDPIAware();[DllImport("User32.dll")] public static extern int LoadCursorA(int hInstance, int lpCursorName);[DllImport("User32.dll")] public static extern bool GetCursorInfo(IntPtr pci);' -Name User32 -Namespace W; -$global:PowerRemoteDesktopVersion = "1.0.4.beta.5" +$global:PowerRemoteDesktopVersion = "1.0.5.beta.6" + +$global:HostSyncHash = [HashTable]::Synchronized(@{ + host = $host + ClipboardText = (Get-Clipboard -Raw) +}) enum TransportMode { Raw = 1 Base64 = 2 } +enum ClipboardMode { + Disabled = 1 + Receive = 2 + Send = 3 + Both = 4 +} + function Write-Banner { <# @@ -114,6 +124,27 @@ function Test-PasswordComplexity return ($PasswordCandidate -match $complexityRules) } +function New-RandomPassword +{ + <# + .SYNOPSIS + Generate a new secure password. + + .DESCRIPTION + Generate new password candidates until one candidate match complexity rules. + Generally only one iteration is enough but in some rare case it could be one or two more. + TODO: Better algorithm to avoid loop ? + #> + do + { + $authorizedChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#%^&*_" + $candidate = -join ((1..18) | ForEach-Object { Get-Random -Input $authorizedChars.ToCharArray() }) + + } until (Test-PasswordComplexity -PasswordCandidate $candidate) + + return $candidate +} + function New-DefaultX509Certificate { <# @@ -541,11 +572,6 @@ function Get-LocalMachineInformation } class ServerSession { - <# - .SYNOPSIS - PowerRemoteDesktop Session Class. - #> - [string] $Id = "" [string] $TiedAddress = "" [string] $Screen = "" @@ -560,7 +586,7 @@ class ServerSession { network. #> - $this.Id = (SHA512FromString -String (-join ((33..126) | Get-Random -Count 128 | ForEach-Object{[char] $_}))) + $this.Id = (SHA512FromString -String (-join ((1..128) | ForEach-Object {Get-Random -input ([char[]](33..126))}))) $this.TiedAddress = $RemoteAddress } @@ -569,19 +595,12 @@ class ServerSession { } } -class ClientIO { - <# - .SYNOPSIS - Extended version of TcpClient that automatically creates and releases - required streams with other useful methods. - - Supports SSL/TLS. - #> +class ClientIO { [System.Net.Sockets.TcpClient] $Client = $null [System.IO.StreamWriter] $Writer = $null [System.IO.StreamReader] $Reader = $null [System.Net.Security.SslStream] $SSLStream = $null - [TransportMode] $TransportMode = "Raw" + [TransportMode] $TransportMode ClientIO( @@ -634,17 +653,19 @@ class ClientIO { $false, $TLSVersion, $false - ) + ) if (-not $this.SSLStream.IsEncrypted) { throw "Could not established an encrypted tunnel with remote peer." } + $this.SSLStream.WriteTimeout = 5000 + Write-Verbose "Open communication channels..." $this.Writer = New-Object System.IO.StreamWriter($this.SSLStream) - $this.Writer.AutoFlush = $true + $this.Writer.AutoFlush = $true $this.Reader = New-Object System.IO.StreamReader($this.SSLStream) @@ -670,7 +691,7 @@ class ClientIO { Write-Verbose "New authentication challenge..." - $candidate = (-join ((33..126) | Get-Random -Count 128 | ForEach-Object{[char] $_})) + $candidate = -join ((1..128) | ForEach-Object {Get-Random -input ([char[]](33..126))}) $candidate = Get-SHA512FromString -String $candidate $challengeSolution = Resolve-AuthenticationChallenge -Candidate $candidate -Password $Password @@ -745,7 +766,7 @@ class ClientIO { } } - [ServerSession]Hello() { + [ServerSession]Hello([bool] $ViewOnly) { <# .SYNOPSIS Initialize a new session with remote Viewer. @@ -764,7 +785,8 @@ class ClientIO { $sessionInformation | Add-Member -MemberType NoteProperty -Name "TransportMode" -Value $this.TransportMode $sessionInformation | Add-Member -MemberType NoteProperty -Name "SessionId" -Value $session.Id - $sessionInformation | Add-Member -MemberType NoteProperty -Name "Version" -Value $global:PowerRemoteDesktopVersion + $sessionInformation | Add-Member -MemberType NoteProperty -Name "Version" -Value $global:PowerRemoteDesktopVersion + $sessionInformation | Add-Member -MemberType NoteProperty -Name "ViewOnly" -Value $ViewOnly Write-Verbose "Sending Session Information with Local System Information..." @@ -785,13 +807,21 @@ class ClientIO { } [string]RemoteAddress() { - <# - .SYNOPSIS - Returns the remote address of peer. - #> return $this.Client.Client.RemoteEndPoint.Address } + [int]RemotePort() { + return $this.Client.Client.RemoteEndPoint.Port + } + + [string]LocalAddress() { + return $this.Client.Client.LocalEndPoint.Address + } + + [int]LocalPort() { + return $this.Client.Client.LocalEndPoint.Port + } + [void]Close() { <# .SYNOPSIS @@ -821,18 +851,12 @@ class ClientIO { } class ServerIO { - <# - .SYNOPSIS - Extended version of TcpListener. - - Supports SSL/TLS. - #> - [string] $ListenAddress = "127.0.0.1" [int] $ListenPort = 2801 [bool] $TLSv1_3 = $false - [TransportMode] $TransportMode = "Raw" + [TransportMode] $TransportMode [string] $Password + [bool] $ViewOnly = $false [System.Net.Sockets.TcpListener] $Server = $null [System.IO.StreamWriter] $Writer = $null @@ -862,10 +886,13 @@ class ServerIO { X509 Certificate used for SSL/TLS encryption tunnel. .PARAMETER TLSv1_3 - Define whether or not SSL/TLS v1.3 must be used. + Define if TLS v1.3 must be used. .PARAMETER TransportMode - Define transport method for streams (Base64 or Raw) + Define stream transport method. + + .PARAMETER ViewOnly + Define if mouse / keyboard is authorized. #> [string] $ListenAddress, @@ -873,7 +900,8 @@ class ServerIO { [string] $Password, [TransportMode] $TransportMode, [System.Security.Cryptography.X509Certificates.X509Certificate2] $Certificate, - [bool] $TLSv1_3 + [bool] $TLSv1_3, + [bool] $ViewOnly ) { # Check again in current class just in case. if (-not (Test-PasswordComplexity -PasswordCandidate $Password)) @@ -886,6 +914,7 @@ class ServerIO { $this.TLSv1_3 = $TLSv1_3 $this.Password = $Password $this.TransportMode = $TransportMode + $this.ViewOnly = $ViewOnly if (-not $Certificate) { @@ -972,7 +1001,7 @@ class ServerIO { $this.TransportMode ) try - { + { Write-Verbose "New client socket connected: ""$($client.RemoteAddress())"". Proceed password authentication..." # STEP 1 : Authentication @@ -992,7 +1021,7 @@ class ServerIO { else { # STEP 2 : Create new Session - $this.Session = $client.Hello() + $this.Session = $client.Hello($this.ViewOnly) } } catch @@ -1029,16 +1058,11 @@ class ServerIO { } $global:DesktopStreamScriptBlock = { - <# - .SYNOPSIS - Threaded code block to send updates of local desktop to remote peer. - - This code is expected to be run inside a new PowerShell Runspace. - - .PARAMETER syncHash.Param.Client - A ClientIO Object containing an active connection. This is where, desktop updates will be - sent over network. - #> + + enum TransportMode { + Raw = 1 + Base64 = 2 + } function Get-DesktopImage { <# @@ -1093,9 +1117,9 @@ $global:DesktopStreamScriptBlock = { } $imageQuality = 100 - if ($syncHash.Param.ImageQuality -ge 0 -and $syncHash.Param.ImageQuality -lt 100) + if ($Param.ImageQuality -ge 0 -and $Param.ImageQuality -lt 100) { - $imageQuality = $syncHash.Param.ImageQuality + $imageQuality = $Param.ImageQuality } try { @@ -1112,7 +1136,7 @@ $global:DesktopStreamScriptBlock = { { try { - $desktopImage = Get-DesktopImage -Screen $syncHash.Param.Screen + $desktopImage = Get-DesktopImage -Screen $Param.Screen $imageStream = New-Object System.IO.MemoryStream @@ -1140,11 +1164,11 @@ $global:DesktopStreamScriptBlock = { $imageStream.position = 0 try { - switch ($syncHash.Param.Client.TransportMode) + switch ([TransportMode] $Param.Client.TransportMode) { - "Raw" - { - $syncHash.Param.Client.SSLStream.Write([BitConverter]::GetBytes([int32] $imageStream.Length) , 0, 4) # SizeOf(Int32) + ([TransportMode]::Raw) + { + $Param.Client.SSLStream.Write([BitConverter]::GetBytes([int32] $imageStream.Length) , 0, 4) # SizeOf(Int32) $totalBytesSent = 0 @@ -1166,7 +1190,7 @@ $global:DesktopStreamScriptBlock = { # (OPTIMIZATION IDEA): Try with BinaryStream to save the need of "byte[]"" buffer. $null = $imageStream.Read($buffer, 0, $buffer.Length) - $syncHash.Param.Client.SSLStream.Write($buffer, 0, $buffer.Length) + $Param.Client.SSLStream.Write($buffer, 0, $buffer.Length) $totalBytesSent += $bufferSize } until ($totalBytesSent -eq $imageStream.Length) @@ -1174,9 +1198,9 @@ $global:DesktopStreamScriptBlock = { break } - "Base64" - { - $syncHash.Param.Client.Writer.WriteLine( + ([TransportMode]::Base64) + { + $Param.Client.Writer.WriteLine( [System.Convert]::ToBase64String($imageStream.ToArray()) ) @@ -1221,17 +1245,7 @@ $global:DesktopStreamScriptBlock = { } } -$global:InputControlScriptBlock = { - <# - .SYNOPSIS - Threaded code block to receive remote orders (Ex: Keyboard Strokes, Mouse Clicks, Moves etc...) - - This code is expected to be run inside a new PowerShell Runspace. - - .PARAMETER syncHash.Client - A ClientIO Object containing an active connection. This is where, remote events will be - received and treated. - #> +$global:IngressEventScriptBlock = { Add-Type -MemberDefinition '[DllImport("user32.dll")] public static extern void mouse_event(int flags, int dx, int dy, int cButtons, int info);[DllImport("user32.dll")] public static extern bool SetCursorPos(int X, int Y);' -Name U32 -Namespace W; @@ -1250,10 +1264,12 @@ $global:InputControlScriptBlock = { MOUSEEVENTF_HWHEEL = 0x01000 } - enum InputCommand { + enum InputEvent { Keyboard = 0x1 MouseClickMove = 0x2 MouseWheel = 0x3 + KeepAlive = 0x4 + ClipboardUpdated = 0x5 } enum MouseState { @@ -1262,6 +1278,13 @@ $global:InputControlScriptBlock = { Move = 0x3 } + enum ClipboardMode { + Disabled = 1 + Receive = 2 + Send = 3 + Both = 4 + } + class KeyboardSim { <# .SYNOPSIS @@ -1298,56 +1321,62 @@ $global:InputControlScriptBlock = { } } - $keyboardSim = [KeyboardSim]::New() while ($true) - { + { try { - $jsonCommand = $syncHash.Param.Client.Reader.ReadLine() + $jsonEvent = $Param.Reader.ReadLine() } catch { # ($_ | Out-File "c:\temp\debug.txt") break - } - - if (-not $jsonCommand) - { break } + } - $command = $jsonCommand | ConvertFrom-Json + try + { + $aEvent = $jsonEvent | ConvertFrom-Json + } + catch { continue } - if (-not ($command.PSobject.Properties.name -match "Id")) + if (-not ($aEvent.PSobject.Properties.name -match "Id")) { continue } - switch ([InputCommand] $command.Id) + switch ([InputEvent] $aEvent.Id) { # Keyboard Input Simulation - "Keyboard" - { - if (-not ($command.PSobject.Properties.name -match "Keys")) + ([InputEvent]::Keyboard) + { + if ($Param.ViewOnly) + { continue } + + if (-not ($aEvent.PSobject.Properties.name -match "Keys")) { break } - $keyboardSim.SendInput($command.Keys) + $keyboardSim.SendInput($aEvent.Keys) break } # Mouse Move & Click Simulation - "MouseClickMove" + ([InputEvent]::MouseClickMove) { - if (-not ($command.PSobject.Properties.name -match "Type")) + if ($Param.ViewOnly) + { continue } + + if (-not ($aEvent.PSobject.Properties.name -match "Type")) { break } - switch ([MouseState] $command.Type) + switch ([MouseState] $aEvent.Type) { # Mouse Down/Up - {($_ -eq "Down") -or ($_ -eq "Up")} + {($_ -eq ([MouseState]::Down)) -or ($_ -eq ([MouseState]::Up))} { - [W.U32]::SetCursorPos($command.X, $command.Y) + [W.U32]::SetCursorPos($aEvent.X, $aEvent.Y) - $down = ($_ -eq "Down") + $down = ($_ -eq ([MouseState]::Down)) $mouseCode = [int][MouseFlags]::MOUSEEVENTF_LEFTDOWN if (-not $down) @@ -1355,7 +1384,7 @@ $global:InputControlScriptBlock = { $mouseCode = [int][MouseFlags]::MOUSEEVENTF_LEFTUP } - switch($command.Button) + switch($aEvent.Button) { "Right" { @@ -1389,9 +1418,12 @@ $global:InputControlScriptBlock = { } # Mouse Move - "Move" + ([MouseState]::Move) { - [W.U32]::SetCursorPos($command.X, $command.Y) + if ($Param.ViewOnly) + { continue } + + [W.U32]::SetCursorPos($aEvent.X, $aEvent.Y) break } @@ -1401,15 +1433,268 @@ $global:InputControlScriptBlock = { } # Mouse Wheel Simulation - "MouseWheel" { - [W.U32]::mouse_event([int][MouseFlags]::MOUSEEVENTF_WHEEL, 0, 0, $command.Delta, 0); + ([InputEvent]::MouseWheel) { + if ($Param.ViewOnly) + { continue } + + [W.U32]::mouse_event([int][MouseFlags]::MOUSEEVENTF_WHEEL, 0, 0, $aEvent.Delta, 0); break } + + # Clipboard Update + ([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 CursorType { + IDC_APPSTARTING = 32650 + IDC_ARROW = 32512 + IDC_CROSS = 32515 + IDC_HAND = 32649 + IDC_HELP = 32651 + IDC_IBEAM = 32513 + IDC_ICON = 32641 + IDC_NO = 32648 + IDC_SIZE = 32640 + IDC_SIZEALL = 32646 + IDC_SIZENESW = 32643 + IDC_SIZENS = 32645 + IDC_SIZENWSE = 32642 + IDC_SIZEWE = 32644 + IDC_UPARROW = 32516 + IDC_WAIT = 32514 + } + + enum OutputEvent { + KeepAlive = 0x1 + MouseCursorUpdated = 0x2 + ClipboardUpdated = 0x3 + } + + enum ClipboardMode { + Disabled = 1 + Receive = 2 + Send = 3 + Both = 4 + } + + function Initialize-Cursors + { + <# + .SYNOPSIS + Initialize different Windows supported mouse cursors. + + .DESCRIPTION + Unfortunately, there is not WinAPI to get current mouse cursor icon state (Ex: as a flag) + but only current mouse cursor icon (via its handle). + + One solution, is to resolve each supported mouse cursor handles (HCURSOR) with corresponding name + in a hashtable and then compare with GetCursorInfo() HCURSOR result. + #> + $cursors = @{} + + foreach ($cursorType in [CursorType].GetEnumValues()) { + $result = [W.User32]::LoadCursorA(0, [int]$cursorType) + + if ($result -gt 0) + { + $cursors[[string] $cursorType] = $result + } + } + + return $cursors + } + + function Get-GlobalMouseCursorIconHandle + { + <# + .SYNOPSIS + Return global mouse cursor handle. + .DESCRIPTION + For this project I really want to avoid using "inline c#" but only pure PowerShell Code. + I'm using a Hackish method to retrieve the global Windows cursor info by playing by hand + with memory to prepare and read CURSORINFO structure. + --- + typedef struct tagCURSORINFO { + DWORD cbSize; // Size: 0x4 + DWORD flags; // Size: 0x4 + HCURSOR hCursor; // Size: 0x4 (32bit) , 0x8 (64bit) + POINT ptScreenPos; // Size: 0x8 + } CURSORINFO, *PCURSORINFO, *LPCURSORINFO; + Total Size of Structure: + - [32bit] 20 Bytes + - [64bit] 24 Bytes + #> + + # sizeof(cbSize) + sizeof(flags) + sizeof(ptScreenPos) = 16 + $structSize = [IntPtr]::Size + 16 + + $cursorInfo = [System.Runtime.InteropServices.Marshal]::AllocHGlobal($structSize) + try + { + # ZeroMemory(@cursorInfo, SizeOf(tagCURSORINFO)) + for ($i = 0; $i -lt $structSize; $i++) + { + [System.Runtime.InteropServices.Marshal]::WriteByte($cursorInfo, $i, 0x0) + } + + [System.Runtime.InteropServices.Marshal]::WriteInt32($cursorInfo, 0x0, $structSize) + + if ([W.User32]::GetCursorInfo($cursorInfo)) + { + $hCursor = [System.Runtime.InteropServices.Marshal]::ReadInt64($cursorInfo, 0x8) + + return $hCursor + } + + <#for ($i = 0; $i -lt $structSize; $i++) + { + $offsetValue = [System.Runtime.InteropServices.Marshal]::ReadByte($cursorInfo, $i) + Write-Host "Offset: ${i} -> " -NoNewLine + Write-Host $offsetValue -ForegroundColor Green -NoNewLine + Write-Host ' (' -NoNewLine + Write-Host ('0x{0:x}' -f $offsetValue) -ForegroundColor Cyan -NoNewLine + Write-Host ')' + }#> + } + finally + { + [System.Runtime.InteropServices.Marshal]::FreeHGlobal($cursorInfo) + } + } + + 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.Writer.WriteLine(($Data | ConvertTo-Json -Compress)) + + return $true + } + catch + { + return $false + } + } + + $cursors = Initialize-Cursors + + $oldCursor = 0 + + $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() + } + } + + # Monitor for global mouse cursor change + # Update Frequently (Maximum probe time to be efficient: 30ms) + $currentCursor = Get-GlobalMouseCursorIconHandle + if ($currentCursor -ne 0 -and $currentCursor -ne $oldCursor) + { + $cursorTypeName = ($cursors.GetEnumerator() | Where-Object { $_.Value -eq $currentCursor }).Key + + $data = New-Object -TypeName PSCustomObject -Property @{ + Cursor = $cursorTypeName + } + + if (-not (Send-Event -AEvent ([OutputEvent]::MouseCursorUpdated) -Data $data)) + { break } + + $oldCursor = $currentCursor + } + + Start-Sleep -Milliseconds 30 + } + + $stopWatch.Stop() +} + function New-RunSpace { <# @@ -1437,20 +1722,17 @@ function New-RunSpace [PSCustomObject] $Param = $null ) - $syncHash = [HashTable]::Synchronized(@{}) - $syncHash.host = $host # For debugging purpose - - if ($Param) - { - $syncHash.Param = $Param - } - $runspace = [RunspaceFactory]::CreateRunspace() $runspace.ThreadOptions = "ReuseThread" $runspace.ApartmentState = "STA" $runspace.Open() - $runspace.SessionStateProxy.SetVariable("syncHash", $syncHash) + if ($Param) + { + $runspace.SessionStateProxy.SetVariable("Param", $Param) + } + + $runspace.SessionStateProxy.SetVariable("HostSyncHash", $global:HostSyncHash) $powershell = [PowerShell]::Create().AddScript($ScriptBlock) @@ -1524,6 +1806,17 @@ function Invoke-RemoteDesktopServer JPEG Compression level from 0 to 100 0 = Lowest quality. 100 = Highest quality. + + .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. + + .PARAMETER ViewOnly (Default: None) + If this switch is present, viewer wont be able to take the control of mouse (moves, clicks, wheel) and keyboard. + Useful for view session only. #> param ( @@ -1535,12 +1828,12 @@ function Invoke-RemoteDesktopServer # Or [string] $EncodedCertificate = "", # 2 - [TransportMode] $TransportMode = "Raw", - [switch] $TLSv1_3, - + [TransportMode] $TransportMode = [TransportMode]::Raw, + [switch] $TLSv1_3, [switch] $DisableVerbosity, - - [int] $ImageQuality = 100 + [int] $ImageQuality = 100, + [ClipboardMode] $Clipboard = [ClipboardMode]::Both, + [switch] $ViewOnly ) @@ -1585,10 +1878,7 @@ function Invoke-RemoteDesktopServer if (-not $Password) { - $Password = ( - # a-Z, 0-9, !@#%^&*_ - -join ((48..57) + (64..90) + 35 + (37..38) + 33 + 42 + 94 + 95 + (97..122) | Get-Random -Count 18 | ForEach-Object{[char] $_}) - ) + $Password = New-RandomPassword Write-Host -NoNewLine "Server password: """ Write-Host -NoNewLine ${Password} -ForegroundColor green @@ -1613,14 +1903,15 @@ function Invoke-RemoteDesktopServer $Certificate = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2 if ($CertificateFile) { - $Certificate.Import($CertificateFile) + $Certificate = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2 $CertificateFile } else { - $Certificate.Import([Convert]::FromBase64String($EncodedCertificate)) + $Certificate = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2 @(, [Convert]::FromBase64String($EncodedCertificate)) } } + Write-Verbose $TransportMode # Create new server and listen $server = [ServerIO]::New( $ListenAddress, @@ -1628,7 +1919,8 @@ function Invoke-RemoteDesktopServer $Password, $TransportMode, $Certificate, - $TLSv1_3 + $TLSv1_3, + $ViewOnly ) $server.Listen() @@ -1647,7 +1939,7 @@ function Invoke-RemoteDesktopServer # Otherwise a Timeout Exception will be raised. # Actually, if someone else decide to connect in the mean time it will interrupt the whole session, # Remote Viewer will then need to establish a new session from scratch. - $clientControl = $server.PullClient(10 * 1000); + $clientEvents = $server.PullClient(10 * 1000); # Grab desired screen to capture $screen = [System.Windows.Forms.Screen]::AllScreens | Where-Object -FilterScript { $_.DeviceName -eq $server.Session.Screen } @@ -1661,20 +1953,36 @@ function Invoke-RemoteDesktopServer $newRunspace = (New-RunSpace -ScriptBlock $global:DesktopStreamScriptBlock -Param $param) $runspaces.Add($newRunspace) + + # Notice: In current PowerRemoteDesktop Protocol design, Client wont Read or Write simultaneously from different + # threads. Sockets allow to Read and Write at the same time but not Read or Write at the same + # time. + + # If protocol change and require simultaneously Read or Write from different threads + # I will need to implement a synchronization mechanism to avoid conflicts like Synchronized Hashtables. + + # Create Runspace #2 for Incoming Events. + $param = New-Object -TypeName PSCustomObject -Property @{ + Reader = $clientEvents.Reader + Clipboard = $Clipboard + ViewOnly = $ViewOnly + } - # Create Runspace #2 for Input Control. - $param = New-Object -TypeName PSCustomObject -Property @{ - Client = $clientControl + $newRunspace = (New-RunSpace -ScriptBlock $global:IngressEventScriptBlock -Param $param) + $runspaces.Add($newRunspace) + + # Create Runspace #3 for Outgoing Events + $param = New-Object -TypeName PSCustomObject -Property @{ + Writer = $clientEvents.Writer + Clipboard = $Clipboard } - $newRunspace = (New-RunSpace -ScriptBlock $global:InputControlScriptBlock -Param $param) + $newRunspace = (New-RunSpace -ScriptBlock $global:EgressEventScriptBlock -Param $param) $runspaces.Add($newRunspace) # Waiting for Runspaces to finish their jobs. while ($true) - { - # TODO: Inspect TCP Table to probe for ghost connections and gracefully close them. - + { $completed = $true # Probe each existing runspaces @@ -1705,9 +2013,9 @@ function Invoke-RemoteDesktopServer $server.CloseSession() - if ($clientControl) + if ($clientEvents) { - $clientControl.Close() + $clientEvents.Close() } if ($clientDesktop) diff --git a/PowerRemoteDesktop_Viewer/PowerRemoteDesktop_Viewer.psd1 b/PowerRemoteDesktop_Viewer/PowerRemoteDesktop_Viewer.psd1 index 03f6f4d..6649e7b 100644 Binary files a/PowerRemoteDesktop_Viewer/PowerRemoteDesktop_Viewer.psd1 and b/PowerRemoteDesktop_Viewer/PowerRemoteDesktop_Viewer.psd1 differ diff --git a/PowerRemoteDesktop_Viewer/PowerRemoteDesktop_Viewer.psm1 b/PowerRemoteDesktop_Viewer/PowerRemoteDesktop_Viewer.psm1 index 876e363..fa051eb 100644 --- a/PowerRemoteDesktop_Viewer/PowerRemoteDesktop_Viewer.psm1 +++ b/PowerRemoteDesktop_Viewer/PowerRemoteDesktop_Viewer.psm1 @@ -1,9 +1,7 @@ <#------------------------------------------------------------------------------- Power Remote Desktop - Version 1.0 beta 2 - REL: January 2022. - + In loving memory of my father. Thanks for all you've done. you will remain in my heart forever. @@ -53,7 +51,12 @@ 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.4.beta.5" +$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 = @() @@ -62,6 +65,18 @@ $global:EphemeralTrustedServers = @() $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 { <# @@ -342,193 +357,7 @@ function Resolve-AuthenticationChallenge } } -$global:VirtualDesktopUpdaterScriptBlock = { - <# - .SYNOPSIS - Threaded code block to receive updates of remote desktop and update Virtual Desktop Form. - - This code is expected to be run inside a new PowerShell Runspace. - - .PARAMETER syncHash.Client - A ClientIO Class instance for handling desktop updates. - - .PARAMETER syncHash.Param.RequireResize - Tell if desktop image needs to be resized to fit viewer screen constrainsts. - - .PARAMETER syncHash.Param.VirtualDesktopWidth - The integer value representing remote screen width. - - .PARAMETER syncHash.Param.VirtualDesktopHeight - The integer value representing remote screen height. - - .PARAMETER syncHash.Param.VirtualDesktopForm - Virtual Desktop Object containing both Form and PaintBox. - - .PARAMETER syncHash.Param.TransportMode - Define desktop image transport mode: Raw or Base64. This value is defined by server following - its options. - #> - - 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] $syncHash.Param.TransportMode) - { - "Raw" - { - $buffer = New-Object -TypeName byte[] -ArgumentList 4 # SizeOf(Int32) - - $syncHash.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 - } - - $syncHash.Client.SSLStream.Read($buffer, 0, $bufferSize) - - $null = $stream.Write($buffer, 0, $buffer.Length) - - $totalBytesRead += $bufferSize - } until ($totalBytesRead -eq $totalBufferSize) - } - - "Base64" - { - [byte[]] $buffer = [System.Convert]::FromBase64String(($syncHash.Client.Reader.ReadLine())) - - $stream.Write($buffer, 0, $buffer.Length) - } - } - - $stream.Position = 0 - - if ($syncHash.Param.RequireResize) - { - #$image = [System.Drawing.Image]::FromStream($stream) - - $bitmap = New-Object -TypeName System.Drawing.Bitmap -ArgumentList $stream - - $syncHash.Param.VirtualDesktopForm.Picture.Image = Invoke-SmoothResize -OriginalImage $bitmap -NewWidth $syncHash.Param.VirtualDesktopWidth -NewHeight $syncHash.Param.VirtualDesktopHeight - } - else - { - $syncHash.Param.VirtualDesktopForm.Picture.Image = [System.Drawing.Image]::FromStream($stream) - } - } - catch - { - $syncHash.Param.host.UI.WriteLine($_) - break - } - finally - { - $stream.Close() - } - - } - } - finally - { - $syncHash.Param.VirtualDesktopForm.Form.Close() - } -} - class ClientIO { - <# - .SYNOPSIS - Extended version of TcpClient that automatically creates and releases - required streams with other useful methods. - - Supports SSL/TLS. - #> - [string] $RemoteAddress [int] $RemotePort [bool] $TLSv1_3 @@ -688,6 +517,8 @@ class ClientIO { 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 @@ -787,7 +618,8 @@ class ClientIO { (-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")) + (-not ($sessionInformation.PSobject.Properties.name -contains "Screens")) -or + (-not ($sessionInformation.PSobject.Properties.name -contains "ViewOnly")) ) { throw "Invalid session information data." @@ -801,6 +633,11 @@ class ClientIO { 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 @@ -908,14 +745,6 @@ class ClientIO { class ViewerSession { - <# - .SYNOPSIS - Viewer Session Class - - .DESCRIPTION - Contains methods to handle from A to Z the Power Remote Desktop Protocol. - #> - [PSCustomObject] $SessionInformation = $null [string] $ServerAddress = "127.0.0.1" [string] $ServerPort = 2801 @@ -923,7 +752,7 @@ class ViewerSession [bool] $TLSv1_3 = $false [ClientIO] $ClientDesktop = $null - [ClientIO] $ClientControl = $null + [ClientIO] $ClientEvents = $null ViewerSession( [string] $ServerAddress, @@ -1001,12 +830,12 @@ class ViewerSession Write-Verbose "Open secondary tunnel for input control..." - $this.ClientControl = [ClientIO]::new($this.ServerAddress, $this.ServerPort, $this.TLSv1_3) - $this.ClientControl.Connect() + $this.ClientEvents = [ClientIO]::new($this.ServerAddress, $this.ServerPort, $this.TLSv1_3) + $this.ClientEvents.Connect() - $this.ClientControl.Authentify($this.SecurePassword) + $this.ClientEvents.Authentify($this.SecurePassword) - $this.ClientControl.Hello($this.SessionInformation.SessionId) + $this.ClientEvents.Hello($this.SessionInformation.SessionId) Write-Verbose "New session successfully established with remote server." Write-Verbose "Session Id: $($this.SessionInformation.SessionId)" @@ -1033,13 +862,13 @@ class ViewerSession $this.ClientDesktop.Close() } - if ($this.ClientControl) + if ($this.ClientEvents) { - $this.ClientControl.Close() + $this.ClientEvents.Close() } $this.ClientDesktop = $null - $this.ClientControl = $null + $this.ClientEvents = $null $this.SessionInformation = $null @@ -1048,6 +877,375 @@ class ViewerSession } +$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 { <# @@ -1109,9 +1307,6 @@ function New-RunSpace Notice: the $host variable is used for debugging purpose to write on caller PowerShell Terminal. - .PARAMETER Client - A ClientIO object containing an active connection with a remote server. - .PARAMETER ScriptBlock A PowerShell block of code to be evaluated on the new Runspace. @@ -1123,30 +1318,23 @@ function New-RunSpace #> param( - [Parameter(Mandatory=$True)] - [ClientIO] $Client, - [Parameter(Mandatory=$True)] [ScriptBlock] $ScriptBlock, [PSCustomObject] $Param = $null ) - $syncHash = [HashTable]::Synchronized(@{}) - $syncHash.Client = $Client - $syncHash.host = $host # For debugging purpose - - if ($Param) - { - $syncHash.Param = $Param - } - $runspace = [RunspaceFactory]::CreateRunspace() $runspace.ThreadOptions = "ReuseThread" $runspace.ApartmentState = "STA" $runspace.Open() - $runspace.SessionStateProxy.SetVariable("syncHash", $syncHash) + if ($Param) + { + $runspace.SessionStateProxy.SetVariable("Param", $Param) + } + + $runspace.SessionStateProxy.SetVariable("HostSyncHash", $global:HostSyncHash) $powershell = [PowerShell]::Create().AddScript($ScriptBlock) @@ -1173,15 +1361,10 @@ function Invoke-RemoteDesktopViewer .PARAMETER ServerPort Remote Server Port. - .PARAMETER DisableInputControl - If set, this option disables control events on form (Mouse Clicks, Moves and Keyboard) - This option is generally set to true during development when connecting to local machine to avoid funny - things. - .PARAMETER SecurePassword SecureString Password object used to authenticate with remote server (Recommended) - Call "ConvertTo-SecureString –String "YouPasswordHere" -AsPlainText -Force" on this parameter to convert + Call "ConvertTo-SecureString -String "YouPasswordHere" -AsPlainText -Force" on this parameter to convert a plain-text String to SecureString. See example section. @@ -1194,7 +1377,14 @@ function Invoke-RemoteDesktopViewer Recommended if possible. .PARAMETER DisableVerbosity - Disable verbosity (not recommended) + 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) @@ -1204,16 +1394,18 @@ function Invoke-RemoteDesktopViewer #> param ( [string] $ServerAddress = "127.0.0.1", - [int] $ServerPort = 2801, - [switch] $DisableInputControl, + [int] $ServerPort = 2801, [switch] $TLSv1_3, [SecureString] $SecurePassword, [String] $Password, - [switch] $DisableVerbosity + [switch] $DisableVerbosity, + [ClipboardMode] $Clipboard = [ClipboardMode]::Both ) + [System.Collections.Generic.List[PSCustomObject]]$runspaces = @() + $oldErrorActionPreference = $ErrorActionPreference $oldVerbosePreference = $VerbosePreference try @@ -1256,9 +1448,11 @@ function Invoke-RemoteDesktopViewer Write-Verbose "Create WinForms Environment..." - $virtualDesktopForm = New-VirtualDesktopForm + $virtualDesktopSyncHash = [HashTable]::Synchronized(@{ + VirtualDesktop = New-VirtualDesktopForm + }) - $virtualDesktopForm.Form.Text = [string]::Format( + $virtualDesktopSyncHash.VirtualDesktop.Form.Text = [string]::Format( "Power Remote Desktop: {0}/{1} - {2}", $session.SessionInformation.Username, $session.SessionInformation.MachineName, @@ -1268,8 +1462,8 @@ function Invoke-RemoteDesktopViewer # Prepare Virtual Desktop $locationResolutionInformation = [System.Windows.Forms.Screen]::PrimaryScreen - $screenRect = $virtualDesktopForm.Form.RectangleToScreen($virtualDesktopForm.Form.ClientRectangle) - $captionHeight = $screenRect.Top - $virtualDesktopForm.Form.Top + $screenRect = $virtualDesktopSyncHash.VirtualDesktop.Form.RectangleToScreen($virtualDesktopSyncHash.VirtualDesktop.Form.ClientRectangle) + $captionHeight = $screenRect.Top - $virtualDesktopSyncHash.VirtualDesktop.Form.Top $localScreenWidth = $locationResolutionInformation.WorkingArea.Width $localScreenHeight = $locationResolutionInformation.WorkingArea.Height @@ -1313,18 +1507,23 @@ function Invoke-RemoteDesktopViewer } # Size Virtual Desktop Form Window - $virtualDesktopForm.Form.ClientSize = [System.Drawing.Size]::new($virtualDesktopWidth, $virtualDesktopHeight) + $virtualDesktopSyncHash.VirtualDesktop.Form.ClientSize = [System.Drawing.Size]::new($virtualDesktopWidth, $virtualDesktopHeight) # Center Virtual Desktop Form - $virtualDesktopForm.Form.Location = [System.Drawing.Point]::new( - (($localScreenWidth - $virtualDesktopForm.Form.Width) / 2), - (($localScreenHeight - $virtualDesktopForm.Form.Height) / 2) - ) + $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 $DisableInputControl) + if (-not $session.SessionInformation.ViewOnly) { - enum InputCommand { + enum OutputEvent { Keyboard = 0x1 MouseClickMove = 0x2 MouseWheel = 0x3 @@ -1336,12 +1535,12 @@ function Invoke-RemoteDesktopViewer Move = 0x3 } - function New-MouseCommand + function New-MouseEvent { <# .SYNOPSIS - Generate a new mouse command object to be sent to server. - This command is used to simulate mouse move and clicks. + 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. @@ -1356,9 +1555,9 @@ function Invoke-RemoteDesktopViewer The pressed button on mouse (Example: Left, Right, Middle) .EXAMPLE - New-MouseCommand -X 10 -Y 35 -Type "Up" -Button "Left" - New-MouseCommand -X 10 -Y 35 -Type "Down" -Button "Left" - New-MouseCommand -X 100 -Y 325 -Type "Move" + 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)] @@ -1372,30 +1571,27 @@ function Invoke-RemoteDesktopViewer ) return New-Object PSCustomObject -Property @{ - Id = [int][InputCommand]::MouseClickMove + Id = [OutputEvent]::MouseClickMove X = $X Y = $Y Button = $Button - Type = [int]$Type + Type = $Type } } - function New-KeyboardCommand + function New-KeyboardEvent { <# .SYNOPSIS - Generate a new keyboard command object to be sent to server. - This command is used to simulate keyboard strokes. + 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. - .TODO - Supports more complex keys (ARROWS ETC...) - .EXAMPLE - New-KeyboardCommand -Keys "Hello, World" - New-KeyboardCommand -Keys "t" + New-KeyboardEvent -Keys "Hello, World" + New-KeyboardEvent -Keys "t" #> param ( [Parameter(Mandatory=$true)] @@ -1403,7 +1599,7 @@ function Invoke-RemoteDesktopViewer ) return New-Object PSCustomObject -Property @{ - Id = [int][InputCommand]::Keyboard + Id = [OutputEvent]::Keyboard Keys = $Keys } } @@ -1415,7 +1611,7 @@ function Invoke-RemoteDesktopViewer 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 command is generated, it is immediately sent to remote server. + When event is generated, it is immediately sent to remote server. .PARAMETER X The position of virtual mouse in horizontal axis. @@ -1452,9 +1648,9 @@ function Invoke-RemoteDesktopViewer $X += $session.SessionInformation.Screen.X $Y += $session.SessionInformation.Screen.Y - $command = (New-MouseCommand -X $X -Y $Y -Button $Button -Type $Type) + $aEvent = (New-MouseEvent -X $X -Y $Y -Button $Button -Type $Type) - $session.ClientControl.Writer.WriteLine(($command | ConvertTo-Json -Compress)) + $outputEventSyncHash.Writer.WriteLine(($aEvent | ConvertTo-Json -Compress)) } function Send-VirtualKeyboard @@ -1475,12 +1671,12 @@ function Invoke-RemoteDesktopViewer [string] $KeyChain ) - $command = (New-KeyboardCommand -Keys $KeyChain) + $aEvent = (New-KeyboardEvent -Keys $KeyChain) - $session.ClientControl.Writer.WriteLine(($command | ConvertTo-Json -Compress)) + $outputEventSyncHash.Writer.WriteLine(($aEvent | ConvertTo-Json -Compress)) } - $virtualDesktopForm.Form.Add_KeyPress( + $virtualDesktopSyncHash.VirtualDesktop.Form.Add_KeyPress( { if ($_.KeyChar) { @@ -1496,7 +1692,8 @@ function Invoke-RemoteDesktopViewer ")" { $result = "{)}" } "[" { $result = "{[}" } "]" { $result = "{]}" } - Default { $result = $_ } + + default { $result = $_ } } Send-VirtualKeyboard -KeyChain $result @@ -1504,7 +1701,7 @@ function Invoke-RemoteDesktopViewer } ) - $virtualDesktopForm.Form.Add_KeyDown( + $virtualDesktopSyncHash.VirtualDesktop.Form.Add_KeyDown( { $result = "" switch ($_.KeyValue) @@ -1553,32 +1750,32 @@ function Invoke-RemoteDesktopViewer } ) - $virtualDesktopForm.Picture.Add_MouseDown( + $virtualDesktopSyncHash.VirtualDesktop.Picture.Add_MouseDown( { - Send-VirtualMouse -X $_.X -Y $_.Y -Button $_.Button -Type "Down" + Send-VirtualMouse -X $_.X -Y $_.Y -Button $_.Button -Type ([MouseState]::Down) } ) - $virtualDesktopForm.Picture.Add_MouseUp( + $virtualDesktopSyncHash.VirtualDesktop.Picture.Add_MouseUp( { - Send-VirtualMouse -X $_.X -Y $_.Y -Button $_.Button -Type "Up" + Send-VirtualMouse -X $_.X -Y $_.Y -Button $_.Button -Type ([MouseState]::Up) } ) - $virtualDesktopForm.Picture.Add_MouseMove( + $virtualDesktopSyncHash.VirtualDesktop.Picture.Add_MouseMove( { - Send-VirtualMouse -X $_.X -Y $_.Y -Button $_.Button -Type "Move" + Send-VirtualMouse -X $_.X -Y $_.Y -Button $_.Button -Type ([MouseState]::Move) } ) - $virtualDesktopForm.Picture.Add_MouseWheel( + $virtualDesktopSyncHash.VirtualDesktop.Picture.Add_MouseWheel( { - $command = New-Object PSCustomObject -Property @{ - Id = [int][InputCommand]::MouseWheel + $aEvent = New-Object PSCustomObject -Property @{ + Id = [OutputEvent]::MouseWheel Delta = $_.Delta } - $session.ClientControl.Writer.WriteLine(($command | ConvertTo-Json -Compress)) + $outputEventSyncHash.Writer.WriteLine(($aEvent | ConvertTo-Json -Compress)) } ) } @@ -1586,18 +1783,42 @@ function Invoke-RemoteDesktopViewer Write-Verbose "Create runspace for desktop streaming..." $param = New-Object -TypeName PSCustomObject -Property @{ - VirtualDesktopForm = $virtualDesktopForm + Client = $session.ClientDesktop + VirtualDesktopSyncHash = $virtualDesktopSyncHash VirtualDesktopWidth = $virtualDesktopWidth VirtualDesktopHeight = $virtualDesktopHeight RequireResize = $requireResize - TransportMode = $session.SessionInformation.TransportMode + TransportMode = [TransportMode] $session.SessionInformation.TransportMode } - $newRunspace = (New-RunSpace -Client $session.ClientDesktop -ScriptBlock $global:VirtualDesktopUpdaterScriptBlock -Param $param) + $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 = $virtualDesktopForm.Form.ShowDialog() + $null = $virtualDesktopSyncHash.VirtualDesktop.Form.ShowDialog() } finally { @@ -1610,16 +1831,19 @@ function Invoke-RemoteDesktopViewer $session = $null } - if ($newRunspace) - { - $null = $newRunspace.PowerShell.EndInvoke($newRunspace.AsyncResult) - $newRunspace.PowerShell.Runspace.Dispose() - $newRunspace.PowerShell.Dispose() - } + Write-Verbose "Free runspaces..." + + foreach ($runspace in $runspaces) + { + $null = $runspace.PowerShell.EndInvoke($runspace.AsyncResult) + $runspace.PowerShell.Runspace.Dispose() + $runspace.PowerShell.Dispose() + } + $runspaces.Clear() - if ($param.VirtualDesktopForm) + if ($virtualDesktopSyncHash.VirtualDesktop) { - $param.VirtualDesktopForm.Form.Dispose() + $virtualDesktopSyncHash.VirtualDesktop.Form.Dispose() } } } diff --git a/README.md b/README.md index 29978f5..643ab25 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ It doesn't rely on any existing Remote Desktop Application or Protocol to function. A serious advantage of this application is its nature (PowerShell) and its ease of use and installation. -This project demonstrate why PowerShell contains the word *Power*. It is unfortunately often an underestimated programming language that is not only resumed to running commands or being a more fancy replacement to the old Windows Terminal (cmd). +This project demonstrate why PowerShell contains the word *Power*. It is unfortunately often an underestimated programming language that is not only resumed to running commands or being a more fancy replacement to the old Windows command-line interpreter (cmd). Tested on: @@ -19,18 +19,23 @@ Tested on: ## Features -* Captures Remote Desktop Image with support of HDPI. -* Supports Mouse Click (Left, Right, Middle), Mouse Moves and Mouse Wheel. -* Supports Keystrokes Simulation (Sending remote key strokes) and few useful shortcuts. -* Traffic is encrypted by default using TLSv1.2 and optionnally using TLSv1.3 (TLS 1.3 might not be possible on older systems). -* Challenge-Based Password Authentication to protect access to server. -* Support custom SSL/TLS Certificate (File or Encoded in base64). If not specified, a default one is generated and installed on local machine (requires Administrator privileges) +https://user-images.githubusercontent.com/2520298/150001915-0982fb1c-a729-4b21-b22c-a58e201bfe27.mp4 + +* Remote Desktop Streaming with support of HDPI and Scaling. +* Remote Control: Mouse (Moves, Clicks, Wheel) and Key Strokes (Keyboard) +* **Secure**: Network traffic is encrypted using TLSv1.2 or 1.3. Access to server is granted via a challenge-based authentication mechanism (using user defined complex password). +* Network traffic encryption is using whether a default X509 Certificate (Requires Administrator) or your custom X509 Certificate. +* Server certificate fingerprint validation supported and optionally persistent between sessions. +* Clipboard text synchronization between Viewer and Server. +* Mouse cursor icon state is synchronized between Viewer (Virtual Desktop) and Server. +* Multi-Screen (Monitor) support. If remote computer have more than one desktop screen, you can choose which desktop screen to capture. +* View Only mode for demonstration. You can disable remote control abilities and just show your screen to remote peer. ## What is still beta -I consider this version as stable but I want to do more tests and have more feedback. +Version 1.0.5 Beta 6 is the last beta before final version. -I also want to implement few additional features before releasing the version 1. +No more features will be added in 1.x version, just optimization and bug fix. ## Installation @@ -156,12 +161,16 @@ Call `Invoke-RemoteDesktopViewer` Supported options: -* `ServerAddress`: Remote Server Address. -* `ServerPort`: Remote Server Port. -* `DisableInputControl`: If set to $true, this option disable control events on form (Mouse Clicks, Moves and Keyboard). This option is generally to true during development when connecting to local machine to avoid funny things. -* `Password`: Password used during server authentication. -* `DisableVerbosity`: Disable verbosity (not recommended) -* `TLSv1_3`: Define whether or not client must use SSL/TLS v1.3 to communicate with remote server. +* `ServerAddress` (Default: `127.0.0.1`): Remote server host/address. +* `ServerPort` (Default: `2801`): Remote server port. +* `Password` (Mandatory): Password used for server authentication. +* `DisableVerbosity` (Default: None): If this switch is present, verbosity will be hidden from console. +* `TLSv1_3` (Default: None): If this switch is present, viewer will use TLS v1.3 instead of TLS v1.2. Use this option only if both viewer and server support TLS v1.3. +* `Clipboard` (Default: `Both`): 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 @@ -175,15 +184,26 @@ Call `Invoke-RemoteDesktopServer` Supported options: -* `ListenAddress`: Define in which interface to listen for new viewer. -* `ListenPort`: Define in which port to listen for new viewer. -* `Password`: Define password used during authentication process. -* `CertificateFile`: A valid X509 Certificate (With Private Key) File. If set, this parameter is prioritize. -* `EncodedCertificate`: A valid X509 Certificate (With Private Key) encoded as a Base64 String. -* `TransportMode`: (Raw or Base64) Tell server how to send desktop image to remote viewer. Best method is Raw Bytes but I decided to keep the Base64 transport method as an alternative. -* `TLSv1_3`: Define whether or not TLS v1.3 must be used for communication with Viewer. -* `DisableVerbosity`: Disable verbosity (not recommended) -* `ImageQuality`: JPEG Compression level from 0 to 100. 0 = Lowest quality, 100 = Highest quality. +* `ListenAddress` (Default: `0.0.0.0`): Define in which interface to listen for new viewer. + * `0.0.0.0` : All interfaces + * `127.0.0.1`: Localhost interface + * `x.x.x.x`: Specific interface (Replace `x` with a valid network address) +* `ListenPort` (Default: `2801`): Define in which port to listen for new viewer. +* `Password` (**Mandatory**): Define password used during authentication process. +* `CertificateFile` (Default: **None**): A valid X509 Certificate (With Private Key) File. If set, this parameter is prioritize. +* `EncodedCertificate` (Default: **None**): A valid X509 Certificate (With Private Key) encoded as a Base64 String. +* `TransportMode`(Default: `Raw`): Define which method to use to transfer streams. + * `Raw`: Transfer streams as raw bytes (recommended) + * `Base64`: Transfer streams as base64 encoded string +* `TLSv1_3` (Default: None): If this switch is present, server will use TLS v1.3 instead of TLS v1.2. Use this option only if both viewer and server support TLS v1.3. +* `DisableVerbosity` (Default: None): If this switch is present, verbosity will be hidden from console. +* `ImageQuality` (Default: `100`): JPEG Compression level from 0 to 100. 0 = Lowest quality, 100 = Highest quality. +* `Clipboard` (Default: `Both`): 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. +* `ViewOnly` (Default: None): If this switch is present, viewer wont be able to take the control of mouse (moves, clicks, wheel) and keyboard. Useful for view session only. If no certificate option is set, then a default X509 Certificate is generated and installed on local machine (Requires Administrative Privilege) @@ -279,13 +299,19 @@ Detail Fingerprint ![Server Fingerprint Validation](Assets/server-fingerprint-validation.png) +### 18 January 2022 (1.0.5 Beta 6) + +* Multiple code improvements to support incoming / outgoing events. +* Global cursor state synchronization implemented (Now virtual desktop mouse cursor is the same as remote server). +* Password Generator algorithm fixed. +* Virtual keyboard `]` and `)` correctly sent and interpreted. +* Clipboard synchronization Viewer <-> Server added. +* Server support a new option to only show desktop (Mouse moves, clicks, wheel and keyboard control is disabled in this mode). + ### List of ideas and TODO * 🟒 Support Password Protected external Certificates. -* 🟒 Mutual Authentication for SSL/TLS (Client Certificate). -* 🟒 Synchronize Cursor State. -* 🟒 Synchronize Clipboard. -* 🟠 Keep-Alive system to implement Read / Write Timeout. +* 🟒 Mutual Authentication for SSL/TLS (Client Certificate). * 🟠 Listen for local/remote screen resolution update event. * πŸ”΄ Motion Update for Desktop Streaming (Only send and update changing parts of desktop). @@ -310,3 +336,7 @@ Jean-Pierre LESUEUR. For these external sites, PHROZEN SASU and / or Jean-Pierre 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. + + +https://user-images.githubusercontent.com/2520298/150001826-fde96ce9-6ad8-46e4-9d13-a2c4e3e77f44.mp4 + diff --git a/TestViewer.ps1 b/TestViewer.ps1 index 89fc039..956f593 100644 --- a/TestViewer.ps1 +++ b/TestViewer.ps1 @@ -2,4 +2,5 @@ Write-Output "This script is used during development phase. Never run this scrip Invoke-Expression -Command (Get-Content "PowerRemoteDesktop_Viewer\PowerRemoteDesktop_Viewer.psm1" -Raw) -Invoke-RemoteDesktopViewer -SecurePassword (ConvertTo-SecureString -String "Jade@123@Pwd" -AsPlainText -Force) -ServerAddress "127.0.0.1" \ No newline at end of file +Invoke-RemoteDesktopViewer -SecurePassword (ConvertTo-SecureString -String "Jade@123@Pwd" -AsPlainText -Force) -ServerAddress "127.0.0.1" +#Invoke-RemoteDesktopViewer -SecurePassword (ConvertTo-SecureString -String "Jade@123@Pwd" -AsPlainText -Force) -ServerAddress "172.31.115.183" \ No newline at end of file