PrtgSensorKit.psm1
|
#Region './Private/Format-PrtgMessage.ps1' -1 function Format-PrtgMessage { <# .SYNOPSIS Makes a string safe for a PRTG sensor message / error text. .DESCRIPTION PRTG does not support the number sign (#) in sensor messages and truncates messages at 2000 characters. This strips '#' and truncates so the emitted text always conforms. Reference: https://www.paessler.com/manuals/prtg/custom_sensors #> [CmdletBinding()] [OutputType([string])] param( [AllowNull()] [AllowEmptyString()] [string]$Text ) if ([string]::IsNullOrEmpty($Text)) { return '' } $clean = $Text.Replace('#', '') if ($clean.Length -gt 2000) { $clean = $clean.Substring(0, 2000) } return $clean } #EndRegion './Private/Format-PrtgMessage.ps1' 24 #Region './Private/Invoke-PrtgRelaunch.ps1' -1 function Invoke-PrtgRelaunch { # Re-launches the CALLING sensor script in another PowerShell host, then exits with its code. # Uses the caller's InvocationInfo (captured by the Restart-* function from the call stack) # rather than [Environment]::GetCommandLineArgs(): PRTG does not start sensors with # '-File script.ps1', so the process command line does not contain the script, but the caller's # InvocationInfo always exposes the script path and the parameters it was called with. # # Always relaunches via -File (not -Command): a script invoked as `powershell -Command "& 'x.ps1'"` # that calls `exit N` reports exit code 1, while -File reports N. -File preserves the exit code. [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$Executable, [Parameter(Mandatory = $true)] [System.Management.Automation.InvocationInfo]$Invocation ) $scriptPath = if ($Invocation.MyCommand -and $Invocation.MyCommand.Path) { $Invocation.MyCommand.Path } else { [string]$Invocation.InvocationName } if (-not $scriptPath) { throw "cannot determine the sensor script path to relaunch. Call Restart-* from a .ps1 file at the top level." } $newArgs = [System.Collections.Generic.List[string]]::new() '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass' | ForEach-Object { $newArgs.Add($_) } $newArgs.Add('-File') $newArgs.Add($scriptPath) # Forward the sensor's parameters so they survive the relaunch (PRTG "Parameters" field). foreach ($kv in $Invocation.BoundParameters.GetEnumerator()) { $val = $kv.Value if ($val -is [switch]) { if ($val.IsPresent) { $newArgs.Add("-$($kv.Key)") } } elseif ($val -is [System.Collections.IEnumerable] -and $val -isnot [string]) { $newArgs.Add("-$($kv.Key)") foreach ($item in $val) { $newArgs.Add([string]$item) } } else { $newArgs.Add("-$($kv.Key)") $newArgs.Add([string]$val) } } foreach ($u in $Invocation.UnboundArguments) { $newArgs.Add([string]$u) } & $Executable @newArgs exit $LASTEXITCODE } #EndRegion './Private/Invoke-PrtgRelaunch.ps1' 51 #Region './Private/PrtgState.ps1' -1 # Module-scope sensor output state. In the built single-file module every source file shares # one script scope, so $script:OutputObject is visible to all functions without touching the # caller's session. Clear-PrtgOutput re-initializes it (needed between runs and in tests). $script:OutputObject = [PSCustomObject]@{ prtg = [PSCustomObject]@{ result = [System.Collections.ArrayList]@() text = '' } } #EndRegion './Private/PrtgState.ps1' 10 #Region './Private/Set-PrtgSecretAcl.ps1' -1 function Set-PrtgSecretAcl { # Locks a secret file or its folder down to the account that created it, Administrators, and # SYSTEM. DPAPI already restricts *decryption* to the saving account; this is defence in depth # so other non-admin users cannot even read the encrypted blob. Windows-only (uses NTFS ACLs). [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseCompatibleCommands', '', Justification = 'Get-Acl/Set-Acl are Windows-only by design; this helper is only ever called on Windows (Save-PrtgSecret guards the call with Test-PrtgWindows).')] [CmdletBinding(SupportsShouldProcess = $true)] param( [Parameter(Mandatory = $true)] [string]$Path ) $item = Get-Item -LiteralPath $Path $acl = Get-Acl -LiteralPath $Path # Disable inheritance and drop any inherited/explicit rules so only ours remain. $acl.SetAccessRuleProtection($true, $false) @($acl.Access) | ForEach-Object { [void]$acl.RemoveAccessRule($_) } $inherit = if ($item.PSIsContainer) { 'ContainerInherit,ObjectInherit' } else { 'None' } $identities = @( [System.Security.Principal.WindowsIdentity]::GetCurrent().User # the saving account [System.Security.Principal.SecurityIdentifier]::new('S-1-5-32-544') # BUILTIN\Administrators [System.Security.Principal.SecurityIdentifier]::new('S-1-5-18') # NT AUTHORITY\SYSTEM ) foreach ($id in $identities) { $rule = [System.Security.AccessControl.FileSystemAccessRule]::new( $id, 'FullControl', $inherit, 'None', 'Allow' ) $acl.AddAccessRule($rule) } if ($PSCmdlet.ShouldProcess($Path, 'Restrict ACL to owner, Administrators, and SYSTEM')) { Set-Acl -LiteralPath $Path -AclObject $acl } } #EndRegion './Private/Set-PrtgSecretAcl.ps1' 37 #Region './Private/Test-PrtgWindows.ps1' -1 function Test-PrtgWindows { # True on Windows. On Windows PowerShell 5.1 $IsWindows is undefined, so PSEdition 'Desktop' # implies Windows; on PowerShell Core $IsWindows is authoritative. return (($PSVersionTable.PSEdition -eq 'Desktop') -or [bool]$IsWindows) } #EndRegion './Private/Test-PrtgWindows.ps1' 6 #Region './Public/Add-PrtgChannel.ps1' -1 function Add-PrtgChannel { <# .SYNOPSIS Adds a PRTG channel object to the current sensor output. .DESCRIPTION Appends a channel object to the module-scope result collection. Build output incrementally: add channels via this cmdlet (one per call, pipeline-friendly), then emit the JSON with Write-PrtgOutput. PRTG allows a maximum of 50 channels per sensor, so adding a 51st throws. Channel objects are typically created with New-PrtgChannel. .PARAMETER PrtgChannel A channel object (PSCustomObject) to add to the output. Usually from New-PrtgChannel. Accepts pipeline input. .EXAMPLE Get-Process | ForEach-Object { New-PrtgChannel -Channel $_.ProcessName -Value $_.CPU -Float } | Add-PrtgChannel Write-PrtgOutput Adds a channel per process (CPU value), then writes PRTG JSON output. .EXAMPLE New-PrtgChannel -Channel 'A' -Value 1 | Add-PrtgChannel New-PrtgChannel -Channel 'B' -Value 2 | Add-PrtgChannel Adds two channels one after the other. .INPUTS PSCustomObject. Channel objects from New-PrtgChannel (or compatible structure). .LINK New-PrtgChannel .LINK Write-PrtgOutput #> [CmdletBinding()] param( [Parameter( Mandatory = $true, ValueFromPipeline = $true )] [PSCustomObject]$PrtgChannel ) process { if ($script:OutputObject.prtg.result.Count -ge 50) { throw "PRTG allows a maximum of 50 channels per sensor; refusing to add another." } [void] $script:OutputObject.prtg.result.Add($PrtgChannel) } } #EndRegion './Public/Add-PrtgChannel.ps1' 54 #Region './Public/Clear-PrtgOutput.ps1' -1 function Clear-PrtgOutput { <# .SYNOPSIS Resets the sensor output state to an empty result set. .DESCRIPTION Re-initializes the module-scope output object (channels and text). Because module state persists for the lifetime of the imported module, call this to start a fresh sensor output when reusing the same session (e.g. between test cases or multiple sensor runs in one host). .EXAMPLE Clear-PrtgOutput New-PrtgChannel -Channel 'A' -Value 1 | Add-PrtgChannel Write-PrtgOutput #> [CmdletBinding()] param() $script:OutputObject = [PSCustomObject]@{ prtg = [PSCustomObject]@{ result = [System.Collections.ArrayList]@() text = '' } } } #EndRegion './Public/Clear-PrtgOutput.ps1' 26 #Region './Public/Get-PrtgMessage.ps1' -1 function Get-PrtgMessage { <# .SYNOPSIS Returns the current sensor message text. .DESCRIPTION Reads the 'text' currently set on the module-scope output object (see Set-PrtgMessage). .EXAMPLE Set-PrtgMessage 'Working' Get-PrtgMessage # -> 'Working' .OUTPUTS System.String. The current sensor message. .LINK Set-PrtgMessage #> return $script:OutputObject.prtg.text } #EndRegion './Public/Get-PrtgMessage.ps1' 21 #Region './Public/Get-PrtgSecret.ps1' -1 function Get-PrtgSecret { <# .SYNOPSIS Reads a secret previously stored with Save-PrtgSecret. .DESCRIPTION Loads a DPAPI-protected secret and returns it as a SecureString or PSCredential (whatever was saved). Decryption only succeeds when this runs as the SAME Windows account on the SAME machine that saved the secret - which for a sensor means the account PRTG runs it as. Windows only. .PARAMETER Name Identifier passed to Save-PrtgSecret. .PARAMETER Path Folder the secret was stored in. Defaults to '$env:ProgramData\PrtgSensorKit\Secrets'. .PARAMETER AsPlainText Return the secret as a plain [string] instead of a SecureString/PSCredential. For a stored SecureString this is the secret itself; for a stored PSCredential this is the password. Use only when an API requires the raw value - it defeats the point of keeping it a SecureString. .PARAMETER AllowUnprotected Development only. Allows reading a secret off Windows, where it was stored obfuscated rather than DPAPI-encrypted (see Save-PrtgSecret -AllowUnprotected). Never needed on a Windows sensor. .EXAMPLE $token = Get-PrtgSecret -Name 'AcmeApi' -AsPlainText Invoke-RestMethod -Uri $url -Headers @{ Authorization = "Bearer $token" } Reads a token for use in a web request. .EXAMPLE $cred = Get-PrtgSecret -Name 'SqlLogin' Invoke-Sqlcmd -Credential $cred -ServerInstance 'db01' -Query '...' Reads a stored credential and uses it directly. .LINK Save-PrtgSecret #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [ValidatePattern('^[A-Za-z0-9._-]+$')] [string]$Name, [Parameter(Mandatory = $false)] [string]$Path, [Parameter(Mandatory = $false)] [switch]$AsPlainText, [Parameter(Mandatory = $false)] [switch]$AllowUnprotected ) $onWindows = Test-PrtgWindows if (-not $onWindows -and -not $AllowUnprotected) { throw "PrtgSensorKit secret storage uses Windows DPAPI and NTFS ACLs; it is only available on Windows. Pass -AllowUnprotected to read an OBFUSCATED (not encrypted) development secret." } if ([string]::IsNullOrEmpty($Path)) { $Path = if ($onWindows) { Join-Path $env:ProgramData 'PrtgSensorKit\Secrets' } else { Join-Path ([System.IO.Path]::GetTempPath()) 'PrtgSensorKit/Secrets' } } $file = Join-Path $Path "$Name.clixml" if (-not (Test-Path -LiteralPath $file)) { throw "Secret '$Name' not found at '$file'. Save it first with Save-PrtgSecret, running as the same account this sensor runs as (e.g. Local System)." } try { $object = Import-Clixml -LiteralPath $file } catch { $who = if ($onWindows) { [System.Security.Principal.WindowsIdentity]::GetCurrent().Name } else { $env:USER } throw "Failed to decrypt secret '$Name'. DPAPI-protected secrets can only be read by the same Windows account and machine that saved them; this is running as '$who'. Re-save the secret as that account. ($($_.Exception.Message))" } if ($AsPlainText) { if ($object -is [System.Management.Automation.PSCredential]) { return $object.GetNetworkCredential().Password } if ($object -is [System.Security.SecureString]) { return [System.Net.NetworkCredential]::new('', $object).Password } } return $object } #EndRegion './Public/Get-PrtgSecret.ps1' 92 #Region './Public/Invoke-PrtgSensor.ps1' -1 function Invoke-PrtgSensor { <# .SYNOPSIS Runs a sensor script block and emits exactly one valid PRTG response. .DESCRIPTION The batteries-included way to write a sensor. Wrap your channel-building logic in a script block and Invoke-PrtgSensor handles all the boilerplate for you: - starts from a clean output state (calls Clear-PrtgOutput), - sets $ErrorActionPreference to 'Stop' so any failure is caught, - runs your block with its success and information output discarded, so a stray Write-Host or an un-captured command result cannot corrupt the sensor output, - emits the PRTG JSON once on success, or a PRTG error response if your block throws. Inside the block, build channels with New-PrtgChannel | Add-PrtgChannel and set the message with Set-PrtgMessage. Do NOT call Write-PrtgOutput / Write-PrtgError yourself and do NOT write to the output stream (see the note below) - the wrapper produces the single response. .PARAMETER ScriptBlock The sensor logic. Add one or more channels and set the message here; the block may contain any logic (loops, function calls, etc.). The wrapper emits the single result. .EXAMPLE Invoke-PrtgSensor { New-PrtgChannel -Channel 'CPU' -Value (Get-Counter '\Processor(_Total)\% Processor Time').CounterSamples.CookedValue -Unit Percent -Float | Add-PrtgChannel Set-PrtgMessage 'ok' } Builds a single channel and emits the PRTG JSON. If the block throws, a PRTG error is emitted instead. .EXAMPLE Invoke-PrtgSensor { # The block can contain any logic and add as many channels as you need (up to PRTG's 50). foreach ($disk in Get-CimInstance Win32_LogicalDisk -Filter 'DriveType = 3') { $freePct = [math]::Round($disk.FreeSpace / $disk.Size * 100, 1) New-PrtgChannel -Channel "Free % $($disk.DeviceID)" -Value $freePct -Unit Percent -Float ` -LimitMinWarning 15 -LimitMinError 5 -LimitMode $true | Add-PrtgChannel } Set-PrtgMessage 'Disk free space per volume' } Builds one channel per fixed disk in a loop. Multiple channels and arbitrarily complex block logic (loops, function calls, remote queries) are fully supported - the wrapper only handles the surrounding error handling and single-response output. .NOTES A PRTG EXE/Script Advanced sensor reads its result from the process standard output, which must contain only the JSON. Anything your block writes to the output stream (a bare Write-Host, Write-Output, or an un-captured command that returns objects) would corrupt it, so the wrapper discards that output for you. For debugging, log to a file instead - never to the output stream. Import-Module works normally inside the block. Restart-As64BitPowershell / Restart-InPwsh do NOT: they relaunch the sensor as a child process, whose output would be discarded by this wrapper's output guard. Call them at the top of your script, BEFORE Invoke-PrtgSensor. .LINK New-PrtgChannel .LINK Write-PrtgError #> [CmdletBinding()] param( [Parameter(Mandatory = $true, Position = 0)] [scriptblock]$ScriptBlock ) $ErrorActionPreference = 'Stop' Clear-PrtgOutput try { # Merge the information stream into success, then discard both so stray output from the # user's code never reaches stdout. Terminating errors still propagate to the catch below. & $ScriptBlock 6>&1 | Out-Null Write-PrtgOutput } catch { $_ | Write-PrtgError } } #EndRegion './Public/Invoke-PrtgSensor.ps1' 81 #Region './Public/New-PrtgChannel.ps1' -1 function New-PrtgChannel { <# .SYNOPSIS Creates a new PRTG Channel object for use in custom sensor JSON output. .DESCRIPTION New-PrtgChannel creates a PSCustomObject that represents a PRTG channel definition. The output can be piped to Write-PrtgOutput or Add-PrtgChannel to produce valid PRTG custom sensor JSON. Channel objects define how values are displayed, graphed, and evaluated (limits) in PRTG. Reference: https://www.paessler.com/manuals/prtg/custom_sensors#advanced_elements Dynamic parameters appear based on the -Unit value. Because they are dynamic, Get-Help only shows their descriptions once -Unit is bound; they are summarized here: - CustomUnit: mandatory when Unit is 'Custom'. Text shown after the value as its unit. - SpeedSize: when Unit is BytesBandwidth, SpeedDisk, or SpeedNet. Size prefix - one of One, Kilo, Mega, Giga, Tera, Byte, KiloByte, MegaByte, GigaByte, TeraByte, Bit, KiloBit, MegaBit, GigaBit, TeraBit. - SpeedTime: when Unit is BytesBandwidth, SpeedDisk, or SpeedNet. One of Second, Minute, Hour, Day. - VolumeSize: when Unit is BytesDisk or BytesFile. Same value set as SpeedSize. .PARAMETER Channel The display name of the channel in PRTG. .PARAMETER Value The numeric value for the channel. Supports int, int64, float, double, or decimal. .PARAMETER Unit The unit type for the channel. Determines formatting and available dynamic parameters. Default is 'Count'. .PARAMETER Mode Value mode: 'Absolute' (default in PRTG) or 'Difference' for delta display. .PARAMETER Float When specified, the value is sent as floating-point (recommended for decimal values). .PARAMETER DecimalMode Controls decimal places: 'Auto' or 'All'. .PARAMETER Warning When specified, marks the channel in warning state for PRTG. .PARAMETER ShowChart Whether to show the channel in the sensor graph. Default is $true. .PARAMETER ShowTable Whether to show the channel in the sensor table. Default is $true. .PARAMETER LimitMaxError Upper error limit. Value above this triggers error state. .PARAMETER LimitMaxWarning Upper warning limit. Value above this triggers warning state. .PARAMETER LimitMinWarning Lower warning limit. Value below this triggers warning state. .PARAMETER LimitMinError Lower error limit. Value below this triggers error state. .PARAMETER LimitErrorMsg Custom message shown when an error limit is exceeded. .PARAMETER LimitWarningMsg Custom message shown when a warning limit is exceeded. .PARAMETER LimitMode When $true, PRTG uses the defined limits for state evaluation. Default is $false. .PARAMETER ValueLookup Name of a PRTG value lookup to translate numeric values to text (e.g. "prtg.standardlookups.boolean"). .PARAMETER NotifyChanged When specified, PRTG triggers change notification when the value changes. .PARAMETER CustomUnit Dynamic parameter, available and mandatory when -Unit is 'Custom'. The text displayed after the value as its unit. .PARAMETER SpeedSize Dynamic parameter, available when -Unit is 'BytesBandwidth', 'SpeedDisk', or 'SpeedNet'. The size prefix for the value (One, Kilo, Mega, Giga, Tera, Byte, KiloByte, MegaByte, GigaByte, TeraByte, Bit, KiloBit, MegaBit, GigaBit, TeraBit). .PARAMETER SpeedTime Dynamic parameter, available when -Unit is 'BytesBandwidth', 'SpeedDisk', or 'SpeedNet'. The time unit for the speed (Second, Minute, Hour, Day). .PARAMETER VolumeSize Dynamic parameter, available when -Unit is 'BytesDisk' or 'BytesFile'. The size prefix for the value (One, Kilo, Mega, Giga, Tera, Byte, KiloByte, MegaByte, GigaByte, TeraByte, Bit, KiloBit, MegaBit, GigaBit, TeraBit). .EXAMPLE New-PrtgChannel -Channel 'Total Items' -Value 42 Creates a simple channel named "Total Items" with value 42 and unit Count. .EXAMPLE New-PrtgChannel -Channel 'CPU %' -Value 78.5 -Unit Percent -Float Creates a percentage channel with a floating-point value (e.g. CPU usage). .EXAMPLE New-PrtgChannel -Channel 'Temperature' -Value 65.2 -Unit Temperature -Float Creates a temperature channel (e.g. for hardware sensors). .EXAMPLE New-PrtgChannel -Channel 'Response Time' -Value 120 -Unit TimeResponse -LimitMaxWarning 100 -LimitMaxError 500 -LimitMode $true Creates a response time channel with warning above 100 ms and error above 500 ms. .EXAMPLE New-PrtgChannel -Channel 'Disk Free %' -Value 25.0 -Unit Percent -Float -LimitMinWarning 20 -LimitMinError 10 -LimitMode $true Creates a "Disk Free %" channel with lower limits (warning below 20%, error below 10%). .EXAMPLE New-PrtgChannel -Channel 'Success' -Value 950 -Unit Count | Add-PrtgChannel New-PrtgChannel -Channel 'Failed' -Value 50 -Unit Count -Warning | Add-PrtgChannel Creates two channels and writes them as PRTG JSON output (e.g. for a custom sensor script). .OUTPUTS PSCustomObject. A channel object with properties Channel, Value, Unit, ShowChart, ShowTable, and any specified optional parameters. .NOTES The returned object is designed to be consumed by Write-PrtgOutput or Add-PrtgChannel. Use -Float when passing decimal values to ensure correct PRTG JSON formatting. .LINK https://www.paessler.com/manuals/prtg/custom_sensors#advanced_elements .LINK Write-PrtgOutput .LINK Add-PrtgChannel #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Pure factory that returns a channel object; it changes no state, so -WhatIf/-Confirm do not apply.')] [CmdletBinding()] param( [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string] $Channel, [Parameter(Mandatory = $true)] [ValidateScript({ $_ -is [int] -or $_ -is [int64] -or $_ -is [float] -or $_ -is [double] -or $_ -is [decimal] })] $Value, [Parameter(Mandatory = $false)] [ValidateSet( 'BytesBandwidth', 'BytesDisk', 'Temperature', 'Percent', 'TimeResponse', 'TimeSeconds', 'Count', 'Custom', 'CPU', 'BytesFile', 'SpeedDisk', 'SpeedNet', 'TimeHours' )] [string] $Unit = 'Count', [Parameter(Mandatory = $false)] [ValidateSet('Absolute', 'Difference')] [string] $Mode, [Parameter(Mandatory = $false)] [switch] $Float, [Parameter(Mandatory = $false)] [ValidateSet('Auto', 'All')] [string] $DecimalMode, [Parameter(Mandatory = $false)] [switch] $Warning, [Parameter(Mandatory = $false)] [bool] $ShowChart = $true, [Parameter(Mandatory = $false)] [bool] $ShowTable = $true, [Parameter(Mandatory = $false)] [ValidateScript({ $_ -is [int] -or $_ -is [int64] -or $_ -is [float] -or $_ -is [double] -or $_ -is [decimal] -or $_ -is [string] })] $LimitMaxError, [Parameter(Mandatory = $false)] [ValidateScript({ $_ -is [int] -or $_ -is [int64] -or $_ -is [float] -or $_ -is [double] -or $_ -is [decimal] -or $_ -is [string] })] $LimitMaxWarning, [Parameter(Mandatory = $false)] [ValidateScript({ $_ -is [int] -or $_ -is [int64] -or $_ -is [float] -or $_ -is [double] -or $_ -is [decimal] -or $_ -is [string] })] $LimitMinWarning, [Parameter(Mandatory = $false)] [ValidateScript({ $_ -is [int] -or $_ -is [int64] -or $_ -is [float] -or $_ -is [double] -or $_ -is [decimal] -or $_ -is [string] })] $LimitMinError, [Parameter(Mandatory = $false)] [ValidateNotNullOrEmpty()] [string] $LimitErrorMsg, [Parameter(Mandatory = $false)] [ValidateNotNullOrEmpty()] [string] $LimitWarningMsg, [Parameter(Mandatory = $false)] [bool] $LimitMode = $false, [Parameter(Mandatory = $false)] [ValidateNotNullOrEmpty()] [string] $ValueLookup, [Parameter(Mandatory = $false)] [switch] $NotifyChanged ) dynamicparam { $ParamDictionary = [System.Management.Automation.RuntimeDefinedParameterDictionary]::New() $ParameterAttribute = [System.Management.Automation.ParameterAttribute]::New() $ValidateSizeAttribute = [System.Management.Automation.ValidateSetAttribute]::New( 'One', 'Kilo', 'Mega', 'Giga', 'Tera', 'Byte', 'KiloByte', 'MegaByte', 'GigaByte', 'TeraByte', 'Bit', 'KiloBit', 'MegaBit', 'GigaBit', 'TeraBit' ) switch ( $Unit ) { { $_ -eq 'Custom' } { $ParameterAttribute.Mandatory = $True $ValidateAttribute = [System.Management.Automation.ValidateNotNullOrEmptyAttribute]::New() $AttributeCollection = [System.Collections.ObjectModel.Collection[System.Attribute]]::New() $AttributeCollection.Add($ParameterAttribute) $AttributeCollection.Add($ValidateAttribute) $CustomUnitDynParam = [System.Management.Automation.RuntimeDefinedParameter]::New( 'CustomUnit', [string], $AttributeCollection ) $ParamDictionary.Add('CustomUnit', $CustomUnitDynParam) } { $_ -in @('BytesBandwidth', 'SpeedDisk', 'SpeedNet') } { $ParameterAttribute.Mandatory = $false $AttributeCollection = [System.Collections.ObjectModel.Collection[System.Attribute]]::New() $AttributeCollection.Add($ParameterAttribute) $AttributeCollection.Add($ValidateSizeAttribute) $SpeedSizeDynParam = [System.Management.Automation.RuntimeDefinedParameter]::New( 'SpeedSize', [string], $AttributeCollection ) $ParamDictionary.Add('SpeedSize', $SpeedSizeDynParam) $ValidateTimeAttribute = [System.Management.Automation.ValidateSetAttribute]::New( 'Second', 'Minute', 'Hour', 'Day' ) $AttributeCollection = [System.Collections.ObjectModel.Collection[System.Attribute]]::New() $AttributeCollection.Add($ParameterAttribute) $AttributeCollection.Add($ValidateTimeAttribute) $SpeedTimeDynParam = [System.Management.Automation.RuntimeDefinedParameter]::New( 'SpeedTime', [string], $AttributeCollection ) $ParamDictionary.Add('SpeedTime', $SpeedTimeDynParam) } { $_ -in @('BytesDisk', 'BytesFile') } { $ParameterAttribute.Mandatory = $false $AttributeCollection = [System.Collections.ObjectModel.Collection[System.Attribute]]::New() $AttributeCollection.Add($ParameterAttribute) $AttributeCollection.Add($ValidateSizeAttribute) $VolumeSizeDynParam = [System.Management.Automation.RuntimeDefinedParameter]::New( 'VolumeSize', [string], $AttributeCollection ) $ParamDictionary.Add('VolumeSize', $VolumeSizeDynParam) } } return $ParamDictionary } process { $PrtgChannel = [PSCustomObject]@{ PSTypeName = 'PrtgSensorKit.Channel' Channel = $Channel Value = $Value Unit = $Unit ShowChart = [int][bool]$ShowChart ShowTable = [int][bool]$ShowTable } if ( $Float -or $Value -is [float] -or $Value -is [double] -or $Value -is [decimal] ) { $PrtgChannel.Value = [double]$Value $PrtgChannel | Add-Member -Name 'Float' -Type NoteProperty -Value 1 } $PSBoundParameters.GetEnumerator() | Where-Object { $_.Key -notin 'Channel', 'Value', 'Unit', 'Float', 'ShowChart', 'ShowTable' } | ForEach-Object { $TypeConvertedValue = switch ($_.Value) { { $_ -is [switch] } { [int][bool]$_ } { $_ -is [bool] } { [int]$_ } { $_ -is [string] } { $_ } default { "$_" } } # Limit messages are shown as sensor messages, so strip '#' and truncate to 2000 chars if ($_.Key -in 'LimitErrorMsg', 'LimitWarningMsg') { $TypeConvertedValue = Format-PrtgMessage $TypeConvertedValue } $PrtgChannel | Add-Member -Name $_.Key -Type NoteProperty -Value $TypeConvertedValue } return $PrtgChannel } } #EndRegion './Public/New-PrtgChannel.ps1' 388 #Region './Public/Restart-As64BitPowershell.ps1' -1 function Restart-As64BitPowershell { <# .SYNOPSIS Ensures the current script runs in 64-bit PowerShell. .DESCRIPTION PRTG and some hosts start PowerShell as a 32-bit (x86) Process. This function detects this and re-invokes the same command line in 64-bit PowerShell, then exits so the caller can rely on running in 64-bit. Call early in your script after importing the module (e.g. after Import-Module PrtgSensorKit). .EXAMPLE Import-Module PrtgSensorKit Restart-As64BitPowershell # rest of your sensor runs in 64-bit PowerShell .LINK Restart-InPwsh #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Non-interactive sensor bootstrap that relaunches the process; a -Confirm prompt would stall a PRTG probe.')] [CmdletBinding()] param() # Only restart when we're in 32-bit process on 64-bit Windows if (-not [Environment]::Is64BitProcess) { $Powershell64BitPath = "$env:WINDIR\sysnative\windowspowershell\v1.0\powershell.exe" if (-not (Test-Path -LiteralPath $Powershell64BitPath -PathType Leaf)) { Write-Warning "Restart-As64BitPowershell: 64-bit PowerShell not found at '$Powershell64BitPath'." return } try { # Relaunch the CALLING sensor script. From a module function, the caller's $MyInvocation is # not reachable via scope; take it from the call stack ([0] is this function, [1] the caller). $CallerInvocation = (Get-PSCallStack)[1].InvocationInfo Invoke-PrtgRelaunch -Executable $Powershell64BitPath -Invocation $CallerInvocation } catch { throw "Restart-As64BitPowershell: Failed to start 64-bit PowerShell: $_" } } } #EndRegion './Public/Restart-As64BitPowershell.ps1' 42 #Region './Public/Restart-InPwsh.ps1' -1 function Restart-InPwsh { <# .SYNOPSIS Ensures the current script runs in PowerShell 7+ (pwsh). .DESCRIPTION PRTG runs custom sensors in Windows PowerShell 5.1 (Desktop edition). If your sensor needs PowerShell 7+ features or modules, call this early (after Import-Module PrtgSensorKit) to re-invoke the same command line under pwsh, then exit so the caller can rely on running in pwsh. No-op when already on PowerShell Core. Warns and continues if pwsh is not installed. Can be combined with Restart-As64BitPowershell. .EXAMPLE Import-Module PrtgSensorKit Restart-InPwsh # rest of your sensor runs under pwsh .LINK Restart-As64BitPowershell #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Non-interactive sensor bootstrap that relaunches the process; a -Confirm prompt would stall a PRTG probe.')] [CmdletBinding()] param() # Desktop edition == Windows PowerShell 5.1; Core == pwsh 6+, so only act on Desktop if ($PSVersionTable.PSEdition -eq 'Desktop') { $Pwsh = Get-Command -Name 'pwsh' -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 if (-not $Pwsh) { Write-Warning "Restart-InPwsh: PowerShell 7+ (pwsh) not found; continuing in Windows PowerShell." return } try { # Relaunch the CALLING sensor script. From a module function, the caller's $MyInvocation is # not reachable via scope; take it from the call stack ([0] is this function, [1] the caller). $CallerInvocation = (Get-PSCallStack)[1].InvocationInfo Invoke-PrtgRelaunch -Executable $Pwsh.Source -Invocation $CallerInvocation } catch { throw "Restart-InPwsh: Failed to start PowerShell 7+: $_" } } } #EndRegion './Public/Restart-InPwsh.ps1' 46 #Region './Public/Save-PrtgSecret.ps1' -1 function Save-PrtgSecret { <# .SYNOPSIS Saves a secret (SecureString or PSCredential) encrypted with Windows DPAPI for later use by a sensor. .DESCRIPTION Stores an API token, password, or full credential so a sensor never has to carry it in plain text. The secret is written with Export-Clixml, which protects it using Windows DPAPI: the file can only be decrypted by the SAME Windows account on the SAME machine that saved it. IMPORTANT - run this AS the account the sensor runs as. A PRTG custom sensor runs as Local System or the Windows credentials configured on the device/probe. You must save the secret while running as that same account, or Get-PrtgSecret will fail to decrypt it at sensor time. For Local System, run the save under Local System (for example via 'PsExec -s'). Windows only. The store folder and file are ACL-locked to the saving account, Administrators, and SYSTEM. .PARAMETER Name Identifier for the secret. Used as the file name, so it is restricted to letters, digits, dot, dash, and underscore. .PARAMETER Secret The secret as a SecureString (e.g. an API token). Use this or -Credential. .PARAMETER Credential A full PSCredential (user name + password). Use this or -Secret. .PARAMETER Path Folder to store secrets in. Defaults to '$env:ProgramData\PrtgSensorKit\Secrets' on Windows, or a temp folder when -AllowUnprotected is used off Windows. .PARAMETER AllowUnprotected Development only. Off Windows there is no DPAPI, so Export-Clixml stores the secret merely OBFUSCATED (the UTF-16 bytes), not encrypted - anyone who can read the file can recover it. This switch opts in to that behaviour so you can exercise sensor logic on non-Windows; it prints a warning and never applies on a real Windows sensor host. Do not use for real secrets. .EXAMPLE Save-PrtgSecret -Name 'AcmeApi' -Secret (Read-Host -AsSecureString) Prompts for a token and stores it encrypted for the current account. .EXAMPLE Save-PrtgSecret -Name 'SqlLogin' -Credential (Get-Credential) Stores a user name and password for later use in a sensor. .NOTES DPAPI ties the encryption to the saving account and machine - the secret does not roam to other users or servers. Re-save it per machine / per sensor account. .LINK Get-PrtgSecret #> [CmdletBinding(DefaultParameterSetName = 'SecureString')] param( [Parameter(Mandatory = $true)] [ValidatePattern('^[A-Za-z0-9._-]+$')] [string]$Name, [Parameter(Mandatory = $true, ParameterSetName = 'SecureString')] [System.Security.SecureString]$Secret, [Parameter(Mandatory = $true, ParameterSetName = 'Credential')] [System.Management.Automation.PSCredential]$Credential, [Parameter(Mandatory = $false)] [string]$Path, [Parameter(Mandatory = $false)] [switch]$AllowUnprotected ) $onWindows = Test-PrtgWindows if (-not $onWindows) { if (-not $AllowUnprotected) { throw "PrtgSensorKit secret storage uses Windows DPAPI and NTFS ACLs; it is only available on Windows. Pass -AllowUnprotected to store an OBFUSCATED (not encrypted) secret for development only." } Write-Warning "Save-PrtgSecret: off Windows the secret is only OBFUSCATED, NOT encrypted (DPAPI is unavailable). Anyone who can read '$Name' can recover it. Use for development only - never for real credentials." } if ([string]::IsNullOrEmpty($Path)) { $Path = if ($onWindows) { Join-Path $env:ProgramData 'PrtgSensorKit\Secrets' } else { Join-Path ([System.IO.Path]::GetTempPath()) 'PrtgSensorKit/Secrets' } } if (-not (Test-Path -LiteralPath $Path)) { [void] (New-Item -ItemType Directory -Path $Path -Force) } $file = Join-Path $Path "$Name.clixml" $object = if ($PSCmdlet.ParameterSetName -eq 'Credential') { $Credential } else { $Secret } $object | Export-Clixml -LiteralPath $file -Force # NTFS ACL hardening is Windows-only; the DPAPI protection is what matters and only exists here. if ($onWindows) { Set-PrtgSecretAcl -Path $Path Set-PrtgSecretAcl -Path $file } if ($onWindows) { Write-Verbose "Saved secret '$Name' to '$file' (DPAPI-protected for account '$([System.Security.Principal.WindowsIdentity]::GetCurrent().Name)')." } else { Write-Verbose "Saved OBFUSCATED (not encrypted) secret '$Name' to '$file'." } } #EndRegion './Public/Save-PrtgSecret.ps1' 109 #Region './Public/Set-PrtgMessage.ps1' -1 function Set-PrtgMessage { <# .SYNOPSIS Sets the sensor message text. .DESCRIPTION Sets the 'text' shown for the sensor in PRTG. The number sign (#) is stripped and the message is truncated to 2000 characters automatically, as PRTG requires. .PARAMETER Text The sensor message. .EXAMPLE Set-PrtgMessage 'All checks passed' Sets the sensor message. .LINK Get-PrtgMessage .LINK Write-PrtgOutput #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Only mutates in-memory module-scope output state; nothing persistent to confirm and sensors run non-interactively.')] [CmdletBinding()] param( [string]$Text ) $script:OutputObject.prtg.text = Format-PrtgMessage $Text } #EndRegion './Public/Set-PrtgMessage.ps1' 32 #Region './Public/Set-PrtgOutput.ps1' -1 function Set-PrtgOutput { <# .SYNOPSIS Replaces the entire sensor output object. .DESCRIPTION Overwrites the module-scope output object with the one provided. Most sensors never need this - build output with New-PrtgChannel / Add-PrtgChannel instead. Use it only when you want to supply a fully pre-built object with the PRTG shape (a 'prtg' property containing 'result' and 'text'). .PARAMETER Object The replacement output object. Must have the PRTG structure for Add-PrtgChannel, Set-PrtgMessage, and Write-PrtgOutput to keep working. .EXAMPLE $custom = [PSCustomObject]@{ prtg = [PSCustomObject]@{ result = [System.Collections.ArrayList]@(); text = '' } } Set-PrtgOutput $custom Replaces the current output object with a custom one. .LINK Clear-PrtgOutput .LINK Write-PrtgOutput #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Only replaces the in-memory module-scope output object; nothing persistent to confirm and sensors run non-interactively.')] [CmdletBinding()] param( $Object ) $script:OutputObject = $Object } #EndRegion './Public/Set-PrtgOutput.ps1' 36 #Region './Public/Write-PrtgError.ps1' -1 function Write-PrtgError { <# .SYNOPSIS Outputs an error to PRTG. .DESCRIPTION Emits a PRTG error response (JSON) that replaces all channel data with a single error message. The number sign (#) is stripped and the message is truncated to 2000 characters, as PRTG requires. .PARAMETER ErrorObject The error object to output. This is typically the $_ variable in a try/catch block or the $Error[0] variable. The emitted message includes line, char, message, and source line. .PARAMETER ErrorString A custom error message to output. .EXAMPLE Write-PrtgError -ErrorObject $_ Reports a caught error (from a trap or catch block) to PRTG. .EXAMPLE Write-PrtgError -ErrorString "My error message" Reports a custom error message to PRTG. .LINK Write-PrtgOutput #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseProcessBlockForPipelineCommand', '', Justification = 'A PRTG sensor returns exactly one error response. Deliberately no process block, so piping multiple errors yields a single response, not one per error.')] [CmdletBinding()] param ( [Parameter( Mandatory = $true, ValueFromPipeline = $true, ParameterSetName = 'ErrorObject' )] [System.Management.Automation.ErrorRecord] $ErrorObject, [Parameter( Mandatory = $true, ValueFromPipeline = $true, ParameterSetName = 'ErrorString' )] [string] $ErrorString ) $RawText = if ($PsCmdlet.ParameterSetName -eq 'ErrorObject') { "line:$($ErrorObject.InvocationInfo.ScriptLineNumber.ToString()) " + "char:$($ErrorObject.InvocationInfo.OffsetInLine.ToString()) --- " + "message: $($ErrorObject.Exception.Message.ToString()) --- " + "line: $($ErrorObject.InvocationInfo.Line.ToString())" } else { $ErrorString } $ErrorOutput = [PSCustomObject]@{ prtg = [PSCustomObject]@{ error = 1 text = Format-PrtgMessage $RawText } } [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 Write-Output ($ErrorOutput | ConvertTo-Json -Depth 10) } #EndRegion './Public/Write-PrtgError.ps1' 70 #Region './Public/Write-PrtgOutput.ps1' -1 function Write-PrtgOutput { <# .SYNOPSIS Emits the current sensor output as PRTG JSON. .DESCRIPTION Serializes the module-scope output object (channels and message) to the JSON structure PRTG expects and writes it to the output stream. Call this once, last, after adding all channels with Add-PrtgChannel and optionally setting text with Set-PrtgMessage. .EXAMPLE New-PrtgChannel -Channel 'A' -Value 1 | Add-PrtgChannel Write-PrtgOutput Emits a sensor result containing a single channel. .OUTPUTS System.String. The PRTG sensor JSON. .LINK Add-PrtgChannel .LINK Set-PrtgMessage #> [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 Write-Output ($script:OutputObject | ConvertTo-Json -Depth 10) } #EndRegion './Public/Write-PrtgOutput.ps1' 28 |