Last active
February 28, 2024 07:50
-
-
Save JustinGrote/9e4b984495c3d878415e17714dbeb49b to your computer and use it in GitHub Desktop.
ScreenConnect Client
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#requires -version 7 | |
using namespace Microsoft.PowerShell.Commands | |
using namespace System.Text | |
$ErrorActionPreference = 'Stop' | |
#Suppress useless IRM Verbose output | |
$PSDefaultParameterValues['Invoke-RestMethod:Verbose'] = $false | |
$PSDefaultParameterValues['Invoke-WebRequest:Verbose'] = $false | |
$DebugPreference = 'SilentlyContinue' | |
#region CoreEngine | |
class CCContext { | |
[string]$Server | |
[string]$User | |
[WebRequestSession]$Session | |
[string]$AntiForgeryToken | |
} | |
[CCContext]$SCRIPT:CCContext = $null | |
function Connect-Sc { | |
[CmdletBinding(SupportsShouldProcess)] | |
param( | |
[Parameter(Mandatory)] | |
[string]$ComputerName, | |
[Parameter(Mandatory)] | |
[PSCredential]$Credential, | |
[switch]$Force, | |
[switch]$PassThru | |
) | |
if ($script:CCServerConnection -and (-not $Force -or -not $PassThru)) { | |
Write-Warning "Already connected to $($CCContext.Server) as $($CCContext.User). Use -Force to reconnect or -PassThru to connect to multiple servers" | |
return | |
} | |
$ComputerName = $ComputerName -replace ('http.*:\/\/', '') | |
$baseUri = "https://$ComputerName/" | |
#TODO: Unify this with Invoke-ScRequest | |
$tryLoginParams = @{ | |
Uri = "${baseUri}Services/AuthenticationService.ashx/TryLogin" | |
Authentication = 'Basic' | |
Credential = $Credential | |
SessionVariable = 'CCNewSession' | |
ContentType = 'application/json; charset=utf-8' | |
Method = 'POST' | |
Verbose = $false | |
} | |
if (!(Approve-Action $ComputerName "Connect with user $($credential.UserName)")) { return } | |
$response = Invoke-WebRequest @tryLoginParams | |
#If a auth cookie is not being set, there was a login issue. | |
if ($response.Headers.'set-cookie' -notmatch '^\.ASPXAUTH') { | |
#This only shows if there was a problem | |
if ($response.Headers.'X-Login-Result') { | |
Write-Error "Connection to $ComputerName using user $($credential.UserName) failed: $($response.Headers.'X-Login-Result')" | |
return | |
} | |
Write-Error "Connection to $ComputerName using user $($credential.UserName) failed for an unknown reason (the auth cookie was not detected)" | |
} | |
#Strip the auth headers out of the session | |
$CCNewSession.Headers = @{} | |
#Get the anti forgery token fron the frontpage, if present | |
$antiForgeryToken = (Invoke-RestMethod -Uri $baseUri -WebSession $CCNewSession -ErrorAction Stop | |
| Select-String -Pattern '(?<=antiForgeryToken":")(.*)(?=","isUserAdministrator)').Matches[0].Value | |
if ($antiForgeryToken) { | |
$CCNewSession.Headers.Add('x-anti-forgery-token', $antiForgeryToken) | |
} else { | |
Write-Warning 'No anti forgery token was found on the front page, some requests such as command execution may fail' | |
} | |
$newContext = [CCContext]@{ | |
Server = $ComputerName | |
User = $Credential.UserName | |
Session = $CCNewSession | |
AntiForgeryToken = $antiForgeryToken | |
} | |
Write-Verbose "Successfully connected to $($newContext.Server) as $($newContext.User)" | |
if ($PassThru) { | |
return $newContext | |
} else { | |
$script:CCContext = $newContext | |
} | |
} | |
function Clear-ScContext { | |
$script:CCContext = $null | |
} | |
function Invoke-ScRequest { | |
[CmdletBinding(DefaultParameterSetName = 'Endpoint')] | |
param( | |
[Parameter(Mandatory, ParameterSetName = 'Endpoint')][string]$Endpoint, | |
[Parameter(Mandatory, ParameterSetName = 'Service')][string]$Service, | |
[Parameter(Mandatory, ParameterSetName = 'Service')][string]$Action, | |
$Body, | |
[string]$Method = 'POST', | |
[Parameter(ValueFromPipeline, ValueFromPipelineByPropertyName)] | |
[ValidateNotNullOrEmpty()] | |
[CCContext]$Context = $SCRIPT:CCContext | |
) | |
if (-not $Endpoint) { | |
$Endpoint = "Services/$Service.ashx/$Action" | |
} | |
if (-not $Context) { | |
Write-Error 'No ConnectWise server connection found. Use Connect-Sc to establish a connection.' | |
return | |
} | |
$reqParams = @{ | |
Uri = "https://$($Context.Server)/$Endpoint" | |
Body = $Body | ConvertTo-Json -Depth 5 | |
WebSession = $Context.Session | |
Method = $Method | |
ContentType = 'application/json; charset=utf-8' | |
Verbose = $false | |
ResponseHeadersVariable = 'CCResponseHeaders' | |
Headers = @{ | |
'x-anti-forgery-token' = $Context.AntiForgeryToken | |
} | |
} | |
Write-Debug "Sending ${Method} request to $($reqParams.Uri)$(if ($Body) {[Environment]::NewLine + ($Body | ConvertTo-Json -Depth 5)})" | |
$response = Invoke-RestMethod @reqParams | |
if ($DebugPreference -eq 'Continue') { | |
Write-Debug "Received response from $($reqParams.Uri): $($response | ConvertTo-Json -Depth 5)" | |
} | |
return $response | |
} | |
#endregion CoreEngine | |
enum SessionType { | |
Support = 0 | |
Meeting = 1 | |
Access = 2 | |
} | |
filter Get-ScSession { | |
[CmdletBinding(DefaultParameterSetName = 'Filter')] | |
param( | |
[Parameter(ParameterSetName = 'Filter')][string]$Filter, | |
[Parameter(ParameterSetName = 'Filter')][string[]]$SessionGroup, | |
[SessionType]$Type = 'Access', | |
[int]$First = 1000, | |
[Parameter(ValueFromPipeline)][CCContext]$Context = $SCRIPT:CCContext, | |
[Alias('SessionId')] | |
[Parameter(ParameterSetName = 'Id', ValueFromPipelineByPropertyName)][Guid]$Id = [Guid]::Empty | |
) | |
Assert-ScContext $Context | |
$reqParams = @{ | |
Service = 'PageService' | |
Action = 'GetLiveData' | |
Context = $Context | |
Body = @( | |
@{ | |
HostSessionInfo = @{ | |
sessionType = $Type | |
sessionGroupPathParts = @() | |
findSessionID = $null | |
sessionLimit = $First | |
} | |
ActionCenterInfo = @{} | |
}, 0 | |
) | |
} | |
if ($Id -ne [Guid]::Empty) { | |
#Fixme: Using findSessionID doesn't seem to work so we just patch it into the search function | |
$reqParams.Body[0].HostSessionInfo.findSessionID = $Id | |
$IdSpecified = $true | |
} | |
if ($PSCmdlet.ParameterSetName -eq 'Filter') { | |
if ($filter) { | |
$reqParams.Body[0].HostSessionInfo.filter = $filter | |
} | |
if ($SessionGroup) { | |
$reqParams.Body[0].HostSessionInfo.sessionGroupPathParts = $SessionGroup | |
} | |
} | |
$response = Invoke-ScRequest @reqParams | |
if ($null -eq $Response.ResponseInfoMap.HostSessionInfo.Sessions) { | |
throw 'Unexpected Session Info Response (ResponseInfoMap.HostSessionInfo.Sessions is not present). This is probably a bug. Rerun with -Debug to see the response' | |
} | |
if (-not $Response.ResponseInfoMap.HostSessionInfo.Sessions) { | |
Write-Verbose "Get-ScSession: No sessions found for the specified filter: $filter" | |
} | |
$response.ResponseInfoMap.HostSessionInfo.Sessions | |
| Add-Member -PassThru -NotePropertyName 'Context' -NotePropertyValue $Context | |
| Set-ToStringProperty 'Name' | |
| Add-ScriptProperty 'ConnectedOperators' { $PSItem.ActiveConnections.ParticipantName | Where-Object { $PSItem } } | |
| Add-PSType 'ConnectWiseControl.Session' -DefaultDisplayPropertySet Name, GuestLoggedOnUserName, GuestOperatingSystemName, ConnectedOperators | |
} | |
filter Get-ScSessionDetail { | |
[CmdletBinding()] | |
param ( | |
[string]$Group = 'All Machines', | |
[Parameter(Mandatory, ValueFromPipeline)] | |
[PSTypeName('ConnectWiseControl.Session')]$Session | |
) | |
$reqParams = @{ | |
Service = 'PageService' | |
Action = 'GetSessionDetails' | |
Context = $Session.Context | |
Body = @(@($Group), $Session.SessionID.ToString()) | |
} | |
Invoke-ScRequest @reqParams | |
| Add-Member -PassThru -NotePropertyName 'SessionInfo' -NotePropertyValue $Session | |
| Add-PSType 'ConnectWiseControl.SessionDetail' | |
} | |
filter Invoke-ScCommand { | |
<# | |
.SYNOPSIS | |
Executes PowerShell Commands against a remote windows host. | |
#> | |
[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')] | |
param( | |
[Parameter(Position = 0, Mandatory, ParameterSetName = 'PowerShell')] | |
[scriptblock]$Command, | |
#Specify the timeout in milliseconds | |
[int]$TimeOutMs = [int]::MaxValue, | |
#Specifiy the Maximum length of the command output return | |
[int]$MaxLength = [int]::MaxValue, | |
#How long in seconds to wait between checking for command completion | |
[int]$WaitInterval = 1, | |
[string]$Group = 'All Machines', | |
[switch]$AsJob, | |
[Parameter(Mandatory, ValueFromPipeline)] | |
[PSTypeName('ConnectWiseControl.Session')]$Session | |
) | |
if ($Session.GuestOperatingSystemName -notmatch 'Windows') { | |
throw [NotSupportedException]"The session $($Session.Name) is not a Windows session, and cannot be used with this command" | |
} | |
#HACK: Connectwise does not give us a unique ID to correlate a command run output to its invocation, so we force output this in any PS script so that we can correlate the command with the response and avoid a race condition. This is necessary in case two commands with the same format are run in quick succession. For example: sleep (get-random -max 30) if run twice, would return the wrong result if comparing by date if the second invocation completes first. We will trim it off after invocation so the result will be the same. | |
$invocationHint = "[[INVOCATIONID:$(New-Guid)]]" | |
# Format command and inject an invocation ID so we can detect when this command completes | |
$FormattedCommand = @" | |
#!ps | |
#timeout=$TimeOutMs | |
#maxlength=$MaxLength | |
#Started using ScreenConnect PowerShell Module Invoke-ScCommand on $env:ComputerName | |
try { | |
& powershell -nop -noni -o xml -c { | |
$command | |
} | |
} finally { | |
'$invocationHint' | |
} | |
"@ | |
$reqParams = @{ | |
Service = 'PageService' | |
Action = 'AddSessionEvents' | |
Context = $Session.Context | |
Body = @( | |
$Group, | |
@( | |
@{ | |
SessionID = $Session.SessionID | |
EventType = 44 #Assuming this means "run command" | |
Data = $FormattedCommand | |
} | |
) | |
) | |
} | |
if (-not $PSCmdlet.ShouldProcess($Session.Name, "Execute command: $Command")) { return } | |
Invoke-ScRequest @reqParams | |
$waitCommandJob = { | |
param($CCModulePath, $Command, $Session, $invocationHint) | |
$CCModule = Import-Module $CCModulePath -PassThru | |
& ($CCModule) { | |
Wait-ScCommand -Session $Session -invocationHint $invocationHint -ScriptBlock $Command -AsJob | |
} | |
} | |
if ($AsJob) { | |
Start-ThreadJob -Name "SC-$($Session.Name):$($invocationHint)" -ScriptBlock $waitCommandJob -ArgumentList $PSScriptRoot, $Command, $Session, $invocationHint | |
} else { | |
Wait-ScCommand -Session $Session -invocationHint $invocationHint -ScriptBlock $Command | |
} | |
} | |
#region Helpers | |
filter Add-PSType { | |
param( | |
[string]$TypeName, | |
[string[]]$DefaultDisplayPropertySet | |
) | |
$PSItem.PSTypeNames.Insert(0, $TypeName) | |
if ($DefaultDisplayPropertySet -and -not (Get-TypeData $TypeName)) { | |
Update-TypeData -TypeName $TypeName -DefaultDisplayPropertySet $DefaultDisplayPropertySet | |
} | |
return $PSItem | |
} | |
filter Add-ScriptProperty { | |
param( | |
[string]$Name, | |
[ScriptBlock]$ScriptBlock | |
) | |
Add-Member -InputObject $PSItem -PassThru -MemberType ScriptProperty -Name $Name -Value $ScriptBlock | |
} | |
filter Set-ToStringProperty { | |
param( | |
[string]$Property | |
) | |
Add-Member -InputObject $PSItem -PassThru -MemberType ScriptMethod -Name 'ToString' -Value ([ScriptBlock]::Create("`$this.$Property")) -Force | |
} | |
function Assert-ScContext ($Context) { | |
if (-not $Context) { | |
Write-Error 'No ConnectWise server connection found. Use Connect-Sc to establish a connection.' | |
} | |
} | |
function Wait-ScCommand ($Session, $ScriptBlock, $invocationHint, [switch]$AsJob) { | |
$invocationHintRegex = [regex]::Escape($invocationHint) | |
#Wait for the command to complete | |
while ($true) { | |
Write-Verbose "Waiting for command completion for $invocationHint" | |
Start-Sleep -Seconds 1 | |
$sessionDetail = Get-ScSessionDetail -Session $Session | |
$commandOutput = $sessionDetail.Events | |
| Where-Object EventType -EQ 70 #Assuming this means "command output" | |
| Where-Object Data -Match $invocationHintRegex | |
if (-not $commandOutput) { | |
continue | |
} | |
if ($commandOutput.count -gt 1) { | |
Write-Warning "Multiple command completions were found for $invocationHint, this is probably a bug. Returning latest result but this might be a duplicate" | |
} | |
#Trim out the invocation hint and CLIXML headers | |
$cliXmlOutput = $commandOutput[-1].Data -replace [regex]::Escape($invocationHint) -replace [regex]::Escape('#< CLIXML') | |
$objOutput = try { [PSSerializer]::Deserialize($cliXmlOutput) } catch { "$PSItem" } | |
if (-not $AsJob) { | |
return $objOutput | |
} | |
$result = [PSCustomObject]@{ | |
Session = $Session | |
ScriptBlock = $ScriptBlock | |
Output = $objOutput | |
} | |
return $result | |
} | |
} | |
#Fixes an issue where ShouldProcess will not respect ConfirmPreference if -Debug is specified | |
function Approve-Action { | |
param( | |
[ValidateNotNullOrEmpty()][string]$Target, | |
[ValidateNotNullOrEmpty()][string]$Action, | |
$ThisCmdlet = $PSCmdlet, | |
[Bool]$Verbose = $VerbosePreference, | |
$Confirm = $ConfirmPreference, | |
$WhatIf = $WhatIfPreference | |
) | |
$ShouldProcessMessage = 'Performing the operation "{0}" on target "{1}"' -f $Action, $Target | |
if ($ENV:CI -ceq 'TRUE') { | |
Write-Verbose "$ShouldProcessMessage (Auto-Confirmed because `$ENV:CI is specified)" -Verbose:$Verbose | |
return $true | |
} | |
if ($Confirm -eq 'None' -and -not $WhatIf) { | |
Write-Verbose "$ShouldProcessMessage (Auto-Confirmed because `$ConfirmPreference is set to 'None')" -Verbose:$Verbose | |
return $true | |
} | |
return $ThisCmdlet.ShouldProcess($Target, $Action) | |
} | |
#endRegion Helpers |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment