ScreenConnectUtils.psm1


# module variables
$ScriptPath = Split-Path (Get-Variable MyInvocation -Scope Script).Value.Mycommand.Definition -Parent
$ModuleName = (Get-Item (Get-Variable MyInvocation -Scope Script).Value.Mycommand.Definition).BaseName


# turn on informational messages
$InformationPreference = 'Continue'

# load localized language
Import-LocalizedData -BindingVariable 'Messages' -FileName 'Messages' -BaseDirectory (Join-Path $ScriptPath 'lang')

# load the config
if ( Test-Path "$ScriptPath\DefaultConfig.psd1" ) {

    # configuration parameters
    # we have to add it in the loader script so that it's available to the dot sourced files
    $ConfigSplat = @{
        Name        = $ModuleName
        CompanyName = 'Brooksworks'
        DefaultPath = "$ScriptPath\DefaultConfig.psd1"
    }

    # create config variable
    # we have to add it in the loader script so that it's available to the dot sourced files
    $Config = Import-Configuration @ConfigSplat

}

# import cached data
if ( Test-Path "$ScriptPath\data\*.json" ) {

    $Data = @{}
    Get-ChildItem -Path "$ScriptPath\data" -Filter '*.json' |
        ForEach-Object { $Data.($_.BaseName) = Get-Content $_.FullName | ConvertFrom-Json }

}
<#
.SYNOPSIS
 Extracts attachments from a JNLP file.
 
.PARAMETER Path
 Path to the JNLP file to search for attachments.
 
.PARAMETER Destination
 Path to destination directory.
 
#>

function Expand-JnlpAttachments {

    param(

        [Parameter(Mandatory)]
        [ValidatePattern('\.jnlp$')]
        $Path

    )

    $Path = [System.IO.FileInfo][string]( Resolve-Path $Path )

    Get-Content $Path -Raw |
        ForEach-Object { ([xml]$_).jnlp.'application-desc'.argument } |
        Where-Object { $_ -match 'JNLP_ATTACHMENTS' } |
        ForEach-Object { $_.Split('=',2)[1].Split(';') } |
        Select-Object @{N='Path';E={ Join-Path $Path.Directory.FullName $_.Split(',',2)[0] }}, @{N='Base64Data';E={ $_.Split(':',2)[1] }} |
        ForEach-Object {
            $DecodedData = [System.Convert]::FromBase64String( $_.Base64Data )
            $MemoryStream = New-Object System.IO.MemoryStream ( , $DecodedData )
            $DeflateStream = New-Object System.IO.Compression.DeflateStream ( $MemoryStream, [System.IO.Compression.CompressionMode]::Decompress )
            $ByteList = New-Object collections.generic.list[byte]
            while ( ( $Byte = $DeflateStream.ReadByte() ) -ne -1 ) { $ByteList.Add( $Byte) }
            Set-Content -Encoding Byte -Value $ByteList.ToArray() -Path $_.Path
        }

}

function New-RemoteScheduledTask {

    param(

        [Parameter(Mandatory)]
        [string]
        $Execute,

        [string]
        $Argument,

        [string]
        $WorkingDirectory,

        [string]
        $TaskName = ( 'Deployment Task ({0}) - {1}' -f $env:USERNAME, (Get-Date -f 'yyyyMMddHHmmss') ),
    
        [Parameter(Mandatory)]
        [string]
        $ComputerName,

        [pscredential]
        $Credential,

        [switch]
        $Wait

    )

    $CredentialSplat = @{}
    if ( $Credential ) { $CredentialSplat.Credential = $Credential }

    $TaskActionSplat = @{}
    $TaskActionSplat.Execute = $Execute
    if ( $Argument ) { $TaskActionSplat.Argument = $Argument }
    if ( $WorkingDirectory ) { $TaskActionSplat.WorkingDirectory = $WorkingDirectory }

    $TaskStart = (Get-Date).AddSeconds(5)
    
    $TaskSplat = @{
        Action      = New-ScheduledTaskAction @TaskActionSplat
        Trigger     = New-ScheduledTaskTrigger -Once -At $TaskStart
        Description = 'Task created by PowerShell'
        Settings    = New-ScheduledTaskSettingsSet -RunOnlyIfNetworkAvailable -StartWhenAvailable -DeleteExpiredTaskAfter 5
        Principal   = New-ScheduledTaskPrincipal -UserID "NT AUTHORITY\SYSTEM" -LogonType ServiceAccount -RunLevel Highest
    }
    $Task = New-ScheduledTask @TaskSplat |
        %{ $_.Triggers[0].EndBoundary = $TaskStart.ToUniversalTime().AddSeconds(5).ToString('u').Replace(' ', 'T'); $_ }

    Invoke-Command -ComputerName $ComputerName @CredentialSplat -ScriptBlock {

        $Using:Task | Register-ScheduledTask -TaskName $Using:TaskName | %{

            Write-Verbose ( $Using:Messages.RegisteredScheduledTaskVerboseMessage -f $Using:TaskName, $env:COMPUTERNAME )

        }

        if ( $Using:Wait ) {

            Start-Sleep -Seconds 5

            while ( Get-ScheduledTask -TaskName $Using:TaskName -ErrorAction SilentlyContinue ) {

                for ( $i = 10; $i -ge 0; $i -- ) {

                    Write-Progress -Activity $Using:Messages.WaitingForScheduledTaskCompletionProgressActivity -Status ( $Using:Messages.WaitingForScheduledTaskCompletionProgressStatus -f $Using:TaskName ) -PercentComplete ( ( 10 - $i ) / 10 * 100 )

                    Start-Sleep -Seconds 1
                
                }
            
            }

        }

    }

}
<#
.SYNOPSIS
 Utility function to check if host is online.
#>

function Test-HostConnection {

    [CmdletBinding()]
    param(

        [string]
        $ComputerName

    )

    if ( $PSBoundParameters.Keys -notcontains 'ErrorAction' ) {

        $ErrorActionPreference = 'Stop'

    }

    Write-Verbose ( $Messages.CheckingHostConnectionVerboseMessage -f $ComputerName )

    # verify computer is responding

    if ( -not( Test-Connection -ComputerName $ComputerName -Count 1 -Quiet ) ) {

        Write-Error ( $Messages.HostConnectionFailedError -f $ComputerName )

        return $false

    }
    
    # check that port 445 is open

    $Socket= New-Object Net.Sockets.TcpClient
    $IAsyncResult= [IAsyncResult] $Socket.BeginConnect( $ComputerName, 445, $null, $null )
    $IAsyncResult.AsyncWaitHandle.WaitOne( 500, $true ) > $null
    $PortOpen = $Socket.Connected
    $Socket.close()

    if ( -not $PortOpen ) {

        Write-Error ( $Messages.HostPortConnectionFailedError -f $ComputerName )

        return $false

    }
    
    
    return $true

}

<#
.DESCRIPTION
 Connect to a ScreenConnect remote support session from PowerShell. Note that
 you must have the Guest Session starter extension enabled.
 
 See: https://docs.connectwise.com/ConnectWise_Control_Documentation/Supported_extensions/Productivity/Guest_Session_Starter
  
.PARAMETER ScreenConnectUri
 URI for ScreenConnect instance
  
.PARAMETER SessionName
 What should your session be named
  
.PARAMETER ScreenConnectPath
 Path to ScreenConnect files
#>

function Connect-SupportSession {

    param(

        [Parameter(Mandatory=$true)]
        [ValidatePattern('(?# must include http/https )^https?://.+')]
        [ValidateNotNullOrEmpty()]
        [string]
        $ScreenConnectUri,

        [ValidateNotNullOrEmpty()]
        [string]
        $SessionName = "PowerShell Session - $env:COMPUTERNAME",

        [ValidateNotNullOrEmpty()]
        [string]
        $ScreenConnectPath = ( Join-Path $env:TEMP 'ScreenConnectClient' )

    )

    $ErrorActionPreference = 'Stop'

    if ( $PSVersionTable.PSVersion.Major -lt 3 ) {

        throw 'Minimum supported version of PowerShell is 3.0'

    }

    $ScreenConnectPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($ScreenConnectPath)

    if ( -not( Test-Path -Path $ScreenConnectPath -PathType Container ) ) {

        New-Item -Path $ScreenConnectPath -ItemType Directory -Force > $null

    }

    $ConnectionParams = @{
        y = 'Guest'
        h = $null
        p = $null
        s = $null
        k = $null
        i = $SessionName
    }

    $InvokeWebRequestSplat = @{
        Uri             = '{0}/Script.ashx' -f $ScreenConnectUri.Trim('/')
        UseBasicParsing = $true
    }
    $ScreenConnectJS = Invoke-WebRequest @InvokeWebRequestSplat

    if ( $ScreenConnectJS.RawContent -match '"h":"(?<h>[^"]+)","p":(?<p>\d+),"k":"(?<k>[^"]+)"' ) {

        $ConnectionParams.h = $Matches.h
        $ConnectionParams.p = $Matches.p
        $ConnectionParams.k = [uri]::EscapeDataString($Matches.k)

    } else {

        Write-Error 'Could not parse connection params!'

    }

    $InvokeRestMethodSplat = @{
        Method      = 'Post'
        Uri         = '{0}/App_Extensions/2d4e908b-8471-431d-b4e0-2390f43bfe67/Service.ashx/CreateGuestSupportSession' -f $ScreenConnectUri.Trim('/')
        Body        = (ConvertTo-Json @($SessionName) -Compress)
        ContentType = 'application/json'
    }
    $ConnectionParams.s = Invoke-RestMethod @InvokeRestMethodSplat

    $ScreenConnectArguments = ( $ConnectionParams.Keys | %{ '{0}={1}' -f $_, $ConnectionParams.$_ } ) -join '&' -replace '^', '"?' -replace '$', '"'

    $ScreenConnectExe = Join-Path $ScreenConnectPath 'ScreenConnect.WindowsClient.exe'

    if ( -not (Test-Path -Path $ScreenConnectExe ) ) {

        $URIs = @(
            '{0}/Bin/ConnectWiseControl.ClientBootstrap.jnlp{1}' -f $ScreenConnectUri.Trim('/'), $ScreenConnectArguments.Trim('"')
            '{0}/Bin/ScreenConnect.Client.exe.jar' -f $ScreenConnectUri.Trim('/')
        )

        $URIs |
            ForEach-Object {@{ Uri = $_ ; OutFile = Join-Path $ScreenConnectPath ( Split-Path -Path ( $_ -replace '\?.*' ) -Leaf ) }} |
            ForEach-Object { Invoke-WebRequest @_ }

        Add-Type -Assembly System.IO.Compression.Filesystem

        [System.IO.Compression.ZipFile]::ExtractToDirectory( "$ScreenConnectPath\ScreenConnect.Client.exe.jar", "$ScreenConnectPath" )

        Expand-JnlpAttachments -Path "$ScreenConnectPath\ConnectWiseControl.ClientBootstrap.jnlp"
    
    }

    if ( Test-Path -Path $ScreenConnectExe ) {

        Start-Process -FilePath $ScreenConnectExe -ArgumentList $ScreenConnectArguments

    } else {

        Write-Error 'Could not locate ScreenConnect.WindowsClient.exe'

    }

}

<#
.SYNOPSIS
    Attempts to install ScreenConnect Host Client on a remote machine.
.PARAMETER Computer
    Computer(s) to attempt to install.
.PARAMETER Credential
    PSCredential object to use for authentication.
.PARAMETER Username
    The plaintext username to use for authentication. Defaults to 'Administrator'.
.PARAMETER Password
    The plaintext password to use for authentication.
.OUTPUTS
    No output.
#>

function Install-HostClient {

    [CmdletBinding(SupportsShouldProcess)]
    param(

        [parameter(Mandatory=$true, Position=1, ValueFromPipeline=$True)]
        [string[]]
        $Computer,

        [Parameter(Mandatory=$true, Position=2)]
        [ValidatePattern('(?# must be an EXE or MSI )\.(exe|msi)$')]
        [string]
        $Installer,

        [pscredential]
        $Credential = [pscredential]::Empty

    )

    begin {

        if ( $PSBoundParameters.Keys -notcontains 'InformationAction' ) {

            $InformationPreference = 'Continue'

        }

        if ( $PSBoundParameters.Keys -notcontains 'ErrorAction' ) {
            
            $ErrorActionPreference = 'Stop'

        }

        Get-Command -Name PsExec.exe > $null

        $CredentialSplat = @{}
        if ( $Credential -ne [pscredential]::Empty ) { $CredentialSplat.Credential = $Credential }

        $InstallerPath =  Resolve-Path $Installer |
            Get-Item

        $ScheduledTaskSplat = switch ( $InstallerPath.Extension ) {

            '.exe' {@{
                Execute          = Join-Path 'C:\_ScreenConnectDeployment' $InstallerPath.Name
                WorkingDirectory = 'C:\_ScreenConnectDeployment'
            }}

            '.msi' {@{
                Execute          = 'C:\Windows\System32\msiexec.exe'
                Argument         = '/i {0} /qn' -f ( Join-Path 'C:\_ScreenConnectDeployment' $InstallerPath.Name )
                WorkingDirectory = 'C:\_ScreenConnectDeployment'
            }}
        }

    }

    process {

        foreach ( $ComputerItem in $Computer ) {

            if ( -not( Test-HostConnection $ComputerItem ) ) { continue }

            Write-Verbose ( $Messages.MappingTemporaryDriveVerboseMessage -f "\\$ComputerItem\C$" )

            New-PSDrive -Name 'RemoteComputer' -PSProvider FileSystem -Root "\\$ComputerItem\C$" @CredentialSplat > $null

            if ( -not( Test-Path -Path 'RemoteComputer:\_ScreenConnectDeployment' ) ) {

                Write-Verbose ( $Messages.CreatingDeploymentDirectoryVerboseMessage -f 'C:\_ScreenConnectDeployment' )

                New-Item 'RemoteComputer:\_ScreenConnectDeployment' -ItemType Directory > $null

            }

            Write-Verbose $Messages.PushingInstallerFileVerboseMessage

            Copy-Item -Path $InstallerPath -Destination 'RemoteComputer:\_ScreenConnectDeployment\' -Force

            Write-Information ( $Messages.InvokingScreenConnectInstallerMessage -f $ComputerItem )

            New-RemoteScheduledTask @ScheduledTaskSplat -ComputerName $ComputerItem @CredentialSplat -Wait

            Write-Verbose ( $Messages.RemovingDeploymentDirectoryVerboseMessage -f 'C:\_ScreenConnectDeployment' )

            Remove-Item 'RemoteComputer:\_ScreenConnectDeployment' -Recurse -Confirm:$false -ErrorAction Continue

            Write-Verbose $Messages.UnMappingTemporaryDriveVerboseMessage

            Remove-PSDrive -Name 'RemoteComputer'

            Write-Information $Messages.InstallationFinishedMessage

        }

    }

}


# cleanup
$ExecutionContext.SessionState.Module.OnRemove = {}