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