LISSTech.DrainCtl.psm1
|
#Requires -Version 5.1 Set-StrictMode -Version Latest # Locate drainctl.dll — may be alongside the psm1 (MSI install) or in bin/ subdir (dev/PSGallery). $script:DllPath = Join-Path $PSScriptRoot 'drainctl.dll' if (-not (Test-Path $script:DllPath)) { $script:DllPath = Join-Path (Join-Path $PSScriptRoot 'bin') 'drainctl.dll' } if (-not (Test-Path $script:DllPath)) { throw "DrainCtl: drainctl.dll not found. Module installation may be corrupt." } # Locate drainctl.exe — add its directory to PATH if not already there. $script:BinDir = Split-Path $script:DllPath $script:ExePath = Join-Path $script:BinDir 'drainctl.exe' if ($env:PATH -notlike "*$($script:BinDir)*") { $env:PATH = "$($script:BinDir);$env:PATH" } $MyInvocation.MyCommand.ScriptBlock.Module.OnRemove = { $env:PATH = ($env:PATH -split ';' | Where-Object { $_ -ne $script:BinDir }) -join ';' } # ── Native interop ────────────────────────────────────────────────────────── # P/Invoke declarations with a UTF-8 helper that works on both # .NET Framework 4.x (Windows PowerShell 5.1) and .NET 6+ (PowerShell 7+). Add-Type -TypeDefinition @" using System; using System.Runtime.InteropServices; using System.Text; public static class DrainCtlNative { [DllImport("$($script:DllPath.Replace('\','\\'))", CallingConvention = CallingConvention.Cdecl)] public static extern IntPtr DrainCtl_Version(); [DllImport("$($script:DllPath.Replace('\','\\'))", CallingConvention = CallingConvention.Cdecl)] public static extern IntPtr DrainCtl_ReadDrainMode(); [DllImport("$($script:DllPath.Replace('\','\\'))", CallingConvention = CallingConvention.Cdecl)] public static extern IntPtr DrainCtl_Check( [MarshalAs(UnmanagedType.LPStr)] string dbPath, int graceMinutes, int retentionDays); [DllImport("$($script:DllPath.Replace('\','\\'))", CallingConvention = CallingConvention.Cdecl)] public static extern IntPtr DrainCtl_History( [MarshalAs(UnmanagedType.LPStr)] string dbPath, int limit, int changesOnly); [DllImport("$($script:DllPath.Replace('\','\\'))", CallingConvention = CallingConvention.Cdecl)] public static extern IntPtr DrainCtl_AuditSetup(); [DllImport("$($script:DllPath.Replace('\','\\'))", CallingConvention = CallingConvention.Cdecl)] public static extern IntPtr DrainCtl_GetSettings(); [DllImport("$($script:DllPath.Replace('\','\\'))", CallingConvention = CallingConvention.Cdecl)] public static extern IntPtr DrainCtl_SetSettings( [MarshalAs(UnmanagedType.LPStr)] string jsonStr); [DllImport("$($script:DllPath.Replace('\','\\'))", CallingConvention = CallingConvention.Cdecl)] public static extern IntPtr DrainCtl_TestNotify(); [DllImport("$($script:DllPath.Replace('\','\\'))", CallingConvention = CallingConvention.Cdecl)] public static extern IntPtr DrainCtl_EnableDashboard(int port, [MarshalAs(UnmanagedType.LPStr)] string group); [DllImport("$($script:DllPath.Replace('\','\\'))", CallingConvention = CallingConvention.Cdecl)] public static extern IntPtr DrainCtl_DisableDashboard(); [DllImport("$($script:DllPath.Replace('\','\\'))", CallingConvention = CallingConvention.Cdecl)] public static extern IntPtr DrainCtl_InstallCertificate( [MarshalAs(UnmanagedType.LPStr)] string certPath, [MarshalAs(UnmanagedType.LPStr)] string keyPath); [DllImport("$($script:DllPath.Replace('\','\\'))", CallingConvention = CallingConvention.Cdecl)] public static extern IntPtr DrainCtl_NotifyAppendTarget( [MarshalAs(UnmanagedType.LPStr)] string jsonStr); [DllImport("$($script:DllPath.Replace('\','\\'))", CallingConvention = CallingConvention.Cdecl)] public static extern IntPtr DrainCtl_NotifySetTarget( [MarshalAs(UnmanagedType.LPStr)] string jsonStr); [DllImport("$($script:DllPath.Replace('\','\\'))", CallingConvention = CallingConvention.Cdecl)] public static extern IntPtr DrainCtl_NotifyRemoveTarget( [MarshalAs(UnmanagedType.LPStr)] string typ, int index); [DllImport("$($script:DllPath.Replace('\','\\'))", CallingConvention = CallingConvention.Cdecl)] public static extern void DrainCtl_Free(IntPtr ptr); /// <summary> /// Read a null-terminated UTF-8 string from unmanaged memory. /// Works on .NET Framework 4.x (PS 5.1) and .NET 6+ (PS 7+). /// </summary> public static string PtrToStringUTF8(IntPtr ptr) { if (ptr == IntPtr.Zero) return null; int len = 0; while (Marshal.ReadByte(ptr, len) != 0) len++; byte[] buf = new byte[len]; Marshal.Copy(ptr, buf, 0, len); return Encoding.UTF8.GetString(buf); } } "@ function Invoke-DrainCtlNative { [CmdletBinding()] param( [Parameter(Mandatory)] [IntPtr]$Ptr ) try { $json = [DrainCtlNative]::PtrToStringUTF8($Ptr) } finally { [DrainCtlNative]::DrainCtl_Free($Ptr) } $obj = $json | ConvertFrom-Json if ($null -ne ($obj.PSObject.Properties['error'])) { $err = $obj.PSObject.Properties['error'].Value $exception = [System.InvalidOperationException]::new($err) $errorRecord = [System.Management.Automation.ErrorRecord]::new( $exception, 'DrainCtlNativeError', [System.Management.Automation.ErrorCategory]::InvalidResult, $null) $PSCmdlet.ThrowTerminatingError($errorRecord) } return $obj } function Get-SafeProperty { # Reads a property from a PSObject without triggering strict-mode errors # when the property doesn't exist (JSON omitempty). param($Object, [string]$Name, $Default = $null) $prop = $Object.PSObject.Properties[$Name] if ($null -ne $prop) { return $prop.Value } return $Default } function ConvertFrom-SecureStringToPlainText { # Decrypts a SecureString into a plaintext string. The plaintext lives in # managed memory only as long as the caller holds the result; we marshal # it to drainctl.dll immediately so DPAPI encryption happens before any # garbage-collected copy can persist. param([System.Security.SecureString]$Secure) if ($null -eq $Secure) { return $null } $bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($Secure) try { return [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) } finally { [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) } } function ConvertTo-NativeDateTime { # Converts an ISO 8601 string to [datetime]. Returns $null for absent, # null, empty, or Go zero-time values. param($Value) if ($null -eq $Value -or $Value -eq '') { return $null } $s = [string]$Value # Go zero time if ($s -like '0001-01-01*') { return $null } return [datetimeoffset]::Parse($s).LocalDateTime } # ── Public cmdlets ────────────────────────────────────────────────────────── function Get-RDSHDrainMode { <# .SYNOPSIS Returns detailed drain mode state with grace period evaluation and audit trail. .DESCRIPTION Reads the TSServerDrainMode registry value, detects state transitions, records an audit entry, and evaluates whether drain mode has exceeded the grace period. Returns a rich object with Status, Host, DrainMode, StateSince, StateDurationSeconds, transition info, and who changed it. Status values: healthy - connections allowed grace - drain mode active but within grace period alert - drain mode active beyond grace period error - could not read registry Sets $LASTEXITCODE (0 = healthy/grace, 1 = alert, 2 = error). .PARAMETER DBPath Path to the JSONL audit trail file. Default: %ProgramData%\drainctl\audit.jsonl .PARAMETER GraceMinutes Minutes drain mode must persist before alerting. Default: 60. .PARAMETER RetentionDays Days to retain audit records. Default: 90. .EXAMPLE PS> Get-RDSHDrainMode Host : RDSH01 DrainMode : ALLOW_ALL_CONNECTIONS Status : healthy StateDurationSeconds : 3600 .EXAMPLE PS> Get-RDSHDrainMode -GraceMinutes 120 | Select-Object Host, Status, DrainMode .EXAMPLE PS> Get-RDSHDrainMode | Where-Object Status -ne 'healthy' #> [CmdletBinding()] [OutputType([PSCustomObject])] param( [Parameter()] [string]$DBPath = '', [Parameter()] [ValidateRange(0, [int]::MaxValue)] [int]$GraceMinutes = 60, [Parameter()] [ValidateRange(1, 365)] [int]$RetentionDays = 90 ) $ptr = [DrainCtlNative]::DrainCtl_Check($DBPath, $GraceMinutes, $RetentionDays) $raw = Invoke-DrainCtlNative -Ptr $ptr $global:LASTEXITCODE = $raw.exit_code [PSCustomObject]@{ PSTypeName = 'DrainCtl.CheckResult' Timestamp = ConvertTo-NativeDateTime $raw.timestamp Host = $raw.host DrainMode = $raw.drain_mode DrainModeValue = $raw.drain_mode_value StateSince = ConvertTo-NativeDateTime $raw.state_since StateDurationSeconds = if ($null -ne $raw.state_duration_seconds) { [int]$raw.state_duration_seconds } else { $null } GracePeriodSeconds = $raw.grace_period_seconds Status = $raw.status ConnectionsAllowed = $raw.connections_allowed Transition = $raw.transition TransitionFrom = Get-SafeProperty $raw 'transition_from' ChangedBy = Get-SafeProperty $raw 'changed_by' Message = Get-SafeProperty $raw 'message' ExitCode = $raw.exit_code Performance = $( $perf = Get-SafeProperty $raw 'performance' if ($null -ne $perf) { [PSCustomObject]@{ CPUPct = Get-SafeProperty $perf 'cpu_pct' 0 MemAvailMB = Get-SafeProperty $perf 'mem_avail_mb' 0 MemTotalMB = Get-SafeProperty $perf 'mem_total_mb' 0 PagesSec = Get-SafeProperty $perf 'pages_sec' 0 DiskQueue = Get-SafeProperty $perf 'disk_queue' 0 TCPRetrans = Get-SafeProperty $perf 'tcp_retrans_sec' 0 InputDelayP50 = Get-SafeProperty $perf 'input_delay_p50_ms' 0 InputDelayP95 = Get-SafeProperty $perf 'input_delay_p95_ms' 0 InputDelayMax = Get-SafeProperty $perf 'input_delay_max_ms' 0 SessionCPUP95 = Get-SafeProperty $perf 'session_cpu_p95_pct' 0 SessionMemP95 = Get-SafeProperty $perf 'session_mem_p95_bytes' 0 RFXAvailable = Get-SafeProperty $perf 'rfx_available' $false RFXFPSOut = Get-SafeProperty $perf 'rfx_fps_out' 0 RFXSkipServer = Get-SafeProperty $perf 'rfx_skip_server_sec' 0 RFXSkipNet = Get-SafeProperty $perf 'rfx_skip_net_sec' 0 RFXEncodeMS = Get-SafeProperty $perf 'rfx_encode_ms' 0 RFXQuality = Get-SafeProperty $perf 'rfx_quality_pct' 0 RFXRTT = Get-SafeProperty $perf 'rfx_rtt_ms' 0 RFXLoss = Get-SafeProperty $perf 'rfx_loss_pct' 0 } } else { $null } ) } } function Test-RDSHDrainMode { <# .SYNOPSIS Tests whether RDSH connections are allowed. Returns $true or $false. .DESCRIPTION Returns $true if the server is accepting connections (drain mode is off or within the grace period). Returns $false if drain mode is active beyond the grace period or the registry cannot be read. Also records an audit entry and sets $LASTEXITCODE (0 = pass, 1 = alert, 2 = error). .PARAMETER DBPath Path to the JSONL audit trail file. Default: %ProgramData%\drainctl\audit.jsonl .PARAMETER GraceMinutes Minutes drain mode must persist before alerting. Default: 60. .PARAMETER RetentionDays Days to retain audit records. Default: 90. .EXAMPLE PS> if (Test-RDSHDrainMode) { 'OK' } else { 'ALERT' } .EXAMPLE PS> Test-RDSHDrainMode -GraceMinutes 120 True #> [CmdletBinding()] [OutputType([bool])] param( [Parameter()] [string]$DBPath = '', [Parameter()] [ValidateRange(0, [int]::MaxValue)] [int]$GraceMinutes = 60, [Parameter()] [ValidateRange(1, 365)] [int]$RetentionDays = 90 ) $ptr = [DrainCtlNative]::DrainCtl_Check($DBPath, $GraceMinutes, $RetentionDays) $result = Invoke-DrainCtlNative -Ptr $ptr $global:LASTEXITCODE = $result.exit_code $result.exit_code -eq 0 } function Get-RDSHDrainHistory { <# .SYNOPSIS Retrieves the drain mode audit trail. .DESCRIPTION Returns audit records from the JSONL trail, newest first. Each record includes the timestamp, host, drain mode, whether a state transition occurred, and who made the change. .PARAMETER DBPath Path to the JSONL audit trail file. Default: %ProgramData%\drainctl\audit.jsonl .PARAMETER Limit Maximum number of records to return. Default: 50. .PARAMETER ChangesOnly If set, returns only records where a state transition occurred. .EXAMPLE PS> Get-RDSHDrainHistory | Format-Table .EXAMPLE PS> Get-RDSHDrainHistory -ChangesOnly -Limit 10 .EXAMPLE PS> Get-RDSHDrainHistory | Where-Object Changed -eq $true #> [CmdletBinding()] [OutputType([PSCustomObject[]])] param( [Parameter()] [string]$DBPath = '', [Parameter()] [ValidateRange(1, [int]::MaxValue)] [int]$Limit = 50, [Parameter()] [switch]$ChangesOnly ) $co = if ($ChangesOnly) { 1 } else { 0 } $ptr = [DrainCtlNative]::DrainCtl_History($DBPath, $Limit, $co) $records = Invoke-DrainCtlNative -Ptr $ptr if ($null -eq $records -or @($records).Count -eq 0) { return } foreach ($r in $records) { [PSCustomObject]@{ PSTypeName = 'DrainCtl.AuditRecord' Timestamp = ConvertTo-NativeDateTime $r.timestamp Host = $r.host DrainMode = $r.drain_mode DrainModeValue = $r.drain_mode_value StateDurationSeconds = Get-SafeProperty $r 'state_duration_seconds' Changed = $r.changed ChangedBy = Get-SafeProperty $r 'changed_by' ActiveSessions = Get-SafeProperty $r 'active_sessions' 0 DisconnectedSessions = Get-SafeProperty $r 'disconnected_sessions' 0 TotalSessions = Get-SafeProperty $r 'total_sessions' 0 MaxSessions = Get-SafeProperty $r 'max_sessions' 0 CPUPct = Get-SafeProperty $r 'cpu_pct' $null InputDelayMax = Get-SafeProperty $r 'input_delay_max_ms' $null ExitCode = $r.exit_code } } } function Install-RDSHDrainAudit { <# .SYNOPSIS Configures registry auditing for drain mode change attribution. .DESCRIPTION One-time setup that enables Object Access auditing and sets a SACL on the Terminal Server registry key so that Windows records Event ID 4657 whenever TSServerDrainMode is modified. This allows Get-RDSHDrainMode and drainctl to attribute changes to specific users. Must be run elevated (as Administrator or SYSTEM). On domain-joined machines, Group Policy may override the local auditpol settings on the next GP refresh (~90 minutes). The command emits warnings with the exact GPO path to configure for persistence. .EXAMPLE PS> Install-RDSHDrainAudit #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')] [OutputType([void])] param() if (-not $PSCmdlet.ShouldProcess('Registry auditing for TSServerDrainMode', 'Configure')) { return } $ptr = [DrainCtlNative]::DrainCtl_AuditSetup() $null = Invoke-DrainCtlNative -Ptr $ptr Write-Verbose 'Registry auditing configured. Event ID 4657 will now record TSServerDrainMode changes.' } function Get-RDSHDrainNotificationTarget { <# .SYNOPSIS Lists all configured notification targets. .DESCRIPTION Returns all notification targets from config.json as structured objects. Each target has a Type (webhook or ntfy), URL, Triggers array, and RepeatMinutes setting. .EXAMPLE PS> Get-RDSHDrainNotificationTarget Type URL Triggers RepeatMinutes ---- --- -------- ------------- webhook https://hooks.example.com/drain {drain_on, drain_off, alert} 15 ntfy https://ntfy.sh/drainctl-alerts {alert} 0 .EXAMPLE PS> Get-RDSHDrainNotificationTarget | Where-Object Type -eq 'webhook' #> [CmdletBinding()] [OutputType([PSCustomObject[]])] param() $ptr = [DrainCtlNative]::DrainCtl_GetSettings() $raw = Invoke-DrainCtlNative -Ptr $ptr $targets = Get-SafeProperty $raw 'notifications' @() if ($null -eq $targets -or @($targets).Count -eq 0) { return } foreach ($t in $targets) { [PSCustomObject]@{ PSTypeName = 'DrainCtl.NotificationTarget' Type = $t.type URL = $t.url Triggers = @($t.triggers) RepeatMinutes = [int](Get-SafeProperty $t 'repeat_minutes' 0) } } } function Add-RDSHDrainNotificationTarget { <# .SYNOPSIS Adds a notification target to DrainCtl. .DESCRIPTION Appends a new notification target (webhook, ntfy, or email) to the notifications array in config.json. The mutation is atomic — drainctl.dll walks the same code path as the CLI, so the dashboard cannot race with this call. Pass -Secret as a [SecureString] to set the webhook HMAC key or SMTP password. The plaintext is DPAPI-encrypted by drainctl before it lands on disk. .PARAMETER Type Target type: 'webhook', 'ntfy', or 'email'. .PARAMETER URL Target URL. For email use smtp:// or smtps:// (e.g. smtp://mail.example.com:587). .PARAMETER Triggers Array of trigger names. Default: drain_on, drain_off, alert, healthy. .PARAMETER RepeatMinutes Minutes between repeated alert notifications. 0 = notify once only. Default: 0. .PARAMETER Secret SecureString containing the HMAC signing secret (webhook) or SMTP password (email). Stored DPAPI-encrypted. Use: $sec = Read-Host -AsSecureString $sec = $env:DRAINCTL_SMTP_PASSWORD | ConvertTo-SecureString -AsPlainText -Force .PARAMETER From (email only) Sender address. .PARAMETER To (email only) One or more recipient addresses. .EXAMPLE PS> Add-RDSHDrainNotificationTarget -Type webhook -URL 'https://hooks.example.com/drain' -Secret (Read-Host -AsSecureString) .EXAMPLE PS> $pw = $env:SMTP_PASSWORD | ConvertTo-SecureString -AsPlainText -Force PS> Add-RDSHDrainNotificationTarget -Type email -URL 'smtp://mail.example.com:587' ` -From 'alerts@example.com' -To 'ops@example.com','oncall@example.com' -Secret $pw #> [CmdletBinding(SupportsShouldProcess)] [OutputType([void])] param( [Parameter(Mandatory)] [ValidateSet('webhook', 'ntfy', 'email')] [string]$Type, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$URL, [Parameter()] [ValidateSet('drain_on', 'drain_off', 'grace_entered', 'alert', 'healthy', 'session_warning', 'cpu_warning', 'cpu_critical', 'input_delay_warning', 'input_delay_critical', 'memory_warning', 'memory_critical')] [string[]]$Triggers = @('drain_on', 'drain_off', 'alert', 'healthy'), [Parameter()] [ValidateRange(0, [int]::MaxValue)] [int]$RepeatMinutes = 0, [Parameter()] [System.Security.SecureString]$Secret, [Parameter()] [string]$From, [Parameter()] [string[]]$To ) if (-not $PSCmdlet.ShouldProcess("$Type target $URL", 'Add notification target')) { return } $payload = [ordered]@{ type = $Type url = $URL triggers = @($Triggers) repeat_minutes = $RepeatMinutes } if ($PSBoundParameters.ContainsKey('Secret')) { $payload['secret'] = ConvertFrom-SecureStringToPlainText -Secure $Secret } if ($PSBoundParameters.ContainsKey('From')) { $payload['from'] = $From } if ($PSBoundParameters.ContainsKey('To')) { $payload['to'] = @($To) } $jsonStr = $payload | ConvertTo-Json -Depth 4 -Compress $ptr = [DrainCtlNative]::DrainCtl_NotifyAppendTarget($jsonStr) $null = Invoke-DrainCtlNative -Ptr $ptr Write-Verbose "Added $Type notification target: $URL" } function Set-RDSHDrainNotificationTarget { <# .SYNOPSIS Updates an existing notification target in place. .DESCRIPTION Modifies the Nth target of the given type (use Get-RDSHDrainNotificationTarget or 'drainctl notify status' to see indices). Any parameter not supplied is preserved on the target. Pass -Secret as a [SecureString] to set the webhook HMAC key or SMTP password. The plaintext is DPAPI-encrypted by drainctl before it lands on disk. If TargetIndex is past the end of existing targets of Type, a new target is appended (matching the shared API semantics). .PARAMETER Type Target type: 'webhook', 'ntfy', or 'email'. .PARAMETER TargetIndex 0-based index among targets of Type. Default: 0 (first target of that type). .PARAMETER URL Replacement URL. Required by the shared API. .PARAMETER Triggers Replacement trigger list (omit to preserve existing). .PARAMETER RepeatMinutes Replacement repeat interval (omit to preserve existing). .PARAMETER Secret SecureString containing new HMAC secret or SMTP password. Omit to preserve the existing secret. .PARAMETER From (email) Sender address (omit to preserve). .PARAMETER To (email) Recipient addresses (omit to preserve). .EXAMPLE PS> $pw = $env:SMTP_PASSWORD | ConvertTo-SecureString -AsPlainText -Force PS> Set-RDSHDrainNotificationTarget -Type email -TargetIndex 0 ` -URL 'smtp://mail.example.com:587' -Secret $pw .EXAMPLE PS> Set-RDSHDrainNotificationTarget -Type webhook -TargetIndex 1 ` -URL 'https://hooks.example.com/drain' -RepeatMinutes 30 #> [CmdletBinding(SupportsShouldProcess)] [OutputType([void])] param( [Parameter(Mandatory)] [ValidateSet('webhook', 'ntfy', 'email')] [string]$Type, [Parameter()] [ValidateRange(0, [int]::MaxValue)] [int]$TargetIndex = 0, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$URL, [Parameter()] [ValidateSet('drain_on', 'drain_off', 'grace_entered', 'alert', 'healthy', 'session_warning', 'cpu_warning', 'cpu_critical', 'input_delay_warning', 'input_delay_critical', 'memory_warning', 'memory_critical')] [string[]]$Triggers, [Parameter()] [ValidateRange(0, [int]::MaxValue)] [int]$RepeatMinutes, [Parameter()] [System.Security.SecureString]$Secret, [Parameter()] [string]$From, [Parameter()] [string[]]$To ) if (-not $PSCmdlet.ShouldProcess("$Type target #$TargetIndex", 'Update notification target')) { return } $payload = [ordered]@{ type = $Type url = $URL target_index = $TargetIndex } if ($PSBoundParameters.ContainsKey('Triggers')) { $payload['triggers'] = @($Triggers) } if ($PSBoundParameters.ContainsKey('RepeatMinutes')) { $payload['repeat_minutes'] = $RepeatMinutes } if ($PSBoundParameters.ContainsKey('Secret')) { $payload['secret'] = ConvertFrom-SecureStringToPlainText -Secure $Secret } if ($PSBoundParameters.ContainsKey('From')) { $payload['from'] = $From } if ($PSBoundParameters.ContainsKey('To')) { $payload['to'] = @($To) } $jsonStr = $payload | ConvertTo-Json -Depth 4 -Compress $ptr = [DrainCtlNative]::DrainCtl_NotifySetTarget($jsonStr) $null = Invoke-DrainCtlNative -Ptr $ptr Write-Verbose "Updated $Type[$TargetIndex]: $URL" } function Remove-RDSHDrainNotificationTarget { <# .SYNOPSIS Removes a notification target from DrainCtl by URL or by (Type, TargetIndex). .DESCRIPTION Two parameter sets: - ByURL (default, legacy): removes the first target whose URL matches. - ByIndex: removes the Nth target of the given type — preferred for deterministic scripting against multi-target configurations. Both paths are atomic via drainctl.dll. .PARAMETER URL URL of the target to remove (case-insensitive match). ByURL parameter set. .PARAMETER Type Target type ('webhook', 'ntfy', or 'email'). ByIndex parameter set. .PARAMETER TargetIndex 0-based index among targets of Type. ByIndex parameter set. .EXAMPLE PS> Remove-RDSHDrainNotificationTarget -URL 'https://hooks.example.com/drain' .EXAMPLE PS> Remove-RDSHDrainNotificationTarget -Type webhook -TargetIndex 1 #> [CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'ByURL')] [OutputType([void])] param( [Parameter(Mandatory, ValueFromPipelineByPropertyName, ParameterSetName = 'ByURL')] [ValidateNotNullOrEmpty()] [string]$URL, [Parameter(Mandatory, ParameterSetName = 'ByIndex')] [ValidateSet('webhook', 'ntfy', 'email')] [string]$Type, [Parameter(Mandatory, ParameterSetName = 'ByIndex')] [ValidateRange(0, [int]::MaxValue)] [int]$TargetIndex ) process { if ($PSCmdlet.ParameterSetName -eq 'ByIndex') { if (-not $PSCmdlet.ShouldProcess("$Type[$TargetIndex]", 'Remove notification target')) { return } $ptr = [DrainCtlNative]::DrainCtl_NotifyRemoveTarget($Type, $TargetIndex) $null = Invoke-DrainCtlNative -Ptr $ptr Write-Verbose "Removed $Type[$TargetIndex]" return } if (-not $PSCmdlet.ShouldProcess("target $URL", 'Remove notification target')) { return } # Legacy URL-match path: read, filter, write whole notifications array. $ptr = [DrainCtlNative]::DrainCtl_GetSettings() $raw = Invoke-DrainCtlNative -Ptr $ptr $targets = @(Get-SafeProperty $raw 'notifications' @()) $filtered = @($targets | Where-Object { $_.url -ine $URL }) if ($filtered.Count -eq $targets.Count) { Write-Warning "No notification target found with URL: $URL" return } $payload = @{ notifications = @($filtered) } $jsonStr = $payload | ConvertTo-Json -Depth 4 -Compress $ptr = [DrainCtlNative]::DrainCtl_SetSettings($jsonStr) $null = Invoke-DrainCtlNative -Ptr $ptr Write-Verbose "Removed notification target: $URL" } } function Test-RDSHDrainNotificationTarget { <# .SYNOPSIS Sends a test notification to one, several, or all configured targets. .DESCRIPTION Returns one structured result per target ({Type, URL, TypeIndex, OK, Error}) so the operator can see exactly which target failed and why — no more "1 of 3 failed" without context. With no parameters: tests every configured target. With pipeline input from Get-RDSHDrainNotificationTarget: tests only those objects (matched by Type + URL). .PARAMETER URL Pipeline-bound URL of a target to test (matched together with Type). .PARAMETER Type Pipeline-bound type ('webhook' / 'ntfy' / 'email'). .EXAMPLE PS> Test-RDSHDrainNotificationTarget # tests all .EXAMPLE PS> Get-RDSHDrainNotificationTarget | Where-Object Type -eq 'webhook' | Test-RDSHDrainNotificationTarget # tests only webhooks #> [CmdletBinding()] [OutputType([PSCustomObject])] param( [Parameter(ValueFromPipelineByPropertyName)] [string]$URL, [Parameter(ValueFromPipelineByPropertyName)] [ValidateSet('webhook', 'ntfy', 'email')] [string]$Type ) begin { $filter = @() } process { if ($PSBoundParameters.ContainsKey('URL') -or $PSBoundParameters.ContainsKey('Type')) { $filter += [PSCustomObject]@{ Type = $Type; URL = $URL } } } end { # The DLL endpoint tests all targets and returns per-target results. # When the operator passed a filter via the pipeline, we filter the # returned results down to the matching subset. $ptr = [DrainCtlNative]::DrainCtl_TestNotify() $raw = Invoke-DrainCtlNative -Ptr $ptr $results = @(Get-SafeProperty $raw 'results' @()) if ($filter.Count -gt 0) { $results = $results | Where-Object { $r = $_ $filter | Where-Object { ($_.Type -eq '' -or $_.Type -eq $r.type) -and ($_.URL -eq '' -or $_.URL -eq $r.url) } | Select-Object -First 1 } } foreach ($r in $results) { [PSCustomObject]@{ PSTypeName = 'DrainCtl.NotificationTestResult' Type = $r.type URL = $r.url TypeIndex = [int](Get-SafeProperty $r 'type_index' 0) OK = [bool]$r.ok Error = Get-SafeProperty $r 'error' '' } } } } function Enable-RDSHDrainDashboard { <# .SYNOPSIS Enable the DrainCtl multi-server dashboard on this server. .PARAMETER Port Port the dashboard listens on. Default: 49470. .PARAMETER Group AD group authorized to view the dashboard. Default: Domain Admins. .EXAMPLE Enable-RDSHDrainDashboard -Port 49470 -Group "RDS Admins" #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')] param( [ValidateRange(1, 65535)][int]$Port = 49470, [ValidateNotNullOrEmpty()][string]$Group = 'Domain Admins' ) if (-not $PSCmdlet.ShouldProcess('DrainCtl Dashboard', 'Enable')) { return } $ptr = [DrainCtlNative]::DrainCtl_EnableDashboard($Port, $Group) $null = Invoke-DrainCtlNative -Ptr $ptr Write-Host "Dashboard enabled on port $Port for group '$Group'." Write-Host 'Restart the DrainCtl service to activate: Restart-Service DrainCtl' } function Disable-RDSHDrainDashboard { <# .SYNOPSIS Disable the DrainCtl dashboard on this server. .EXAMPLE Disable-RDSHDrainDashboard #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')] param() if (-not $PSCmdlet.ShouldProcess('DrainCtl Dashboard', 'Disable')) { return } $ptr = [DrainCtlNative]::DrainCtl_DisableDashboard() $null = Invoke-DrainCtlNative -Ptr $ptr Write-Host 'Dashboard disabled. Restart the DrainCtl service to apply: Restart-Service DrainCtl' } function Install-RDSHDrainCertificate { <# .SYNOPSIS Install a custom TLS certificate for the DrainCtl dashboard. .DESCRIPTION Copies a PEM certificate and private key into the DrainCtl data directory and updates config.json so the dashboard uses them instead of the auto-generated self-signed certificate. Restart the DrainCtl service after installing a new certificate. .PARAMETER CertPath Path to the PEM certificate file. .PARAMETER KeyPath Path to the PEM private key file. .EXAMPLE Install-RDSHDrainCertificate -CertPath C:\certs\dashboard.pem -KeyPath C:\certs\dashboard-key.pem #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')] param( [Parameter(Mandatory)][string]$CertPath, [Parameter(Mandatory)][string]$KeyPath ) if (-not (Test-Path $CertPath)) { throw "Certificate file not found: $CertPath" } if (-not (Test-Path $KeyPath)) { throw "Key file not found: $KeyPath" } if (-not $PSCmdlet.ShouldProcess("$CertPath + $KeyPath", 'Install as dashboard TLS certificate')) { return } $ptr = [DrainCtlNative]::DrainCtl_InstallCertificate($CertPath, $KeyPath) $null = Invoke-DrainCtlNative -Ptr $ptr Write-Host "Certificate installed. Restart the DrainCtl service to use the new certificate: Restart-Service DrainCtl" } Export-ModuleMember -Function @( 'Get-RDSHDrainMode' 'Test-RDSHDrainMode' 'Get-RDSHDrainHistory' 'Install-RDSHDrainAudit' 'Get-RDSHDrainNotificationTarget' 'Add-RDSHDrainNotificationTarget' 'Set-RDSHDrainNotificationTarget' 'Remove-RDSHDrainNotificationTarget' 'Test-RDSHDrainNotificationTarget' 'Enable-RDSHDrainDashboard' 'Disable-RDSHDrainDashboard' 'Install-RDSHDrainCertificate' ) # SIG # Begin signature block # MIItnAYJKoZIhvcNAQcCoIItjTCCLYkCAQExDzANBglghkgBZQMEAgEFADB5Bgor # BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG # KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCCz6UU39y9UNcIy # QPTZxztNZLxQubxSbrB2vB4fO75Xt6CCJp8wggWNMIIEdaADAgECAhAOmxiO+dAt # 5+/bUOIIQBhaMA0GCSqGSIb3DQEBDAUAMGUxCzAJBgNVBAYTAlVTMRUwEwYDVQQK # EwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xJDAiBgNV # BAMTG0RpZ2lDZXJ0IEFzc3VyZWQgSUQgUm9vdCBDQTAeFw0yMjA4MDEwMDAwMDBa # Fw0zMTExMDkyMzU5NTlaMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2Vy # dCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lD # ZXJ0IFRydXN0ZWQgUm9vdCBHNDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoC # ggIBAL/mkHNo3rvkXUo8MCIwaTPswqclLskhPfKK2FnC4SmnPVirdprNrnsbhA3E # MB/zG6Q4FutWxpdtHauyefLKEdLkX9YFPFIPUh/GnhWlfr6fqVcWWVVyr2iTcMKy # unWZanMylNEQRBAu34LzB4TmdDttceItDBvuINXJIB1jKS3O7F5OyJP4IWGbNOsF # xl7sWxq868nPzaw0QF+xembud8hIqGZXV59UWI4MK7dPpzDZVu7Ke13jrclPXuU1 # 5zHL2pNe3I6PgNq2kZhAkHnDeMe2scS1ahg4AxCN2NQ3pC4FfYj1gj4QkXCrVYJB # MtfbBHMqbpEBfCFM1LyuGwN1XXhm2ToxRJozQL8I11pJpMLmqaBn3aQnvKFPObUR # WBf3JFxGj2T3wWmIdph2PVldQnaHiZdpekjw4KISG2aadMreSx7nDmOu5tTvkpI6 # nj3cAORFJYm2mkQZK37AlLTSYW3rM9nF30sEAMx9HJXDj/chsrIRt7t/8tWMcCxB # YKqxYxhElRp2Yn72gLD76GSmM9GJB+G9t+ZDpBi4pncB4Q+UDCEdslQpJYls5Q5S # UUd0viastkF13nqsX40/ybzTQRESW+UQUOsxxcpyFiIJ33xMdT9j7CFfxCBRa2+x # q4aLT8LWRV+dIPyhHsXAj6KxfgommfXkaS+YHS312amyHeUbAgMBAAGjggE6MIIB # NjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBTs1+OC0nFdZEzfLmc/57qYrhwP # TzAfBgNVHSMEGDAWgBRF66Kv9JLLgjEtUYunpyGd823IDzAOBgNVHQ8BAf8EBAMC # AYYweQYIKwYBBQUHAQEEbTBrMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5kaWdp # Y2VydC5jb20wQwYIKwYBBQUHMAKGN2h0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0LmNv # bS9EaWdpQ2VydEFzc3VyZWRJRFJvb3RDQS5jcnQwRQYDVR0fBD4wPDA6oDigNoY0 # aHR0cDovL2NybDMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0QXNzdXJlZElEUm9vdENB # LmNybDARBgNVHSAECjAIMAYGBFUdIAAwDQYJKoZIhvcNAQEMBQADggEBAHCgv0Nc # Vec4X6CjdBs9thbX979XB72arKGHLOyFXqkauyL4hxppVCLtpIh3bb0aFPQTSnov # Lbc47/T/gLn4offyct4kvFIDyE7QKt76LVbP+fT3rDB6mouyXtTP0UNEm0Mh65Zy # oUi0mcudT6cGAxN3J0TU53/oWajwvy8LpunyNDzs9wPHh6jSTEAZNUZqaVSwuKFW # juyk1T3osdz9HNj0d1pcVIxv76FQPfx2CWiEn2/K2yCNNWAcAgPLILCsWKAOQGPF # mCLBsln1VWvPJ6tsds5vIy30fnFqI2si/xK4VC0nftg62fC2h5b9W9FcrBjDTZ9z # twGpn1eqXijiuZQwggXfMIIEx6ADAgECAhBOQOQ3VO3mjAAAAABR05R/MA0GCSqG # SIb3DQEBCwUAMIG+MQswCQYDVQQGEwJVUzEWMBQGA1UEChMNRW50cnVzdCwgSW5j # LjEoMCYGA1UECxMfU2VlIHd3dy5lbnRydXN0Lm5ldC9sZWdhbC10ZXJtczE5MDcG # A1UECxMwKGMpIDIwMDkgRW50cnVzdCwgSW5jLiAtIGZvciBhdXRob3JpemVkIHVz # ZSBvbmx5MTIwMAYDVQQDEylFbnRydXN0IFJvb3QgQ2VydGlmaWNhdGlvbiBBdXRo # b3JpdHkgLSBHMjAeFw0yMTA1MDcxNTQzNDVaFw0zMDExMDcxNjEzNDVaMGkxCzAJ # BgNVBAYTAlVTMRYwFAYDVQQKDA1FbnRydXN0LCBJbmMuMUIwQAYDVQQDDDlFbnRy # dXN0IENvZGUgU2lnbmluZyBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5IC0g # Q1NCUjEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCngY/3FEW2YkPy # 2K7TJV5IT1G/xX2fUBw10dZ+YSqUGW0nRqSmGl33VFFqgCLGqGZ1TVSDyV5oG6v2 # W2Swra0gvVTvRmttAudFrnX2joq5Mi6LuHccUk15iF+lOhjJUCyXJy2/2gB9Y3/v # MuxGh2Pbmp/DWiE2e/mb1cqgbnIs/OHxnnBNCFYVb5Cr+0i6udfBgniFZS5/tcnA # 4hS3NxFBBuKK4Kj25X62eAUBw2DtTwdBLgoTSeOQm3/dvfqsv2RR0VybtPVc51z/ # O5uloBrXfQmywrf/bhy8yH3m6Sv8crMU6UpVEoScRCV1HfYq8E+lID1oJethl3wP # 5bY9867DwRG8G47M4EcwXkIAhnHjWKwGymUfe5SmS1dnDH5erXhnW1XjXuvH2OxM # bobL89z4n4eqclgSD32m+PhCOTs8LOQyTUmM4OEAwjignPqEPkHcblauxhpb9Gdo # BQHNG7+uh7ydU/Yu6LZr5JnexU+HWKjSZR7IH9Vybu5ZHFc7CXKd18q3kMbNe0WS # kUIDTH0/yvKquMIOhvMQn0YupGaGaFpoGHApOBGAYGuKQ6NzbOOzazf/5p1nAZKG # 3y9I0ftQYNVc/iHTAUJj/u9wtBfAj6ju08FLXxLq/f0uDodEYOOp9MIYo+P9zgyE # Ig3zp3jak/PbOM+5LzPG/wc8Xr5F0wIDAQABo4IBKzCCAScwDgYDVR0PAQH/BAQD # AgGGMBIGA1UdEwEB/wQIMAYBAf8CAQEwHQYDVR0lBBYwFAYIKwYBBQUHAwMGCCsG # AQUFBwMIMDsGA1UdIAQ0MDIwMAYEVR0gADAoMCYGCCsGAQUFBwIBFhpodHRwOi8v # d3d3LmVudHJ1c3QubmV0L3JwYTAzBggrBgEFBQcBAQQnMCUwIwYIKwYBBQUHMAGG # F2h0dHA6Ly9vY3NwLmVudHJ1c3QubmV0MDAGA1UdHwQpMCcwJaAjoCGGH2h0dHA6 # Ly9jcmwuZW50cnVzdC5uZXQvZzJjYS5jcmwwHQYDVR0OBBYEFIK61j2Xzp/PceiS # N6/9s7VpNVfPMB8GA1UdIwQYMBaAFGpyJnrQHu995ztpUdRsjZ+QEmarMA0GCSqG # SIb3DQEBCwUAA4IBAQAfXkEEtoNwJFMsVXMdZTrA7LR7BJheWTgTCaRZlEJeUL9P # bG4lIJCTWEAN9Rm0Yu4kXsIBWBUCHRAJb6jU+5J+Nzg+LxR9jx1DNmSzZhNfFMyl # cfdbIUvGl77clfxwfREc0yHd0CQ5KcX+Chqlz3t57jpv3ty/6RHdFoMI0yyNf02o # FHkvBWFSOOtg8xRofcuyiq3AlFzkJg4sit1Gw87kVlHFVuOFuE2bRXKLB/GK+0m4 # X9HyloFdaVIk8Qgj0tYjD+uL136LwZNr+vFie1jpUJuXbheIDeHGQ5jXgWG2hZ1H # 7LGerj8gO0Od2KIc4NR8CMKvdgb4YmZ6tvf6yK81MIIGgzCCBGugAwIBAgIQNa+3 # e500H2r8j4RGqzE1KzANBgkqhkiG9w0BAQ0FADBpMQswCQYDVQQGEwJVUzEWMBQG # A1UECgwNRW50cnVzdCwgSW5jLjFCMEAGA1UEAww5RW50cnVzdCBDb2RlIFNpZ25p # bmcgUm9vdCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eSAtIENTQlIxMB4XDTIxMDUw # NzE5MTk1MloXDTQwMTIyOTIzNTkwMFowYzELMAkGA1UEBhMCVVMxFjAUBgNVBAoT # DUVudHJ1c3QsIEluYy4xPDA6BgNVBAMTM0VudHJ1c3QgRXh0ZW5kZWQgVmFsaWRh # dGlvbiBDb2RlIFNpZ25pbmcgQ0EgLSBFVkNTMjCCAiIwDQYJKoZIhvcNAQEBBQAD # ggIPADCCAgoCggIBAL69pznJpX3sXWXx9Cuph9DnrRrFGjsYzuGhUY1y+s5YH1y4 # JEIPRtUxl9BKTeObMMm6l6ic/kU2zyeA53u4bsEkt9+ndNyF8qMkWEXMlJQ7AuvE # jXxG9VxmguOkwdMfrG4MUyMO1Dr62kLxg1RfNTJW8rV4m1cASB6pYWEnDnMDQ7bW # cJL71IWaMMaz5ppeS+8dKthmqxZG/wvYD6aJSgJRV0E8QThOl8dRMm1njmahXk2f # NSKv1Wq3f0BfaDXMafrxBfDqhabqMoXLwcHKg2lFSQbcCWy6SWUZjPm3NyeMZJ41 # 4+Xs5wegnahyvG+FOiymFk49nM8I5oL1RH0owL2JrWwv3C94eRHXHHBL3Z0ITF4u # +o29p91j9n/wUjGEbjrY2VyFRJ5jBmnQhlh4iZuHu1gcpChsxv5pCpwerBFgal7J # aWUu7UMtafF4tzstNfKqT+If4wFvkEaq1agNBFegtKzjbb2dGyiAJ0bH2qpnlfHR # h3vHyCXphAyPiTbSvjPhhcAz1aA8GYuvOPLlk4C/xsOre5PEPZ257kV2wNRobzBe # PLQ2+ddFQuASBoDbpSH85wV6KI20jmB798i1SkesFGaXoFppcjFXa1OEzWG6cwcV # cDt7AfynP4wtPYeM+wjX5S8Xg36Cq08J8inhflV3ZZQFHVnUCt2TfuMUXeK7AgMB # AAGjggErMIIBJzASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBTOiU+CUaoV # ooRiyjEjYdJh+/j+eDAfBgNVHSMEGDAWgBSCutY9l86fz3Hokjev/bO1aTVXzzAz # BggrBgEFBQcBAQQnMCUwIwYIKwYBBQUHMAGGF2h0dHA6Ly9vY3NwLmVudHJ1c3Qu # bmV0MDEGA1UdHwQqMCgwJqAkoCKGIGh0dHA6Ly9jcmwuZW50cnVzdC5uZXQvY3Ni # cjEuY3JsMA4GA1UdDwEB/wQEAwIBhjATBgNVHSUEDDAKBggrBgEFBQcDAzBEBgNV # HSAEPTA7MDAGBFUdIAAwKDAmBggrBgEFBQcCARYaaHR0cDovL3d3dy5lbnRydXN0 # Lm5ldC9ycGEwBwYFZ4EMAQMwDQYJKoZIhvcNAQENBQADggIBAD4AVLgq849mr2EW # xFiTZPRBi2RVjRs1M6GbkdirRsqrX7y+fnDk0tcHqJYH14bRVwoI0NB4Tfgq37IE # 85rh13zwwQB6wUCh34qMt8u0HQFh8piapt24gwXKqSwW3JwtDv6nl+RQqZeVwUsq # jFHjxALga3w1TVO8S5QTi1MYFl6mCqe4NMFssess5DF9DCzGfOGkVugtdtWyE3Xq # gwCuAHfGb6k97mMUgVAW/FtPEhkOWw+N6kvOBkyJS64gzI5HpnXWZe4vMOhdNI8f # gk1cQqbyFExQIJwJonQkXDnYiTKFPK+M5Wqe5gQ6pRP/qh3NR0suAgW0ao/rhU+B # 7wrbfZ8pj6XCP1I4UkGVO7w+W1QwQiMJY95QjYk1RfqruA+Poq17ehGT8Y8ohHto # eUdq6GQpTR/0HS9tHsiUhjzTWpl6a3yrNfcrOUtPuT8Wku8pjI2rrAEazHFEOctA # PiASzghw40f+3IDXCADRC2rqIbV5ZhfpaqpW3c0VeLEDwBStPkcYde0KU0syk83/ # gLGQ1hPl5EF4Iu1BguUO37DOlSFF5osB0xn39CtVrNlWc2MQ4LigbctUlpigmSFR # BqqmDDorY8t52kO50hLM3o9VeukJ8+Ka0yXBezaS2uDlUmfN4+ZUCqWd1HOj0y9d # BmSFA3d/YNjCvHTJlZFot7d+YRl1MIIGtDCCBJygAwIBAgIQDcesVwX/IZkuQEMi # DDpJhjANBgkqhkiG9w0BAQsFADBiMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGln # aUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSEwHwYDVQQDExhE # aWdpQ2VydCBUcnVzdGVkIFJvb3QgRzQwHhcNMjUwNTA3MDAwMDAwWhcNMzgwMTE0 # MjM1OTU5WjBpMQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4x # QTA/BgNVBAMTOERpZ2lDZXJ0IFRydXN0ZWQgRzQgVGltZVN0YW1waW5nIFJTQTQw # OTYgU0hBMjU2IDIwMjUgQ0ExMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKC # AgEAtHgx0wqYQXK+PEbAHKx126NGaHS0URedTa2NDZS1mZaDLFTtQ2oRjzUXMmxC # qvkbsDpz4aH+qbxeLho8I6jY3xL1IusLopuW2qftJYJaDNs1+JH7Z+QdSKWM06qc # hUP+AbdJgMQB3h2DZ0Mal5kYp77jYMVQXSZH++0trj6Ao+xh/AS7sQRuQL37QXbD # hAktVJMQbzIBHYJBYgzWIjk8eDrYhXDEpKk7RdoX0M980EpLtlrNyHw0Xm+nt5pn # YJU3Gmq6bNMI1I7Gb5IBZK4ivbVCiZv7PNBYqHEpNVWC2ZQ8BbfnFRQVESYOszFI # 2Wv82wnJRfN20VRS3hpLgIR4hjzL0hpoYGk81coWJ+KdPvMvaB0WkE/2qHxJ0ucS # 638ZxqU14lDnki7CcoKCz6eum5A19WZQHkqUJfdkDjHkccpL6uoG8pbF0LJAQQZx # st7VvwDDjAmSFTUms+wV/FbWBqi7fTJnjq3hj0XbQcd8hjj/q8d6ylgxCZSKi17y # Vp2NL+cnT6Toy+rN+nM8M7LnLqCrO2JP3oW//1sfuZDKiDEb1AQ8es9Xr/u6bDTn # YCTKIsDq1BtmXUqEG1NqzJKS4kOmxkYp2WyODi7vQTCBZtVFJfVZ3j7OgWmnhFr4 # yUozZtqgPrHRVHhGNKlYzyjlroPxul+bgIspzOwbtmsgY1MCAwEAAaOCAV0wggFZ # MBIGA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0OBBYEFO9vU0rp5AZ8esrikFb2L9RJ # 7MtOMB8GA1UdIwQYMBaAFOzX44LScV1kTN8uZz/nupiuHA9PMA4GA1UdDwEB/wQE # AwIBhjATBgNVHSUEDDAKBggrBgEFBQcDCDB3BggrBgEFBQcBAQRrMGkwJAYIKwYB # BQUHMAGGGGh0dHA6Ly9vY3NwLmRpZ2ljZXJ0LmNvbTBBBggrBgEFBQcwAoY1aHR0 # cDovL2NhY2VydHMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0VHJ1c3RlZFJvb3RHNC5j # cnQwQwYDVR0fBDwwOjA4oDagNIYyaHR0cDovL2NybDMuZGlnaWNlcnQuY29tL0Rp # Z2lDZXJ0VHJ1c3RlZFJvb3RHNC5jcmwwIAYDVR0gBBkwFzAIBgZngQwBBAIwCwYJ # YIZIAYb9bAcBMA0GCSqGSIb3DQEBCwUAA4ICAQAXzvsWgBz+Bz0RdnEwvb4LyLU0 # pn/N0IfFiBowf0/Dm1wGc/Do7oVMY2mhXZXjDNJQa8j00DNqhCT3t+s8G0iP5kvN # 2n7Jd2E4/iEIUBO41P5F448rSYJ59Ib61eoalhnd6ywFLerycvZTAz40y8S4F3/a # +Z1jEMK/DMm/axFSgoR8n6c3nuZB9BfBwAQYK9FHaoq2e26MHvVY9gCDA/JYsq7p # GdogP8HRtrYfctSLANEBfHU16r3J05qX3kId+ZOczgj5kjatVB+NdADVZKON/gnZ # ruMvNYY2o1f4MXRJDMdTSlOLh0HCn2cQLwQCqjFbqrXuvTPSegOOzr4EWj7PtspI # HBldNE2K9i697cvaiIo2p61Ed2p8xMJb82Yosn0z4y25xUbI7GIN/TpVfHIqQ6Ku # /qjTY6hc3hsXMrS+U0yy+GWqAXam4ToWd2UQ1KYT70kZjE4YtL8Pbzg0c1ugMZyZ # Zd/BdHLiRu7hAWE6bTEm4XYRkA6Tl4KSFLFk43esaUeqGkH/wyW4N7OigizwJWeu # kcyIPbAvjSabnf7+Pu0VrFgoiovRDiyx3zEdmcif/sYQsfch28bZeUz2rtY/9TCA # 6TD8dC3JE3rYkrhLULy7Dc90G6e8BlqmyIjlgp2+VqsS9/wQD7yFylIz0scmbKvF # oW2jNrbM1pD2T7m3XDCCBu0wggTVoAMCAQICEAqA7xhLjfEFgtHEdqeVdGgwDQYJ # KoZIhvcNAQELBQAwaTELMAkGA1UEBhMCVVMxFzAVBgNVBAoTDkRpZ2lDZXJ0LCBJ # bmMuMUEwPwYDVQQDEzhEaWdpQ2VydCBUcnVzdGVkIEc0IFRpbWVTdGFtcGluZyBS # U0E0MDk2IFNIQTI1NiAyMDI1IENBMTAeFw0yNTA2MDQwMDAwMDBaFw0zNjA5MDMy # MzU5NTlaMGMxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjE7 # MDkGA1UEAxMyRGlnaUNlcnQgU0hBMjU2IFJTQTQwOTYgVGltZXN0YW1wIFJlc3Bv # bmRlciAyMDI1IDEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDQRqwt # Esae0OquYFazK1e6b1H/hnAKAd/KN8wZQjBjMqiZ3xTWcfsLwOvRxUwXcGx8AUjn # i6bz52fGTfr6PHRNv6T7zsf1Y/E3IU8kgNkeECqVQ+3bzWYesFtkepErvUSbf+EI # YLkrLKd6qJnuzK8Vcn0DvbDMemQFoxQ2Dsw4vEjoT1FpS54dNApZfKY61HAldytx # NM89PZXUP/5wWWURK+IfxiOg8W9lKMqzdIo7VA1R0V3Zp3DjjANwqAf4lEkTlCDQ # 0/fKJLKLkzGBTpx6EYevvOi7XOc4zyh1uSqgr6UnbksIcFJqLbkIXIPbcNmA98Os # kkkrvt6lPAw/p4oDSRZreiwB7x9ykrjS6GS3NR39iTTFS+ENTqW8m6THuOmHHjQN # C3zbJ6nJ6SXiLSvw4Smz8U07hqF+8CTXaETkVWz0dVVZw7knh1WZXOLHgDvundrA # tuvz0D3T+dYaNcwafsVCGZKUhQPL1naFKBy1p6llN3QgshRta6Eq4B40h5avMcpi # 54wm0i2ePZD5pPIssoszQyF4//3DoK2O65Uck5Wggn8O2klETsJ7u8xEehGifgJY # i+6I03UuT1j7FnrqVrOzaQoVJOeeStPeldYRNMmSF3voIgMFtNGh86w3ISHNm0Ia # adCKCkUe2LnwJKa8TIlwCUNVwppwn4D3/Pt5pwIDAQABo4IBlTCCAZEwDAYDVR0T # AQH/BAIwADAdBgNVHQ4EFgQU5Dv88jHt/f3X85FxYxlQQ89hjOgwHwYDVR0jBBgw # FoAU729TSunkBnx6yuKQVvYv1Ensy04wDgYDVR0PAQH/BAQDAgeAMBYGA1UdJQEB # /wQMMAoGCCsGAQUFBwMIMIGVBggrBgEFBQcBAQSBiDCBhTAkBggrBgEFBQcwAYYY # aHR0cDovL29jc3AuZGlnaWNlcnQuY29tMF0GCCsGAQUFBzAChlFodHRwOi8vY2Fj # ZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkRzRUaW1lU3RhbXBpbmdS # U0E0MDk2U0hBMjU2MjAyNUNBMS5jcnQwXwYDVR0fBFgwVjBUoFKgUIZOaHR0cDov # L2NybDMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0VHJ1c3RlZEc0VGltZVN0YW1waW5n # UlNBNDA5NlNIQTI1NjIwMjVDQTEuY3JsMCAGA1UdIAQZMBcwCAYGZ4EMAQQCMAsG # CWCGSAGG/WwHATANBgkqhkiG9w0BAQsFAAOCAgEAZSqt8RwnBLmuYEHs0QhEnmNA # ciH45PYiT9s1i6UKtW+FERp8FgXRGQ/YAavXzWjZhY+hIfP2JkQ38U+wtJPBVBaj # YfrbIYG+Dui4I4PCvHpQuPqFgqp1PzC/ZRX4pvP/ciZmUnthfAEP1HShTrY+2DE5 # qjzvZs7JIIgt0GCFD9ktx0LxxtRQ7vllKluHWiKk6FxRPyUPxAAYH2Vy1lNM4kze # kd8oEARzFAWgeW3az2xejEWLNN4eKGxDJ8WDl/FQUSntbjZ80FU3i54tpx5F/0Kr # 15zW/mJAxZMVBrTE2oi0fcI8VMbtoRAmaaslNXdCG1+lqvP4FbrQ6IwSBXkZagHL # hFU9HCrG/syTRLLhAezu/3Lr00GrJzPQFnCEH1Y58678IgmfORBPC1JKkYaEt2Od # Dh4GmO0/5cHelAK2/gTlQJINqDr6JfwyYHXSd+V08X1JUPvB4ILfJdmL+66Gp3CS # BXG6IwXMZUXBhtCyIaehr0XkBoDIGMUG1dUtwq1qmcwbdUfcSYCn+OwncVUXf53V # JUNOaMWMts0VlRYxe5nK+At+DI96HAlXHAL5SlfYxJ7La54i71McVWRP66bW+yER # NpbJCjyCYG2j+bdpxo/1Cy4uPcU3AWVPGrbn5PhDBf3Froguzzhk++ami+r3Qrx5 # bIbY3TVzgiFI7Gq3zWcwggb3MIIE36ADAgECAhBUqhzmzdht2UDqAdaKxc8tMA0G # CSqGSIb3DQEBCwUAMGMxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1FbnRydXN0LCBJ # bmMuMTwwOgYDVQQDEzNFbnRydXN0IEV4dGVuZGVkIFZhbGlkYXRpb24gQ29kZSBT # aWduaW5nIENBIC0gRVZDUzIwHhcNMjMxMTExMDIzNDE2WhcNMjYxMTExMDIzNDE1 # WjCB0jELMAkGA1UEBhMCVVMxETAPBgNVBAgTCE5ldyBZb3JrMQ8wDQYDVQQHEwZS # b3NseW4xEzARBgsrBgEEAYI3PAIBAxMCVVMxGTAXBgsrBgEEAYI3PAIBAhMITmV3 # IFlvcmsxHjAcBgNVBAoTFUxJU1MgQ29uc3VsdGluZyBDb3JwLjEdMBsGA1UEDxMU # UHJpdmF0ZSBPcmdhbml6YXRpb24xEDAOBgNVBAUTBzEyMDM3MDQxHjAcBgNVBAMT # FUxJU1MgQ29uc3VsdGluZyBDb3JwLjCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCC # AgoCggIBAKNsPm91CdVgVgztDDAOq1XypBtPfEFiuIjryZikaVh3+iPoixSaa4MA # MZe8gR//KdBwqwyfKHRLj79VfmtcRQJtcuuzfdRXlmnvZOcfwOuhnl7dp3ZyON2B # +m/wTxvRulpTfOf7Xa/XD+vseSMZk5Cr3VGs5c8CnfFPxboSGjxPI5iNfEe/hJvI # BS/aYVL/sZqNdCqarwUCC0YuaVCbOiOlpV1h3hfrVQq9eB5FVI8u7YRh0jetAt96 # LoiYwXxmLdxXtMHAZPhLCfJndTVwOgo6P08j+BFViHtHZGOLgH9gC32OPZvGAM69 # IoessdwAK31fBO/alVk2TBnjjaCMiLD7goDYIP9GzDE+o8rO8pcyse4a1s+uF4By # DiotV0/3L1XFneFA9llG1PgmpU0P7myHJGa2BTUuNcZ5NVNEdINGCg3rDEb2oRje # ukOn83iRtsTnV8kdd4BXuEFptjNqj9M6fvk+LJxsZZ7pKaNGlugPH/hb93+2WXd3 # ImzPCLBOQBs9Ms7rgjlGzfZP/cTJibogaYNYhb6mblEHpm5UhBNrJk9ONRNfDjDB # Lz7eeAWtZGHerL3vpaBHCC4QA1aIKMmolnXjBCAsEhqbJnKZEb/fVjfU7fX5/TQJ # lu+w6AZ6y4rBITex0QMGUlcYh1pnQf0tTikfyH250Gyr1pBaD1rvAgMBAAGjggE1 # MIIBMTAMBgNVHRMBAf8EAjAAMB0GA1UdDgQWBBS8w1GaCwrKaNGXgF9k+1HbSv8Y # uTAfBgNVHSMEGDAWgBTOiU+CUaoVooRiyjEjYdJh+/j+eDBnBggrBgEFBQcBAQRb # MFkwIwYIKwYBBQUHMAGGF2h0dHA6Ly9vY3NwLmVudHJ1c3QubmV0MDIGCCsGAQUF # BzAChiZodHRwOi8vYWlhLmVudHJ1c3QubmV0L2V2Y3MyLWNoYWluLnA3YzAxBgNV # HR8EKjAoMCagJKAihiBodHRwOi8vY3JsLmVudHJ1c3QubmV0L2V2Y3MyLmNybDAO # BgNVHQ8BAf8EBAMCB4AwEwYDVR0lBAwwCgYIKwYBBQUHAwMwIAYDVR0gBBkwFzAH # BgVngQwBAzAMBgpghkgBhvpsCgECMA0GCSqGSIb3DQEBCwUAA4ICAQBoCI/Q8Bgz # wIuP21o96o9uMEbhUfRQ6JBB2/1jfNHJewHbMk9D3ftAEYj7nJSWeLpk8TOSPeeg # tpsG8BEj3KZxDKg08jxWcDMCi0SBh31I3gMQowFh8fD3QjgMpb4gW5r9TZttLn2G # txzBuoamhesLb3Bfr492InciZbSXgipiaKUa5ocj1mOuo9Y8I/SlN8yhuREULW59 # JsvWwcNDInmTyxNuQ/4HoeBzXn7I3CY+rlm4aXOmnhE3Fbe3jINEFkCIROTOQ+Ps # gFlOFaz0gGuT8gfmSxiCrMzE90Nfucuay/RxCRsh9Xqu9uxyHCQCuJ4gvvGj431f # UpCOAzRM/ogk9Udna8Gs22tmCrfMQAT+KNtuewT0EYH4qqpkrAxm9RQwUk7cMG35 # ebua7D3pe4OwKe8TldRibPxKBMWueJll+Ku/jWRIL2urhwD1wqZtguYqoLqXHWQR # bd7nt60I+VxIusDiK80OyHXK7gAy1ibC7eAlpaOTOcJ92RAX6cIzKmutaxZLNZtl # u6n/aSBs7saPOb+848VgadEmBXQzOyRspay8JwQ+7C5Tuqa8/S7Qr6yKD7Sosm31 # ZOk8v59Oy+0Q4YiO0gkua/yZpnxGeutJVYteE8t7muhHk3zoGkMmG/K6CvxK3rxz # LuvDI0xr73Ai+rIuifNtzu4NvT8hBzGkwTGCBlMwggZPAgEBMHcwYzELMAkGA1UE # BhMCVVMxFjAUBgNVBAoTDUVudHJ1c3QsIEluYy4xPDA6BgNVBAMTM0VudHJ1c3Qg # RXh0ZW5kZWQgVmFsaWRhdGlvbiBDb2RlIFNpZ25pbmcgQ0EgLSBFVkNTMgIQVKoc # 5s3YbdlA6gHWisXPLTANBglghkgBZQMEAgEFAKCBhDAYBgorBgEEAYI3AgEMMQow # CKACgAChAoAAMBkGCSqGSIb3DQEJAzEMBgorBgEEAYI3AgEEMBwGCisGAQQBgjcC # AQsxDjAMBgorBgEEAYI3AgEVMC8GCSqGSIb3DQEJBDEiBCCzx4xhSSwqy6sTW3Nc # nA4fV4cS7tT1y/gu/8Pvz+lRdjANBgkqhkiG9w0BAQEFAASCAgBSJyTzJtf2yfCo # 07HyoUQEm5JrLVHlCUF5IoMc25XmBl9w9/xLIQTj1pI1MgagJrou48JlVBP49XBX # boU/VPYbMf9nzOLyB9KLnIMwKB/TQE4OorvqgVIacqfv5fECvdR0findWUiEcwWA # eGOgeIT9b57UhVpIa/N3BaBUZm2qUwg6BpHKBo7tDHh+fr/qjGce8554BjOgmNJb # 2vQ6mL3r+XGDhTvzufp/q/Y5JBfEx+HMOynLsAZ5S2OARDAVcBCtHUAsEt9oV2Jw # QKAyYwee1VtfCUNsk1FHH5ASBUbNVKEL7o9BbtOMnQHVLidhAt/YnCoPx61jjuUY # ls98jGprcngL8bl0BykqAZRSZoJtUyQuEpbi2fcshz3Vp59qzLuybXEVPauBNqet # cbm34W9ifj2a0hdAFYQTjxHN6afawIvRrqRjw9dHu/7RBzzU+GHy8EfTOAJex9Za # 3mfyR8T8JFI5Rtb9tqVPDczioZDvRgcNtuhqoosaIaiNZX2pci4mG/R3ACbVJypP # Ms+6u1JXk7aE+ikZNIJ1PoVrTwPmGEzTh0xdNTN/Gb+FkGCIrK9JcW3dOjm061B8 # DV+rCTsKRj7IchcLi01wHgIW9klB9B+oYUrtGkI17PC10GHyhzG3rkfO4XpTgcBm # f6++XmE4XX1PjEMY6S7pF6YH7k3lfqGCAyYwggMiBgkqhkiG9w0BCQYxggMTMIID # DwIBATB9MGkxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjFB # MD8GA1UEAxM4RGlnaUNlcnQgVHJ1c3RlZCBHNCBUaW1lU3RhbXBpbmcgUlNBNDA5 # NiBTSEEyNTYgMjAyNSBDQTECEAqA7xhLjfEFgtHEdqeVdGgwDQYJYIZIAWUDBAIB # BQCgaTAYBgkqhkiG9w0BCQMxCwYJKoZIhvcNAQcBMBwGCSqGSIb3DQEJBTEPFw0y # NjA0MjcxNjQ2MDVaMC8GCSqGSIb3DQEJBDEiBCDGD9YydZ2JGJdjd9VP/ESctRdW # BPnJsc8tIzPk9Xz6nTANBgkqhkiG9w0BAQEFAASCAgCyoEbKmB9xHSExRZoZz9jt # BL8aIW7h2NPV5VSifs9V/vZva/bJQBj6U24AgoUdhck69+GNqXNZNaRZrCUqEhqH # P/+9Vb+5WK5RnQ9s1paKHION6a8f/l/2VoFD2sKr1QbzsYQwAjXYE5e8FXH1s6Op # GU49abEto1uyBkSZZfsU9zNBuHIqxZAT8VFOYj3p+iM78zTNEn5ACoszsFXuYALW # HEpz0cnejLmaR0qs20OkdlfDZSTRAdK+E7oxR5t+S9w5cjWS3+l30CHwmvAR9vY4 # 4dVMKoUtf/JWHncgP3Oqq/VkQK1cQ3xNVIOoAHt76aI1QMaF9XB03UjwbHqrlOye # OvLP0ckiO1XkP4zJxOpp338O6XxEDrKH8jy3AC62IW36/GWVr22sA5I6ti5fCSYA # UdVY5U6UBUh7YC+kfrOkIAPIC3Wr+2IussTZjEVPTUeNBOUIT0yFIQRvfCHIU4Nz # lm8M39zb1rYsARr6UDBesl0MN7Lq/twBiybQlSyEFtdp1bWWlbhl7PKakH60mFTA # CkLTc8RpzE7+zgFgVzyP/lHTxi+4sGomLtlPtJCkJcp3kdZxest2JBgK0u/izPQv # WkHrQPwcAsPbvjwP+kIs4knprmFdHH1Kn2ERbyIjWuAT+okLyilETAU4usQ1Yn2D # ssyvSei7fr/ZxKcKvrpYpg== # SIG # End signature block |