Writing scripts for remote actions on Windows

If you need help performing the operations described in this article, please contact your Nexthink Certified Partner.

This article details the process of preparing Nexthink remote action scripts on Windows. The scripts are written in PowerShell, a Microsoft scripting language built on the Windows .NET Framework, and then signed with a certificate for security purposes. PowerShell scripts are suited for automating tasks and configuration management; They allow for remote actions to be run on employee devices.

The main use cases for remote actions are on-demand data collection from devices, self-healing tasks and modifying configuration settings.

The article assumes that the reader is familiar with PowerShell scripting.

For more information about writing scripts for remote actions on Community, refer to the following documentation:

Creating the script

Generic scripts and input variables

Generic scripts are useful in situations where a signed script requires customization. Modifying a signed script breaks its signature, but a generic script can be customized through parameters, keeping the signature intact.

Declare formal parameters at the beginning of a PowerShell script to make it generic. When editing the relevant remote action, the parameter values can be modified in the Nexthink web interface.

For example, to make a script that reads from a generic registry key, declare a parameter in the script that contains the path to the key in the registry. Several remote actions may use the same script to read from different registry keys by supplying a different path to the parameter in the script.

param(
    [string]$filePath,
    [string]$regPath
)

When uploading the script during the remote action configuration, the system recognizes the parameters of any imported PowerShell script and lists them in the Parameters section. Provide actual values to the parameters in the text input boxes displayed to the right of each parameter name.

Actual values are always passed to the script as text: if the script declares parameters with a type other than string, ensure that you provide values that the script can convert to their expected type.

Creating output variables

Executing a script may generate output that you want to store as on-demand data. Nexthink provides a .NET assembly (nxtremoteactions.dll) that is installed on an employee device at the same time as the Collector. The assembly includes a class called Nxt that provides the methods to write results to the data layer.

To use the Nxt class, add the following line at the beginning of a PowerShell script for remote actions:

Add-Type -Path $env:NEXTHINK\RemoteActions\nxtremoteactions.dll

Use the Nxt class methods to write the desired outputs. All write methods accept two arguments: the name of the output and the value to write. For instance, to write the size of a file to the data layer:

[Nxt]::WriteOutputSize("FileSize", $fileSize)

When uploading the script during the remote action configurations, the system recognizes the calls to write outputs in the script and lists the output variables under the Outputs section below the script text. Set the output's label to indicate how to refer to it in investigations and metrics.

The ending of each written method indicates the type of output. Find the list of available methods and the corresponding PowerShell type of the value to be written in the table below:

Nxt write methodPowerShell typeConstraints

WriteOutputString

[string]

0 - 1024 characters (output truncated if bigger)

WriteOutputBool

[bool]

true / false

WriteOutputUInt32

[uint32]

  • Min: 0

  • Max: 4 294 967 295

WriteOutputFloat

[float]

  • Min: -3.4E+38

  • Max: 3.4E+38

WriteOutputSize

[float]

  • Min: 0

  • Max: 3.4E+38

WriteOutputRatio

[float]

WriteOutputBitRate

[float]

WriteOutputDateTime

[DateTime]

DD.MM.YYYY@HH:MM

WriteOutputDuration

[TimeSpan]

  • Min: 0 ms

  • Max: 49 days

  • Precision in milliseconds

WriteOutputStringList

[string[]]

Same as string

Implementing campaigns

Combine remote actions with campaigns to help employees solve issues independently. Campaigns let you inform employees about the detection of an issue and guide them through its resolution.

To display a campaign on the desktop of an employee who is interacting with a device:

  • The campaign must have a Remote action trigger and be published.

  • The script of the remote action can be executed either:

    • In the context of the employee, if the action does not need any special privileges.

    • In the context of the local system account, if the action needs administrative privileges.

Obtaining a campaign identifier

The methods to run a campaign from a remote action require a campaign identifier to be passed as an argument. You can use both the campaign NQL ID (recommended) and the campaign UID (classic option).

Support for the NQL ID as an identifier requires Collector version 23.5 or later.

To pass the campaign identifier to a remote action, declare a parameter in the script of the remote action for each required campaign. Use the NQL ID (or UID) as the actual value for the parameter when editing the remote action.

For more information on how to get the NQL ID or the UID of a campaign, refer to the Triggering a campaign documentation.

Running a campaign from the script of a remote action

To interact with campaigns, the remote action script must load a .NET assembly (nxtcampaignaction.dll) that is installed on an employee device along with Collector. The assembly includes the class Nxt.CampaignAction which provides the methods of controlling the execution of a campaign and getting the responses from an employee.

To load the assembly, add the following line at the beginning of the script:

Add-Type -Path $env:NEXTHINK\RemoteActions\nxtcampaignaction.dll

The methods of Nxt.CampaignAction to control campaigns are the following:

[nxt.campaignaction]::RunCampaign(string campaignUid)

Run the campaign identified by the campaignUid and wait until the employee finishes answering the campaign. The campaignUid argument can contain either the UID or the NQL ID (recommended). Return the response in an object of type NxTrayResp.

[nxt.campaignaction]::(string campaignUid, int timeout)

Run the campaign identified by the campaignUid and wait until the employee finishes answering the campaign or after the specified amount of time defined by timeout (in seconds) elapses. The campaignUid argument can contain either the UID or the NQL ID (recommended). Return the response in an object of type NxTrayResp.

[nxt.campaignaction]::RunStandAloneCampaign(string campaignUid)

Run the campaign identified by the campaignUid. The campaignUid argument can contain either the NQL ID (recommended) or the UID.

string GetResponseStatus(NxTrayResp response)

Given a response object of type NxTrayResp, the method returns a string that reflects the status of the campaign. Possible values for the status:

  • fully: the employee has fully answered the campaign questions.

  • declined: the employee has declined to participate in the campaign.

  • postponed: the employee has agreed to participate in the campaign.

  • timeout: the system timed out the campaign before the employee finished answering it.

  • connectionfailed: the script was unable to connect to the Collector component that controls campaign notifications due to a technical error in communication between Collector components.

  • notificationfailed: the script was unable to display the campaign successfully, due to one of the following:

    • The campaign definition could not be retrieved from the platform because of a non-existing campaign or non-published campaign.

    • Another campaign is already being displayed to employees.

    • A non-urgent campaign cannot be shown due to the focus protection or do-not-disturb rules of Collector. Refer to the Limiting the reception rate of campaigns documentation for more information.

string[] GetResponseAnswer(NxTrayResp response, string questionLabel)

Given a response object of type NxTrayResp and the label that identifies a question in the campaign, the method returns the response given by the employee.

  • In the case of a single-answer question, the returned string array only has one element.

  • In the case of a multiple-answer question, the returned string array has as many elements as answers selected by the employee. Optional free text is ignored.

  • If the employee has not fully answered the campaign, for example, status is not fully, the returned string array is empty. Optional free text is ignored.

For security reasons, remote actions for self-help scenarios ignore the optional free text answers of multiple answer or opinion scale questions. It serves no use to include optional free text answers in campaigns to be used exclusively in self-help scenarios.

Encoding the script

PowerShell script files must be encoded in UTF-8 with byte order mark (BOM). BOM is a Unicode character that must be present at the beginning of the file, whose representation in UTF-8 is the following sequence of three bytes, in hexadecimal: EF BB BF.

Each line of code must end with the following character sequence in Windows: CR+LF.

Ensure proper encoding to avoid errors or nonfunctioning scripts.

Code examples

Calling a campaign

In this example, the remote action executes a basic campaign call by its ID, outputting a status message if successful or an error message if unsuccessful.

$result = [Nxt.CampaignAction]::RunCampaign($CampaignUid, $maxWaitTimeinSeconds)
$status = [Nxt.CampaignAction]::GetResponseStatus($result)
if ($status -eq "fully") {
        Write-Output "Campaign succeeded"
} else {
        Write-Output "Status is $status"
}
Accessing campaign responses

In this example, the remote action calls for campaign answer data and outputs it as an array. Each answer is represented by its corresponding numbered option. Note that PowerShell uses 0 to n-1 indexing.

# Function to get campaign response
function Get-CampaignResponse ([string]$CampaignId) {
    return [nxt.campaignaction]::RunCampaign($CampaignId, $CAMPAIGN_TIMEOUT)
}
# Function to get campaign status
function Get-CampaignResponseStatus ($Response) {
    return [nxt.campaignaction]::GetResponseStatus($Response)
}

# Function to get response answers
function Get-CampaignResponseAnswer ($Response, [string]$QuestionName) {
    return [nxt.campaignaction]::GetResponseAnswer($Response, $QuestionName)[0]
}

#get campaign response
$campaignResponse = $null
$campaignResponse = Get-CampaignResponse -CampaignId $campaignId

# Get campaign status
$status = $null
$status = Get-CampaignResponseStatus -Response $campaignResponse
Write-Host "The response status is $status"

# Get response answers
$answersArray = $null
$answer = Get-CampaignResponseAnswer -Response $campaignResponse -QuestionName "Question1"
Write-Host "The answer is $answer"
Running a campaign with a timeout

In this example, the remote action is set to run a campaign that times out and closes after a specified amount of time, using seconds as input.

# Run a campaign with timeout
# timeout is in seconds (100s or 00:01:40)

$campaignId = "#my_campaign_nql_id"
$timeout = 100

function Get-CampaignResponse ([string]$CampaignId) {
    return [nxt.campaignaction]::RunCampaign($CampaignId, $timeout)
}

function Get-CampaignResponseStatus ($Response) {
    return [nxt.campaignaction]::GetResponseStatus($Response)
}

$result = Get-CampaignResponse -CampaignId $campaignId -timeout $timeout
$status = Get-CampaignResponseStatus -Response $result
if ($status -eq "fully") {
        Write-Output "Campaign succeeded"
} else {
        Write-Output "Status is $status"
}
Running a non-blocking campaign

In this example, the remote action does not require user input and continues executing after triggering a campaign. The user can dismiss the campaign at any point. This is used mainly for providing users with information rather than obtaining data.

##### Running a non-blocking campaign #####

$mycampaignId = "#my_campaign_nql_id"
function Invoke-OperationCompletedCampaign ([string]$CampaignId) {
    [nxt.campaignaction]::RunStandAloneCampaign($CampaignId)
}

Invoke-OperationCompletedCampaign -CampaignId $mycampaignId
Opening application on specific campaign response

In this example, the remote action script loads the .dll file installed on a device alongside Collector, which acts as a bridge between Collector and any remote action executions. It issues commands from PowerShell scripts to Collector, allowing the use of specialized functions that begin with [Nxt.CampaignAction].

The remote action executes a campaign using the Nxt.CampaignAction]::RunCampaign function with the campaign ID and a timeout measured in seconds as input. Then, it collects the user's response or lack thereof and uses that data to determine a status. If the user responds yes, the remote action starts a process, in this case, Notepad.

Add-Type -Path "$env:NEXTHINK\RemoteActions\nxtcampaignaction.dll"

$CampaignUid  = "<NQL ID of a single-answer campaign>"
$maxWaitTimeinSeconds = 60

$result = [Nxt.CampaignAction]::RunCampaign($CampaignUid, $maxWaitTimeinSeconds)
$status = [Nxt.CampaignAction]::GetResponseStatus($result)

if ($status -eq "fully") {
    $questionName = "question1"
    $choiceName =[Nxt.CampaignAction]::GetResponseAnswer($result, $questionName)
    if ($choiceName -eq "yes") {
        # user has confirmed - let's do some actions:
        Start-Process notepad.exe
    }
}
Checking if a specific application is present on the device

In this example, the remote action checks if the application name you provide as input is present on the device using Kanopy:

#
# Input parameters definition
#
param(
    [Parameter(Mandatory = $true)][string]$application_name
)
# End of parameters definition

$env:Path = 'C:\Windows\system32;C:\Windows;C:\Windows\System32\Wbem;C:\Windows\System32\WindowsPowerShell\v1.0\'

#
# Constants definition
#
$ERROR_EXCEPTION_TYPE = @{Environment = '[Environment error]'
    Input = '[Input error]'
    Internal = '[Internal error]'
}
Set-Variable -Name 'ERROR_EXCEPTION_TYPE' -Option ReadOnly -Scope Script -Force

$LOCAL_SYSTEM_IDENTITY = 'S-1-5-18'
Set-Variable -Name 'LOCAL_SYSTEM_IDENTITY' -Option ReadOnly -Scope Script -Force

$REMOTE_ACTION_DLL_PATH = "$env:NEXTHINK\RemoteActions\nxtremoteactions.dll"
Set-Variable -Name 'REMOTE_ACTION_DLL_PATH' -Option ReadOnly -Scope Script -Force

$WINDOWS_VERSIONS = @{Windows7 = '6.1'
    Windows8 = '6.2'
    Windows81 = '6.3'
    Windows10 = '10.0'
    Windows11 = '10.0'
}
Set-Variable -Name 'WINDOWS_VERSIONS' -Option ReadOnly -Scope Script -Force



#
# Invoke Main
#
function Invoke-Main ([hashtable]$InputParameters) {
    $exitCode = 0
    $appPresent = $false
    try {
        Add-NexthinkRemoteActionDLL
        Test-RunningAsLocalSystem
        Test-MinimumSupportedOSVersion -WindowsVersion 'Windows8'
        Test-InputParameter -InputParameters $InputParameters

        $appPresent = Invoke-CheckApplcationExistance -appName $InputParameters.application_name
    } catch {
        Write-StatusMessage -Message $_
        $exitCode = 1
    } finally {
        Update-EngineOutputVariables -applicationPresent $appPresent
    }

    return $exitCode
}

#
# Template functions
#
function Add-NexthinkRemoteActionDLL {

    if (-not (Test-Path -Path $REMOTE_ACTION_DLL_PATH)) {
        throw "$($ERROR_EXCEPTION_TYPE.Environment) Nexthink Remote Action DLL not found. "
    }
    Add-Type -Path $REMOTE_ACTION_DLL_PATH
}

function Test-RunningAsLocalSystem {

    if (-not (Confirm-CurrentUserIsLocalSystem)) {
        throw "$($ERROR_EXCEPTION_TYPE.Environment) This script must be run as LocalSystem. "
    }
}

function Confirm-CurrentUserIsLocalSystem {

    $currentIdentity = Get-CurrentIdentity
    return $currentIdentity -eq $LOCAL_SYSTEM_IDENTITY
}

function Get-CurrentIdentity {

    return [security.principal.windowsidentity]::GetCurrent().User.ToString()
}

function Test-MinimumSupportedOSVersion ([string]$WindowsVersion, [switch]$SupportedWindowsServer) {
    $currentOSInfo = Get-OSVersionType
    $OSVersion = $currentOSInfo.Version -as [version]

    $supportedWindows = $WINDOWS_VERSIONS.$WindowsVersion -as [version]

    if (-not ($currentOSInfo)) {
        throw "$($ERROR_EXCEPTION_TYPE.Environment) This script could not return OS version. "
    }

    if ( $SupportedWindowsServer -eq $false -and $currentOSInfo.ProductType -ne 1) {
        throw "$($ERROR_EXCEPTION_TYPE.Environment) This script is not compatible with Windows Servers. "
    }

    if ( $OSVersion -lt $supportedWindows) {
        throw "$($ERROR_EXCEPTION_TYPE.Environment) This script is compatible with $WindowsVersion and later only. "
    }
}

function Get-OSVersionType {

    return Get-WindowsManagementData -Class Win32_OperatingSystem | Select-Object -Property Version,ProductType
}

function Get-WindowsManagementData ([string]$Class, [string]$Namespace = 'root/cimv2') {
    try {
        $query = [wmisearcher] "Select * from $Class"
        $query.Scope.Path = "$Namespace"
        $query.Get()
    } catch {
        throw "$($ERROR_EXCEPTION_TYPE.Environment) Error getting CIM/WMI information. Verify WinMgmt service status and WMI repository consistency. "
    }
}

function Write-StatusMessage ([psobject]$Message) {
    $exceptionMessage = $Message.ToString()

    if ($Message.InvocationInfo.ScriptLineNumber) {
        $version = Get-ScriptVersion
        if (-not [string]::IsNullOrEmpty($version)) {
            $scriptVersion = "Version: $version. "
        }

        $errorMessageLine = $scriptVersion + "Line '$($Message.InvocationInfo.ScriptLineNumber)': "
    }

    $host.ui.WriteErrorLine($errorMessageLine + $exceptionMessage)
}

function Get-ScriptVersion {

    $scriptContent = Get-Content $MyInvocation.ScriptName | Out-String
    if ($scriptContent -notmatch '<#[\r\n]{2}.SYNOPSIS[^\#\>]*(.NOTES[^\#\>]*)\#>') { return }

    $helpBlock = $Matches[1].Split([environment]::NewLine)

    foreach ($line in $helpBlock) {
        if ($line -match 'Version:') {
            return $line.Split(':')[1].Split('-')[0].Trim()
        }
    }
}

function Test-StringNullOrEmpty ([string]$ParamName, [string]$ParamValue) {
    if ([string]::IsNullOrEmpty((Format-StringValue -Value $ParamValue))) {
        throw "$($ERROR_EXCEPTION_TYPE.Input) '$ParamName' cannot be empty nor null. "
    }
}

function Format-StringValue ([string]$Value) {
    return $Value.Replace('"', '').Replace("'", '').Trim()
}

#
# Input parameter validation
#
function Test-InputParameter ([hashtable]$InputParameters) {
    Test-StringNullOrEmpty `
        -ParamName 'application_name' `
        -ParamValue $InputParameters.application_name
}

#
# application management
#
function Invoke-CheckApplcationExistance ([string]$appName) {

    $installedApps = Get-CimInstance -Query "SELECT * FROM Win32_Product WHERE Name LIKE '%$appName%'"

    if ($installedApps) {
        return $true
    } else {
        return $false
    }
}

#
# Nexthink Output management
#
function Update-EngineOutputVariables ([bool]$applicationPresent) {

        [nxt]::WriteOutputBool('application_present', $applicationPresent)
}

#
# Main script flow
#
[environment]::Exit((Invoke-Main -InputParameters $MyInvocation.BoundParameters))
Checking if a specific application is present on the device: error handling

In this example, the remote action uses the application log path and error code as input and then parses the logs for the specified error code. If this code is present, it outputs an error message:

#
# Input parameters definition
#
param(
    [Parameter(Mandatory = $true)][string]$application_log_path,
    [Parameter(Mandatory = $true)][string]$error_code
)
# End of parameters definition

$env:Path = 'C:\Windows\system32;C:\Windows;C:\Windows\System32\Wbem;C:\Windows\System32\WindowsPowerShell\v1.0\'

#
# Constants definition
#
$ERROR_EXCEPTION_TYPE = @{Environment = '[Environment error]'
    Input = '[Input error]'
    Internal = '[Internal error]'
}
Set-Variable -Name 'ERROR_EXCEPTION_TYPE' -Option ReadOnly -Scope Script -Force

$LOCAL_SYSTEM_IDENTITY = 'S-1-5-18'
Set-Variable -Name 'LOCAL_SYSTEM_IDENTITY' -Option ReadOnly -Scope Script -Force

$REMOTE_ACTION_DLL_PATH = "$env:NEXTHINK\RemoteActions\nxtremoteactions.dll"
Set-Variable -Name 'REMOTE_ACTION_DLL_PATH' -Option ReadOnly -Scope Script -Force

$WINDOWS_VERSIONS = @{Windows7 = '6.1'
    Windows8 = '6.2'
    Windows81 = '6.3'
    Windows10 = '10.0'
    Windows11 = '10.0'
}
Set-Variable -Name 'WINDOWS_VERSIONS' -Option ReadOnly -Scope Script -Force



#
# Invoke Main
#
function Invoke-Main ([hashtable]$InputParameters) {
    $exitCode = 0
    $outputs = @{
        'error_message' = "-"
        'error_found' = $false
    }
    try {
        Add-NexthinkRemoteActionDLL
        Test-RunningAsLocalSystem
        Test-MinimumSupportedOSVersion -WindowsVersion 'Windows8'
        Test-InputParameter -InputParameters $InputParameters

        $outputs = Invoke-CheckApplicationLogError -applicationLogPath $InputParameters.application_log_path -errorCode $InputParameters.error_code
    } catch {
        Write-StatusMessage -Message $_
        $exitCode = 1
    } finally {
        Update-EngineOutputVariables -OutputData $outputs
    }

    return $exitCode
}

#
# Template functions
#
function Add-NexthinkRemoteActionDLL {

    if (-not (Test-Path -Path $REMOTE_ACTION_DLL_PATH)) {
        throw "$($ERROR_EXCEPTION_TYPE.Environment) Nexthink Remote Action DLL not found. "
    }
    Add-Type -Path $REMOTE_ACTION_DLL_PATH
}

function Test-RunningAsLocalSystem {

    if (-not (Confirm-CurrentUserIsLocalSystem)) {
        throw "$($ERROR_EXCEPTION_TYPE.Environment) This script must be run as LocalSystem. "
    }
}

function Confirm-CurrentUserIsLocalSystem {

    $currentIdentity = Get-CurrentIdentity
    return $currentIdentity -eq $LOCAL_SYSTEM_IDENTITY
}

function Get-CurrentIdentity {

    return [security.principal.windowsidentity]::GetCurrent().User.ToString()
}

function Test-MinimumSupportedOSVersion ([string]$WindowsVersion, [switch]$SupportedWindowsServer) {
    $currentOSInfo = Get-OSVersionType
    $OSVersion = $currentOSInfo.Version -as [version]

    $supportedWindows = $WINDOWS_VERSIONS.$WindowsVersion -as [version]

    if (-not ($currentOSInfo)) {
        throw "$($ERROR_EXCEPTION_TYPE.Environment) This script could not return OS version. "
    }

    if ( $SupportedWindowsServer -eq $false -and $currentOSInfo.ProductType -ne 1) {
        throw "$($ERROR_EXCEPTION_TYPE.Environment) This script is not compatible with Windows Servers. "
    }

    if ( $OSVersion -lt $supportedWindows) {
        throw "$($ERROR_EXCEPTION_TYPE.Environment) This script is compatible with $WindowsVersion and later only. "
    }
}

function Get-OSVersionType {

    return Get-WindowsManagementData -Class Win32_OperatingSystem | Select-Object -Property Version,ProductType
}

function Get-WindowsManagementData ([string]$Class, [string]$Namespace = 'root/cimv2') {
    try {
        $query = [wmisearcher] "Select * from $Class"
        $query.Scope.Path = "$Namespace"
        $query.Get()
    } catch {
        throw "$($ERROR_EXCEPTION_TYPE.Environment) Error getting CIM/WMI information. Verify WinMgmt service status and WMI repository consistency. "
    }
}

function Write-StatusMessage ([psobject]$Message) {
    $exceptionMessage = $Message.ToString()

    if ($Message.InvocationInfo.ScriptLineNumber) {
        $version = Get-ScriptVersion
        if (-not [string]::IsNullOrEmpty($version)) {
            $scriptVersion = "Version: $version. "
        }

        $errorMessageLine = $scriptVersion + "Line '$($Message.InvocationInfo.ScriptLineNumber)': "
    }

    $host.ui.WriteErrorLine($errorMessageLine + $exceptionMessage)
}

function Get-ScriptVersion {

    $scriptContent = Get-Content $MyInvocation.ScriptName | Out-String
    if ($scriptContent -notmatch '<#[\r\n]{2}.SYNOPSIS[^\#\>]*(.NOTES[^\#\>]*)\#>') { return }

    $helpBlock = $Matches[1].Split([environment]::NewLine)

    foreach ($line in $helpBlock) {
        if ($line -match 'Version:') {
            return $line.Split(':')[1].Split('-')[0].Trim()
        }
    }
}

function Test-StringNullOrEmpty ([string]$ParamName, [string]$ParamValue) {
    if ([string]::IsNullOrEmpty((Format-StringValue -Value $ParamValue))) {
        throw "$($ERROR_EXCEPTION_TYPE.Input) '$ParamName' cannot be empty nor null. "
    }
}

function Format-StringValue ([string]$Value) {
    return $Value.Replace('"', '').Replace("'", '').Trim()
}

function Test-ParamIsInteger ([string]$ParamName, [string]$ParamValue) {
    $intValue = $ParamValue -as [int]
    if ([string]::IsNullOrEmpty($ParamValue) -or $null -eq $intValue) {
        throw "$($ERROR_EXCEPTION_TYPE.Input) Error in parameter '$ParamName'. '$ParamValue' is not an integer. "
    }
}

#
# Input parameter validation
#
function Test-InputParameter ([hashtable]$InputParameters) {
    Test-StringNullOrEmpty `
        -ParamName 'application_log_path' `
        -ParamValue $InputParameters.application_log_path 
    Test-ValidPath `
        -ParamName 'application_log_path' `
        -ParamValue $InputParameters.application_log_path
    Test-ParamIsInteger `
        -ParamName 'error_code' `
        -ParamValue $InputParameters.error_code
}

function Test-ValidPath ([string]$ParamName, [string]$ParamValue) {
    if (-not (Test-Path -Path $ParamValue)) {
        throw "$ParamName is not a valid path or is not accessible."
    }
}

#
# application management
#
function Invoke-CheckApplicationLogError ([string]$applicationLogPath, [string]$errorCode) {

    $returnValues = @{
        'error_message' = "-"
        'error_found' = $false
    }

    $errorMatches = Select-String -Path $applicationLogPath -Pattern $errorCode | Select-Object -First 1

    if ($errorMatches) {
        $errorLine =  $errorMatches.Line
        $errorMessage = $errorLine.Split(":")[1].Trim()
        $returnValues.error_message = $errorMessage
        $returnValues.error_found = $true
        
    } else {
        $returnValues.error_found = $false
    }

    return $returnValues
}

#
# Nexthink Output management
#
function Update-EngineOutputVariables ([hashtable]$outputData) {

        [nxt]::WriteOutputString('error_message', $outputData.error_message )
        [nxt]::WriteOutputBool('error_found', $outputData.error_found )
}

#
# Main script flow
#
[environment]::Exit((Invoke-Main -InputParameters $MyInvocation.BoundParameters))
Checking if a specific application is present on the device: error remediation

In this example, the remote action uses a campaign to inform the user the application either requires a restart if it is already running or starts it if it is not running:

#
# Input parameters definition
#
param(
    [Parameter(Mandatory = $true)][string]$initial_camapign_id,
    [Parameter(Mandatory = $true)][string]$final_campaign_id,
    [Parameter(Mandatory = $true)][string]$inform_failure_campaign_id
)
# End of parameters definition

$env:Path = 'C:\Windows\system32;C:\Windows;C:\Windows\System32\Wbem;C:\Windows\System32\WindowsPowerShell\v1.0\'

#
# Constants definition
#
$CAMPAIGN_DLL_PATH = "$env:NEXTHINK\RemoteActions\nxtcampaignaction.dll"
Set-Variable -Name 'CAMPAIGN_DLL_PATH' -Option ReadOnly -Scope Script -Force

$ERROR_EXCEPTION_TYPE = @{Environment = '[Environment error]'
    Input = '[Input error]'
    Internal = '[Internal error]'
}
Set-Variable -Name 'ERROR_EXCEPTION_TYPE' -Option ReadOnly -Scope Script -Force

$LOCAL_SYSTEM_IDENTITY = 'S-1-5-18'
Set-Variable -Name 'LOCAL_SYSTEM_IDENTITY' -Option ReadOnly -Scope Script -Force

$NQL_ID_FORMAT_REGEX = "^[#]*([a-zA-Z0-9_]+_)*[a-zA-Z0-9_#]*$"
Set-Variable -Name 'NQL_ID_FORMAT_REGEX' -Option ReadOnly -Scope Script -Force

$REMOTE_ACTION_DLL_PATH = "$env:NEXTHINK\RemoteActions\nxtremoteactions.dll"
Set-Variable -Name 'REMOTE_ACTION_DLL_PATH' -Option ReadOnly -Scope Script -Force

$WINDOWS_VERSIONS = @{Windows7 = '6.1'
    Windows8 = '6.2'
    Windows81 = '6.3'
    Windows10 = '10.0'
    Windows11 = '10.0'
}
Set-Variable -Name 'WINDOWS_VERSIONS' -Option ReadOnly -Scope Script -Force

$KANOPY_APPLICATION_PROCESS_NAME = 'kanopyagent'
Set-Variable -Name 'KANOPY_SERVICE_NAME' -Option ReadOnly -Scope Script -Force

$KANOPY_APPLICATION_EXECUTABLE_PATH = 'C:\ProgramData\Kanopy\KanopyAgent\KanopyAgent.exe'
Set-Variable -Name 'KANOPY_APPLICATION_EXECUTABLE_PATH' -Option ReadOnly -Scope Script -Force

#
# Invoke Main
#
function Invoke-Main ([hashtable]$InputParameters) {
    $exitCode = 0

    try {
        Add-NexthinkDLLs
        Test-RunningAsInteractiveUser
        Test-MinimumSupportedOSVersion -WindowsVersion 'Windows8'
        Test-InputParameter -InputParameters $InputParameters

        $outputs = Invoke-RemediationAction -InputParameters $InputParameters
    } catch {
        Write-StatusMessage -Message $_
        $exitCode = 1
    }

    return $exitCode
}

#
# Template functions
#
function Add-NexthinkDLLs {

    if (-not (Test-Path -Path $REMOTE_ACTION_DLL_PATH)) {
        throw "$($ERROR_EXCEPTION_TYPE.Environment) Nexthink Remote Action DLL not found. "
    }
    if (-not (Test-Path -Path $CAMPAIGN_DLL_PATH)) {
        throw "$($ERROR_EXCEPTION_TYPE.Environment) Nexthink Campaign DLL not found. "
    }
    Add-Type -Path $REMOTE_ACTION_DLL_PATH
    Add-Type -Path $CAMPAIGN_DLL_PATH
}

function Test-RunningAsInteractiveUser {

    if (Confirm-CurrentUserIsLocalSystem) {
        throw "$($ERROR_EXCEPTION_TYPE.Environment) This script must be run as InteractiveUser. "
    }
}

function Confirm-CurrentUserIsLocalSystem {

    $currentIdentity = Get-CurrentIdentity
    return $currentIdentity -eq $LOCAL_SYSTEM_IDENTITY
}

function Get-CurrentIdentity {

    return [security.principal.windowsidentity]::GetCurrent().User.ToString()
}

function Test-MinimumSupportedOSVersion ([string]$WindowsVersion, [switch]$SupportedWindowsServer) {
    $currentOSInfo = Get-OSVersionType
    $OSVersion = $currentOSInfo.Version -as [version]

    $supportedWindows = $WINDOWS_VERSIONS.$WindowsVersion -as [version]

    if (-not ($currentOSInfo)) {
        throw "$($ERROR_EXCEPTION_TYPE.Environment) This script could not return OS version. "
    }

    if ( $SupportedWindowsServer -eq $false -and $currentOSInfo.ProductType -ne 1) {
        throw "$($ERROR_EXCEPTION_TYPE.Environment) This script is not compatible with Windows Servers. "
    }

    if ( $OSVersion -lt $supportedWindows) {
        throw "$($ERROR_EXCEPTION_TYPE.Environment) This script is compatible with $WindowsVersion and later only. "
    }
}

function Get-OSVersionType {

    return Get-WindowsManagementData -Class Win32_OperatingSystem | Select-Object -Property Version,ProductType
}

function Get-WindowsManagementData ([string]$Class, [string]$Namespace = 'root/cimv2') {
    try {
        $query = [wmisearcher] "Select * from $Class"
        $query.Scope.Path = "$Namespace"
        $query.Get()
    } catch {
        throw "$($ERROR_EXCEPTION_TYPE.Environment) Error getting CIM/WMI information. Verify WinMgmt service status and WMI repository consistency. "
    }
}

function Write-StatusMessage ([psobject]$Message) {
    $exceptionMessage = $Message.ToString()

    if ($Message.InvocationInfo.ScriptLineNumber) {
        $version = Get-ScriptVersion
        if (-not [string]::IsNullOrEmpty($version)) {
            $scriptVersion = "Version: $version. "
        }

        $errorMessageLine = $scriptVersion + "Line '$($Message.InvocationInfo.ScriptLineNumber)': "
    }

    $host.ui.WriteErrorLine($errorMessageLine + $exceptionMessage)
}

function Get-ScriptVersion {

    $scriptContent = Get-Content $MyInvocation.ScriptName | Out-String
    if ($scriptContent -notmatch '<#[\r\n]{2}.SYNOPSIS[^\#\>]*(.NOTES[^\#\>]*)\#>') { return }

    $helpBlock = $Matches[1].Split([environment]::NewLine)

    foreach ($line in $helpBlock) {
        if ($line -match 'Version:') {
            return $line.Split(':')[1].Split('-')[0].Trim()
        }
    }
}

function Test-CampaignID ([string]$ParamName, [string]$ParamValue) {
    if ([string]::IsNullOrEmpty($ParamValue)) {
        throw "$($ERROR_EXCEPTION_TYPE.Input) Error in parameter '$ParamName'. Value cannot be null or empty. "
    }

    if (-not ($ParamValue -as [guid]) -and ($ParamValue -notmatch $NQL_ID_FORMAT_REGEX)) {
        throw "$($ERROR_EXCEPTION_TYPE.Input) Error in parameter '$ParamName'. Only UID or NQL ID values are accepted. "
    }
}

function Write-NxtLog ([string]$Message, [object]$Object) {
    if (Test-PowerShellVersion -MinimumVersion 5) {
        $currentDate = Get-Date -Format 'yyyy/MM/dd hh:mm:ss'
        if ($Object) {
            $jsonObject = $Object | ConvertTo-Json -Compress -Depth 100
            Write-Information -MessageData "$currentDate - $Message $jsonObject"
        } else {
            Write-Information -MessageData "$currentDate - $Message"
        }
    }
}

function Test-PowerShellVersion ([int]$MinimumVersion) {
    if ((Get-Host).Version.Major -ge $MinimumVersion) {
        return $true
    }
}

function Get-CampaignResponseTimeout ([string]$CampaignId, [int]$CampaignTimeout) {
    return [nxt.campaignaction]::RunCampaign($CampaignId,$CampaignTimeout)
}

function Get-CampaignResponseStatus ($Response) {
    return [nxt.campaignaction]::GetResponseStatus($Response)
}

function Get-CampaignResponseAnswer ($Response, [string]$QuestionName) {
    return [nxt.campaignaction]::GetResponseAnswer($Response, $QuestionName)[0]
}

function Invoke-OperationCompletedCampaign ([string]$CampaignId) {
    [nxt.campaignaction]::RunStandAloneCampaign($CampaignId)
}

#
# Input parameter validation
#
function Test-InputParameter ([hashtable]$InputParameters) {
    Test-CampaignID `
        -ParamName 'initial_camapign_id' `
        -ParamValue $InputParameters.initial_camapign_id 
    Test-CampaignID `
        -ParamName 'final_campaign_id' `
        -ParamValue $InputParameters.final_campaign_id
    Test-CampaignID `
        -ParamName 'inform_failure_campaign_id' `
        -ParamValue $InputParameters.inform_failure_campaign_id
}

#
# campaign response management
#

function Invoke-Campaign ([string]$CampaignId) {
    Write-NxtLog -Message "Calling $($MyInvocation.MyCommand)"

    $response = Get-CampaignResponseTimeout -CampaignId $CampaignId -CampaignTimeout 60
    $status = Get-CampaignResponseStatus -Response $response
    switch ($status) {
        'fully' {
            $answer = Get-CampaignResponseAnswer -Response $response -QuestionName 'question_label'
            if ($answer -eq 'yes_label') {
                return $true
            } elseif ($answer -eq 'no_label') {
                return $false
            } else {
                throw "Unexpected answer from the user: $answer. "
            }
        }
        'timeout' { throw "Timeout on getting an answer from the user. " }
        'declined' {throw "Campaign declined by user. " }
        'postponed' {throw "Campaign postponed by user. " }
        'connectionfailed' { throw "Unable to connect to the Collector component that controls campaign notifications. " }
        'notificationfailed' { throw "Unable to notify the Collector component that controls campaign notifications. " }
            default { throw "Failed to handle campaign response: $response. " }
        }
}

#
# application management
#
function Invoke-RemediationAction ([hashtable]$InputParameters) {

    $process = Get-Process -Name $KANOPY_APPLICATION_PROCESS_NAME -ErrorAction SilentlyContinue
    if ($process) {
        $userResponse = Invoke-Campaign -CampaignId $InputParameters.initial_camapign_id

        if ($userResponse -eq $true) {

            try {
                Stop-Process -Name $KANOPY_APPLICATION_PROCESS_NAME -Force -ErrorAction Stop | Out-Null
                Write-StatusMessage -Message "The Kanopy process was stopped successfully."
            } catch {
                Invoke-OperationCompletedCampaign -CampaignId $InputParameters.inform_failure_campaign_id
                throw "Failed to stop the process Kanopy. Error: $_"
            }

            Start-Sleep -Seconds 2

            try {
                Start-Process -Name $KANOPY_APPLICATION_PROCESS_NAME -Force -ErrorAction Stop | Out-Null
                Write-StatusMessage -Message "The Kanopy process was started successfully."
            } catch {
                Invoke-OperationCompletedCampaign -CampaignId $InputParameters.inform_failure_campaign_id
                throw "Failed to start the process Kanopy after stopping it. Error: $_"
            }

            Invoke-OperationCompletedCampaign -CampaignId $InputParameters.final_campaign_id
        } else {
            Write-StatusMessage -Message "The user declined the remediation action."
        }
    } else {
        Write-StatusMessage -Message "The Kanopy process was not running. Application will be started."

        try {
            Start-Process -FilePath $KANOPY_APPLICATION_EXECUTABLE_PATH -ErrorAction Stop
        } catch {
            throw "Failed to start the process Kanopy. Error: $_"
        }
        Start-Sleep -Seconds 2

        $processCheck = Get-Process -Name $KANOPY_APPLICATION_PROCESS_NAME -ErrorAction SilentlyContinue
        if ($processCheck) {
            Write-StatusMessage -Message "The Kanopy process was started successfully."
        } else {
            throw "The Kanopy process was not started successfully."
        }
    }

}

#
# Main script flow
#
[environment]::Exit((Invoke-Main -InputParameters $MyInvocation.BoundParameters))

Signing the script

Nexthink recommends signing all scripts in a production environment. Unsigned scripts should only be used in testing environments.

Obtaining a certificate

To sign your PowerShell script, use the Set-Authenticode command as follows:

  1. Acquire a code-signing certificate from:

    • The PowerShell certificate provider: $cert = Get-ChildItem -Path Cert:\CurrentUser\My -CodeSigningCert

    • A PFX file: $cert = Get-PfxCertificate -FilePath C:\Test\Mysign.pfx

  2. Sign the script for a remote action, for example remoteaction.ps1, using the certificate. Add a timestamp so it continues to work after the certificate expires. The example below uses the DigiCert timestamp server: Set-AuthenticodeSignature -FilePath .\remoteaction.ps1 -Certificate $cert -IncludeChain All -TimestampServer "http://timestamp.digicert.com"

  3. (Optional) Verify the signature in the script. Get-AuthenticodeSignature .\remoteaction.ps1 -Verbose | fl

When using a private Certification Authority (CA), ensure it uses the best OCSP practices to avoid server overload from caching.

Deploying the certificate to endpoints

The default policy (signed_trusted_or_nexthink) lets the official remote actions from Nexthink Library execute on the device without requiring any additional configuration.

If you choose to create and sign your own scripts for remote actions, add the signing certificate to the Local Computer > Trusted Publishers certificate store in Microsoft Windows.

If you use the stricted signed_trusted policy, you can choose to either re-sign library and system scripts with your own certificate, or to deploy the Nexthink code signing certificate to the Local Computer > Trusted Publishers certificate store in Microsoft Windows.

If you do not add the code-signing certificate to the Local Computer > Trusted Publishers store in Microsoft Windows then the system generates the following error: The remote action could not be executed: the script signature is invalid or the certificate is not trusted.

If your certificate was generated by a private CA whose root certificate is not already present in the Local Computer’s Trusted Root Certification Authorities certificate store of Windows, ensure it is added.

If you used an intermediate certificate to sign your scripts, include the full chain of intermediate certificates in the Local Computer’s Intermediate Certification Authorities certificate store:

  1. Log in to Microsoft Windows as an administrator.

  2. Press Win+R keys to open the Run dialog:

    1. Type in certlm.msc.

    2. Click OK.

  3. Click Yes to allow the program to make changes to your device.

  4. Right-click the name of the desired certification store, for example, Trusted Publishers, on the left-hand side list.

    1. Select All-tasks > Import... from the context menu to start the Certificate Import Wizard.

  5. Click Next to start the wizard.

  6. Click Browse and select your certificate file.

  7. Click Next.

  8. Click Next to accept the suggested certificate store in the Place all certificates in the following store dialog window.

  9. Verify the certificate you are importing and click Finish.

Nexthink recommends using an administration tool to deploy certificates on all devices simultaneously, for example, through Group Policy Objects (GPOs) or Intune Policy.

Maintaining the script

Comparison and validation

Before deploying a remote action script, it is possible to compare it with other scripts prepared by Nexthink. This step is optional but recommended if it is your first time preparing a script.

  1. In the Nexthink Library, select Content.

  2. Filter by Remote action.

  3. Navigate to the Remote Actions management page.

  4. Select any remote action script installed directly from the Nexthink Library that matches the target operating system.

  5. Export the script and compare its syntax with your script’s.

Error handling

Nexthink detects whether the execution of a remote action was successful or not based on the return value of the PowerShell process that ran the script:

  • An exit code value of 0 indicates a successful execution.

  • A non-zero value indicates an error.

Unhandled exceptions in PowerShell may cause the termination of a script without returning an appropriate exit code. To handle unexpected errors, Nexthink recommends beginning the body of all your scripts with the following code snippet:

 trap {
     $host.ui.WriteErrorLine($_.ToString())
     exit 1
 }

Place this default error handler underneath the inclusion of the necessary DLL dependencies, which follow the optional declaration of formal parameters.

Performance measurement

To measure the performance and resource consumption of your scripts, turn on the logs for Collector in debug mode with the help of the Collector configuration tool:

nxtcfg.exe /s logmode=2

The output is stored in the nxtcod.log file.

Last updated