Skip to content

Instantly share code, notes, and snippets.

@JustinGrote
Last active February 28, 2024 07:50
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save JustinGrote/9e4b984495c3d878415e17714dbeb49b to your computer and use it in GitHub Desktop.
Save JustinGrote/9e4b984495c3d878415e17714dbeb49b to your computer and use it in GitHub Desktop.
ScreenConnect Client
#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