Nexthink-Citrix-Connector.psm1
|
# End of parameters definition $env:Path = "$env:SystemRoot\system32;$env:SystemRoot;$env:SystemRoot\System32\Wbem;$env:SystemRoot\System32\WindowsPowerShell\v1.0\" # # Constants definition # New-Variable -Name 'LOG_FORMAT' -Value "[%{timestamp:+yyyy-MM-dd HH:mm:ss.fffffzzz}][%{level:-7}][%{lineno:3}] %{message}" -Option ReadOnly -Scope Script -Force New-Variable -Name 'LOG_RETENTION_DAYS' -Value 7 -Option ReadOnly -Scope Script -Force New-Variable -Name 'LOG_LEVEL' -Value 'INFO' -Option ReadOnly -Scope Script -Force New-Variable -Name 'CITRIX_ENV_CONFIG_NOT_FOUND' -Value 'Configuration for Citrix environment not found' -Option ReadOnly -Scope Script -Force New-Variable -Name 'MISSING_FIELDS' -Value 'Missing required fields' -Option ReadOnly -Scope Script -Force # CITRIX REST API New-Variable -Name 'CITRIX_DIRECTOR_HOST' -Value 'localhost' -Option ReadOnly -Scope Script -Force New-Variable -Name 'CITRIX_CONTROLLER_HOST' -Value 'localhost' -Option ReadOnly -Scope Script -Force New-Variable -Name 'FULL_ENRICHMENT_QUERY' ` -Value ('http://{0}/citrix/monitor/odata/v4/data/Machines?$select=DnsName,HostingServerName,IsPendingUpdate' + '&$expand=DesktopGroup($select=Name,DesktopKind,SessionSupport),Hypervisor($select=Name),Catalog($select=ProvisioningSchemeId)' + '&$filter=(DnsName ne null) and (DnsName ne '''') and (DesktopGroup ne null)') ` -Option ReadOnly -Scope Script -Force New-Variable -Name 'DELTA_ENRICHMENT_QUERY' ` -Value ('http://{0}/citrix/monitor/odata/v4/data/Machines?$select=DnsName,HostingServerName,IsPendingUpdate' + '&$expand=DesktopGroup($select=Name,DesktopKind,SessionSupport),Hypervisor($select=Name),Catalog($select=ProvisioningSchemeId)' + '&$filter=(DnsName ne null) and (DnsName ne '''') and (DesktopGroup ne null) and (PoweredOnDate ge {1})') ` -Option ReadOnly -Scope Script -Force # Buffer to cover delays in reporting data from Citrix # Use a configurable(constant) buffer added to the last delta retrieval time to filter PoweredOnDate, compensating for unknown event/API latency and clock misalignments. New-Variable -Name 'DELTA_BUFFER_IN_SECONDS' -Value 10 -Option ReadOnly -Scope Script -Force # HYPERVISOR TYPE & VIRTUALIZATION TYPE New-Variable -Name 'UNKNOWN' -Value 0 -Option ReadOnly -Scope Script -Force New-Variable -Name 'SHARED' -Value 1 -Option ReadOnly -Scope Script -Force New-Variable -Name 'PERSONAL' -Value 2 -Option ReadOnly -Scope Script -Force New-Variable -Name 'POOLED' -Value 3 -Option ReadOnly -Scope Script -Force # DESKTOP BROKER New-Variable -Name 'CITRIX_CVAD' -Value 1 -Option ReadOnly -Scope Script -Force # ENRICHMENT API New-Variable -Name 'ENRICHMENT_API_HOST' -Value 'api.eu-west-3.dev.nexthink.cloud' -Option ReadOnly -Scope Script -Force New-Variable -Name 'TARGET_CREDENTIALS_NAME' -Value 'nxt-citrix-credentials' -Option ReadOnly -Scope Script -Force New-Variable -Name 'JWT_URL' -Value 'https://{0}/api/v1/token' -Option ReadOnly -Scope Script -Force New-Variable -Name 'ENRICHMENT_URL' -Value 'https://{0}/api/v1/enrichment/data/fields' -Option ReadOnly -Scope Script -Force New-Variable -Name 'LOGS_URL' -Value 'https://{0}/vdi/vdi-log-ingestion-api/api/v1/logs/entries' -Option ReadOnly -Scope Script -Force New-Variable -Name 'REQUEST_BATCH_SIZE' -Value 1000 -Option ReadOnly -Scope Script -Force # IN-MEMORY LOG STORAGE New-Variable -Name 'IN_MEMORY_LOGS' -Value ([System.Collections.ArrayList]::new()) -Option ReadOnly -Scope Script -Force # EXIT CODES New-Variable -Name 'EXIT_CODE_OK' -Value 0 -Option ReadOnly -Scope Script -Force New-Variable -Name 'EXIT_CODE_ERROR' -Value 1 -Option ReadOnly -Scope Script -Force # TRACE ID New-Variable -Name 'TRACE_ID_HEADER' -Value 'x-enrichment-trace-id' -Option ReadOnly -Scope Script -Force New-Variable -Name 'TRACE_ID_VALUE' -Value ([Guid]::NewGuid()) -Option ReadOnly -Scope Script -Force New-Variable -Name 'LOGS_TRACE_ID_HEADER' -Value 'x-nexthink-vdi-logs-trace-id' -Option ReadOnly -Scope Script -Force # TIMESTAMP New-Variable -Name 'TIMESTAMP' -Value '1970-01-01T00:00:00Z' -Option ReadOnly -Scope Script -Force New-Variable -Name 'EXECUTION_START_DATE' -Value (Get-Date) -Option ReadOnly -Scope Script -Force # # Invoke Main # function Invoke-Main { param( [Parameter(Mandatory = $true)] [string]$CitrixEnvironment, [Parameter(Mandatory = $true)] [string]$ScriptRootPath ) $Version = Get-ModuleVersion $exitCode = $EXIT_CODE_OK try { if (-not (Test-RequiredModulesInstalled)) { $exitCode = $EXIT_CODE_ERROR } else { Initialize-EnrichmentProcess -CitrixEnvironment $CitrixEnvironment -ScriptRootPath $ScriptRootPath $pwshVersion = $PSVersionTable.PSVersion.ToString() Write-CustomLog -Message "PowerShell version: $pwshVersion" -Severity 'INFO' Write-CustomLog -Message "Starting Citrix connector $Version for environment $CitrixEnvironment" -Severity 'INFO' Write-CitrixSnapInLogs Start-EnrichmentProcess -CitrixEnvironment $CitrixEnvironment } } catch { Write-CustomLog -Message "The execution stopped unexpectedly. Details: $($_.Exception.Message)" -Severity 'ERROR' $exitCode = $EXIT_CODE_ERROR } Write-CustomLog -Message "Stopping Citrix connector $Version with exit code $exitCode`n" -Severity 'INFO' # Send in-memory logs to Logs API try { Send-Logs -CitrixEnvironment $CitrixEnvironment -Version $Version } catch { # Log error but don't fail execution if log sending fails Write-CustomLog -Message "[$TRACE_ID_VALUE] Failed to send logs to API: $($_.Exception.Message)" -Severity 'ERROR' -NoCache } Wait-Logging return $exitCode } Set-Alias -Name Invoke-NexthinkCitrixConnector -Value Invoke-Main function Initialize-EnrichmentProcess { param( [Parameter(Mandatory = $true)] [string]$CitrixEnvironment, [Parameter(Mandatory = $true)] [string]$ScriptRootPath ) # Clear in-memory logs from previous run $script:IN_MEMORY_LOGS.Clear() # Initialize path-dependent variables $storeFolder = Join-Path -Path (Join-Path -Path $ScriptRootPath -ChildPath "Storage") -ChildPath $CitrixEnvironment $logsFolder = Join-Path -Path (Join-Path -Path $ScriptRootPath -ChildPath "Logs") -ChildPath $CitrixEnvironment $configFileFolder = Join-Path -Path (Join-Path -Path $ScriptRootPath -ChildPath "Config") -ChildPath "config.json" New-Variable -Name 'SCRIPT_FOLDER' -Value $ScriptRootPath -Option ReadOnly -Scope Script -Force New-Variable -Name 'LOGS_FOLDER' -Value $logsFolder -Option ReadOnly -Scope Script -Force New-Variable -Name 'STORE_TIMESTAMPS_FILENAME' -Value (Join-Path -Path $storeFolder -ChildPath "timestamps.json") -Option ReadOnly -Scope Script -Force New-Variable -Name 'LOGFILE_NAME' -Value (Join-Path -Path $logsFolder -ChildPath "CitrixConnector-%{+yyyyMMdd}.log") -Option ReadOnly -Scope Script -Force New-Variable -Name 'ZIPFILE_NAME' -Value (Join-Path -Path $logsFolder -ChildPath "RotatedLogs.zip") -Option ReadOnly -Scope Script -Force New-Variable -Name 'CONFIG_FILE_NAME' -Value $configFileFolder -Option ReadOnly -Scope Script -Force Initialize-Folder -Path $logsFolder Initialize-Folder -Path $storeFolder Initialize-Logger Get-ConfigData -CitrixEnvironment $CitrixEnvironment -ConfigFilePath $configFileFolder # Needed to update log level after reading it from the configuration Initialize-Logger New-Variable -Name 'TIMESTAMP' -Value $(Get-Date -Format o) -Option ReadOnly -Scope Script -Force } function Test-ShouldRunFullScan { param( [Parameter(Mandatory = $true)] [string]$LastFullRun ) try { $lastRunTime = Get-StringToDate -DateString $LastFullRun $hourAgo = (Get-NowDateInUTC).AddHours(-1) $isOlderThanHour = $lastRunTime -lt $hourAgo Write-CustomLog -Message "Last full run ($lastRunTime) was $(if ($isOlderThanHour) { 'more' } else { 'less' }) than an hour ago ($hourAgo)" -Severity 'INFO' return $isOlderThanHour } catch { Write-CustomLog -Message "Error checking last full run timestamp: $($_.Exception.Message)" -Severity 'ERROR' # If there's an error reading/parsing the timestamp, consider it as older than an hour return $true } } function Start-EnrichmentProcess { param( [Parameter(Mandatory = $true)] [string]$CitrixEnvironment ) $nextLink = $null $hypervisorTypes = Get-HypervisorTypes $diskImages = Get-DiskImages $timestampsFromFileStore = Read-TimestampFile $isFullScan = $true if ($null -ne $timestampsFromFileStore -and $null -ne $timestampsFromFileStore.last_full_run) { $isFullScan = Test-ShouldRunFullScan -LastFullRun $timestampsFromFileStore.last_full_run } $citrixInitialQuery = if ($isFullScan) { [String]::Format($FULL_ENRICHMENT_QUERY, $CITRIX_DIRECTOR_HOST) } else { Get-MachineDeltaQuery -LastDeltaRun $timestampsFromFileStore.last_delta_run } $devices = @() do { $citrixQuery = if ($nextLink) { $nextLink } else { $citrixInitialQuery } $citrixResponse = Read-ApiCitrixData -CitrixQuery $citrixQuery $nextLink = $citrixResponse.nextLink $devices = $devices + $citrixResponse.devices } while ($nextLink) $totalDevicesToSend = $devices.Count if (!$totalDevicesToSend) { return } Write-CustomLog -Message "$totalDevicesToSend device(s) retrieved in total from Citrix API." -Severity 'INFO' for ($devicesOffset = 0; $devicesOffset -lt $totalDevicesToSend; $devicesOffset += $REQUEST_BATCH_SIZE) { $deviceLimit = [Math]::Min($totalDevicesToSend - 1, $devicesOffset + $REQUEST_BATCH_SIZE - 1) $fullCitrixData = Expand-CitrixDataToSend -CitrixData $devices[$devicesOffset..$deviceLimit] -HypervisorTypes $hypervisorTypes -DiskImages $diskImages Send-EnrichmentRequest -FullCitrixData $fullCitrixData -FullScan $isFullScan -CitrixEnvironment $CitrixEnvironment } $lastFullRun = $timestampsFromFileStore.last_full_run; $now = Get-NowDateInUTCAsString if ($isFullScan) { $lastFullRun = $now; } # Always set lastDeltaRun to now, if it was a full run means the delta data is included # Should write all timestamps in Universal Time Write-TimestampFile -LastFullRun $lastFullRun -LastDeltaRun $now } function Get-MachineDeltaQuery { param( [Parameter(Mandatory = $true)] [string]$LastDeltaRun ) $LastDeltaRunDate = Get-StringToDate -DateString $LastDeltaRun $lastDeltaRunWithBuffer = Get-DateAsString -Date ($LastDeltaRunDate.AddSeconds(-$DELTA_BUFFER_IN_SECONDS)) Write-CustomLog -Message "Retrieving delta data only. Powered on Machines since [$lastDeltaRunWithBuffer]" -Severity 'INFO' return [String]::Format($DELTA_ENRICHMENT_QUERY, $CITRIX_DIRECTOR_HOST, $lastDeltaRunWithBuffer) } function Read-ApiCitrixData ([String]$CitrixQuery) { try { $citrixUserCredentials = Get-CitrixCredentials $citrixResponse = Invoke-WebRequest -Credential $citrixUserCredentials -Uri $CitrixQuery -UseBasicParsing $citrixResponseContent = ConvertFrom-Json $([String]::new($citrixResponse.Content)) $nextLink = $citrixResponseContent.'@odata.nextLink' $listCitrixDevices = $citrixResponseContent.value } catch [Net.WebException], [IO.IOException] { Write-CustomLog "Error sending request to Citrix API. Details: [$($_.Exception.response.StatusCode.value__)] $($_.ErrorDetails)" -Severity "ERROR" throw "Unable to access Citrix API. Details: $($_.Exception.Message)" } catch { throw "Error retrieving devices from Citrix API. Details: $($_.toString())" } Write-CustomLog -Message "$($listCitrixDevices.Count) device(s) retrieved from Citrix API." -Severity 'DEBUG' return @{ devices = $listCitrixDevices; nextLink = $nextLink } } # # Local Store # function Write-TimestampFile { param( [Parameter(Mandatory = $true)] [string]$LastFullRun, [Parameter(Mandatory = $true)] [string]$LastDeltaRun ) try { $timestamps = @{ last_full_run = $LastFullRun last_delta_run = $LastDeltaRun } # Convert to JSON and write to file $jsonContent = $timestamps | ConvertTo-Json Write-CustomLog -Message "Updating timestamps in store file - Last Full Run: [$($LastFullRun)], Last Delta Run: [$($LastDeltaRun)]" -Severity 'INFO' $jsonContent | Out-File -FilePath $STORE_TIMESTAMPS_FILENAME -Force -Encoding UTF8 Write-CustomLog -Message "Successfully wrote timestamps to $STORE_TIMESTAMPS_FILENAME" -Severity 'INFO' } catch { Write-CustomLog -Message "Failed to write timestamps to file: $($_.Exception.Message)" -Severity 'ERROR' throw "Failed to write timestamps to file: $($_.Exception.Message)" } } function Read-TimestampFile { try { if (Test-Path $STORE_TIMESTAMPS_FILENAME) { $content = Get-Content -Path $STORE_TIMESTAMPS_FILENAME -Raw | ConvertFrom-Json return @{ last_full_run = $content.last_full_run last_delta_run = $content.last_delta_run } } else { Write-CustomLog -Message "Timestamp file not found at $STORE_TIMESTAMPS_FILENAME. If this is the first run, this is expected and the file will be created." -Severity 'INFO' return $null } } catch { Write-CustomLog -Message "Failed to read timestamps from file: $($_.Exception.Message)" -Severity 'ERROR' throw "Failed to read timestamps from file: $($_.Exception.Message)" } } # # Date functions # function Get-StringToDate { param( [Parameter(Mandatory = $true)] [string]$DateString ) return ([DateTime]::ParseExact($DateString, "yyyy-MM-ddTHH:mm:ssZ", [System.Globalization.CultureInfo]::InvariantCulture)).ToUniversalTime() } function Get-NowDateInUTC { return (Get-Date).ToUniversalTime() } function Get-DateAsString { param( [Parameter(Mandatory = $true)] [DateTime]$Date ) return $Date.ToString("yyyy-MM-ddTHH:mm:ssZ") } function Get-NowDateInUTCAsString { return Get-DateAsString -Date $(Get-NowDateInUTC) } # # Credentials # function Get-CitrixCredentials { $storedCredentials = Get-StoredCredential -Target $CITRIX_CREDENTIALS_NAME if (-not $storedCredentials) { throw "Citrix credentials not found for target: $CITRIX_CREDENTIALS_NAME" } return @($storedCredentials) } function Get-ClientCredentials () { $storedCredentials = Get-StoredCredential -Target $TARGET_CREDENTIALS_NAME if ($storedCredentials -and $null -ne $storedCredentials.UserName -and $null -ne $storedCredentials.Password ) { $userName = $storedCredentials.UserName $securePassword = $storedCredentials.Password $BSTR = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($securePassword) $unsecurePassword = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($BSTR) return @{ clientId = $userName; clientSecret = $unsecurePassword } } else { throw "Credentials not found or they are empty for Target: $TARGET_CREDENTIALS_NAME" } } # # Logging # function Write-CustomLog { param( [Parameter(Mandatory = $true)] [String]$Message, [Parameter(Mandatory = $false)] [String]$Severity = 'INFO', [Parameter(Mandatory = $false)] [Switch]$NoCache ) Write-Log -Message $Message -Level $Severity # Store log in memory for later transmission to logs API (unless NoCache is specified) if (-not $NoCache) { $logTimestamp = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() $logEntry = @{ timestamp = $logTimestamp level = $Severity.ToUpper() message = $Message } [void]$script:IN_MEMORY_LOGS.Add($logEntry) } } function Initialize-Folder ([String]$Path) { try { if (-not (Test-Path -Path $Path)) { [Void](New-Item -Path $Path -ItemType 'Directory' -Force -ErrorAction Stop) } } catch { throw "Error creating folder at $Path." } } function Initialize-Logger { Add-LoggingTarget -Name File -Configuration @{ Path = $LOGFILE_NAME Encoding = 'unicode' Level = $LOG_LEVEL Format = $LOG_FORMAT RotateAfterAmount = $LOG_RETENTION_DAYS RotateAmount = 1 CompressionPath = $ZIPFILE_NAME } Set-LoggingCallerScope 2 } function Get-ModuleVersion() { param ( [Object]$Module = $MyInvocation.MyCommand.Module ) try { return $Module.Version.ToString() } catch { Write-CustomLog -Message "Could not determine module version: $_" -Severity 'WARNING' return '-' } } function Send-Logs { param( [Parameter(Mandatory = $true)] [String]$CitrixEnvironment, [Parameter(Mandatory = $true)] [String]$Version ) if ($script:IN_MEMORY_LOGS.Count -eq 0) { return } try { # Get JWT token $jwt = Get-Jwt # Build logs endpoint URL $logsEndpoint = [String]::Format($LOGS_URL, $ENRICHMENT_API_HOST) # Build logs payload $logsPayload = @{ source = "citrix-cvad-on-prem-connector" customerEnvironment = $CitrixEnvironment version = $Version logs = $script:IN_MEMORY_LOGS } $logsJsonPayload = $logsPayload | ConvertTo-Json -Depth 10 -Compress # Send logs to API $headers = New-Object "System.Collections.Generic.Dictionary[[String],[String]]" $headers.Add('Content-Type', 'application/json') $headers.Add('Authorization', "Bearer $jwt") $headers.Add($LOGS_TRACE_ID_HEADER, $TRACE_ID_VALUE) $response = Invoke-WebRequest -Uri $logsEndpoint -Method 'POST' -Headers $headers -Body $logsJsonPayload -UseBasicParsing Write-CustomLog -Message "[$TRACE_ID_VALUE] Successfully sent $($script:IN_MEMORY_LOGS.Count) log entries to Logs API. Response code: $($response.StatusCode)" -Severity 'INFO' -NoCache } catch { # Use NoCache to avoid adding to IN_MEMORY_LOGS and creating infinite recursion Write-CustomLog -Message "[$TRACE_ID_VALUE] Error sending logs to Logs API. Details: $($_.Exception.Message)" -Severity 'ERROR' -NoCache # Don't throw - we don't want log sending failures to break the main execution } } # # Config functions # function Get-ConfigData { param( [Parameter(Mandatory = $true)] [string]$CitrixEnvironment, [Parameter(Mandatory = $true)] [string]$ConfigFilePath ) $configData = Read-ConfigFile -ConfigFilePath $ConfigFilePath $citrixEnvironmentConfigData = Get-CitrixEnvironmentConfigData -ConfigData $configData -CitrixEnvName $CitrixEnvironment if ($null -eq $citrixEnvironmentConfigData) { throw "$CITRIX_ENV_CONFIG_NOT_FOUND" } if (-not (Test-ConfigData -ConfigData $configData -CitrixEnvConfigData $citrixEnvironmentConfigData)) { throw "$MISSING_FIELDS" } New-Variable -Name 'LOG_RETENTION_DAYS' -Value $configData.Logging.LogRetentionDays -Option ReadOnly -Scope Script -Force New-Variable -Name 'LOG_LEVEL' -Value $configData.Logging.LogLevel -Option ReadOnly -Scope Script -Force New-Variable -Name 'CITRIX_DIRECTOR_HOST' -Value $citrixEnvironmentConfigData.CitrixDirectorFQDN -Option ReadOnly -Scope Script -Force New-Variable -Name 'CITRIX_CONTROLLER_HOST' -Value $citrixEnvironmentConfigData.CitrixControllerFQDN -Option ReadOnly -Scope Script -Force New-Variable -Name 'CITRIX_CREDENTIALS_NAME' -Value $citrixEnvironmentConfigData.WindowsCredentialEntry -Option ReadOnly -Scope Script -Force New-Variable -Name 'ENRICHMENT_API_HOST' -Value $configData.NexthinkAPI.HostFQDN -Option ReadOnly -Scope Script -Force New-Variable -Name 'TARGET_CREDENTIALS_NAME' -Value $configData.NexthinkAPI.WindowsCredentialEntry -Option ReadOnly -Scope Script -Force New-Variable -Name 'REQUEST_BATCH_SIZE' -Value $configData.NexthinkAPI.RequestBatchSize -Option ReadOnly -Scope Script -Force } function Read-ConfigFile { param( [Parameter(Mandatory = $true)] [string]$ConfigFilePath ) try { return (Get-Content "$ConfigFilePath" -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop) } catch { throw "Error loading config file. Details: $($_.toString())" } } function Get-CitrixEnvironmentConfigData($ConfigData, $CitrixEnvName) { return $ConfigData.CitrixEnvironments | Where-Object { $_.Name -eq $CitrixEnvName } } function Test-ConfigData($ConfigData, $CitrixEnvConfigData) { return ($ConfigData.Logging.LogRetentionDays -and $ConfigData.Logging.LogLevel -and $ConfigData.NexthinkAPI.HostFQDN -and ` $ConfigData.NexthinkAPI.WindowsCredentialEntry -and $ConfigData.NexthinkAPI.RequestBatchSize -and ` $CitrixEnvConfigData.Name -and $CitrixEnvConfigData.CitrixDirectorFQDN -and $CitrixEnvConfigData.CitrixControllerFQDN -and ` $CitrixEnvConfigData.WindowsCredentialEntry) } # # Citrix functions # function Write-CitrixSnapInLogs { $registeredSnapins = Get-PSSnapin -Registered | Where-Object Name -like 'Citrix*' if ($registeredSnapins.Count -eq 0) { throw "No Citrix snap-ins are registered. Ensure that the Citrix SDK is installed." } foreach ($snapin in $registeredSnapins) { # We should be able to correlate the snap-in version to the CVAD marketing version Write-CustomLog -Message "Registered Citrix snap-in: $($snapin.Name), Version: $($snapin.Version)" -Severity 'DEBUG' } } function Set-HypervisorType([Object[]]$CitrixData, [hashtable]$HypervisorTypes) { if ($HypervisorTypes.Count) { foreach ($device in $CitrixData) { $hypervisorType = "" if ($null -eq $device.Hypervisor -or $null -eq $device.Hypervisor.Name) { $device.Hypervisor = [PSCustomObject]@{} } else { $hypervisorType = $HypervisorTypes[$device.Hypervisor.Name] } $device.Hypervisor | Add-Member -NotePropertyName Type -NotePropertyValue $hypervisorType } } return $CitrixData } function Get-HypervisorTypes() { [hashtable]$hypervisorTypes = @{} try { $hypervisorTypesList = Get-HypervisorTypesFromBroker if (-not $hypervisorTypesList) { Write-CustomLog -Message "No hypervisor types found in broker. Hypervisor name won't be enriched." -Severity 'WARNING' return $hypervisorTypes } foreach ($hypervisorType in $hypervisorTypesList) { $hypervisorTypes[$hypervisorType.Name] = $hypervisorType.HypHypervisorType if ('' -eq $hypervisorType.HypHypervisorType) { Write-CustomLog -Message "Empty hypervisor type for hypervisor '$($hypervisorType.Name)'" -Severity 'WARNING' } } } catch { Write-CustomLog -Message "Error retrieving hypervisors. Hypervisor name won't be enriched. Details: $($_.Exception.Message)" -Severity 'ERROR' } return $hypervisorTypes } function Get-HypervisorTypesFromBroker() { $scriptBlock = { Add-PSSnapin -Name Citrix.Broker* -ErrorAction SilentlyContinue; (Get-BrokerHypervisorConnection -AdminAddress $Using:CITRIX_CONTROLLER_HOST | Select-Object -Property Name, HypHypervisorType) } [PSCredential]$citrixUserCredentials = Get-CitrixCredentials $job = Start-Job -ScriptBlock $scriptBlock -Credential $citrixUserCredentials [Void](Wait-Job -Job $job) $output = Receive-Job -Job $job -ErrorAction SilentlyContinue $jobErrors = ($job.ChildJobs[0].Error | ForEach-Object { $_.ToString() }) -join "`n" $jobState = $job.State # cleanup job following best practices to avoid memory leaks Remove-Job -Job $job -Force -ErrorAction SilentlyContinue if ($jobErrors) { throw "Hypervisor types job returned errors. Details: $jobErrors" } if ($jobState -ne 'Completed') { Write-CustomLog -Message "Hypervisor types job did not complete. Job state: $jobState" -Severity 'WARNING' return $null } return $output } function Set-DiskImage([Object[]]$CitrixData, [hashtable]$DiskImages) { if ($DiskImages.Count) { foreach ($device in $CitrixData) { $diskImage = "" if ($null -eq $device.Catalog -or $null -eq $device.Catalog.ProvisioningSchemeId) { $device.Catalog = [PSCustomObject]@{} } else { $fullDiskImage = $DiskImages[$device.Catalog.ProvisioningSchemeId] $diskImage = if ($null -ne $fullDiskImage) { $fullDiskImage.Split('\')[-1] } else { $null } } $device.Catalog | Add-Member -NotePropertyName DiskImage -NotePropertyValue $diskImage } } return $CitrixData } function Get-DiskImages() { [hashtable]$diskImages = @{} try { $provSchemesList = Get-DiskImagesFromProvScheme if (-not $provSchemesList) { Write-CustomLog -Message "No provisioning schemes found in broker. Disk image won't be enriched." -Severity 'WARNING' return $diskImages } foreach ($provScheme in $provSchemesList) { $diskImages[[String]$provScheme.ProvisioningSchemeUid] = $provScheme.MasterImageVM if ('' -eq $provScheme.MasterImageVM) { Write-CustomLog -Message "Empty disk image for prov scheme '$($provScheme.ProvisioningSchemeUid)'" -Severity 'WARNING' } } } catch { Write-CustomLog -Message "Error retrieving disk images. Disk image won't be enriched. Details: $($_.Exception.Message)" -Severity 'ERROR' } return $diskImages } function Get-DiskImagesFromProvScheme() { $scriptBlock = { Add-PSSnapin -Name Citrix.* -ErrorAction SilentlyContinue; (Get-ProvScheme -AdminAddress $Using:CITRIX_CONTROLLER_HOST | Select-Object -Property ProvisioningSchemeUid, MasterImageVM) } [PSCredential]$citrixUserCredentials = Get-CitrixCredentials $job = Start-Job -ScriptBlock $scriptBlock -Credential $citrixUserCredentials [Void](Wait-Job -Job $job) $output = Receive-Job -Job $job -ErrorAction SilentlyContinue $jobErrors = ($job.ChildJobs[0].Error | ForEach-Object { $_.ToString() }) -join "`n" $jobState = $job.State Remove-Job -Job $job -Force -ErrorAction SilentlyContinue if ($jobErrors) { throw "Disk images job returned errors. Details: $jobErrors" } if ($jobState -ne 'Completed') { Write-CustomLog -Message "Disk images job did not complete. Job state: $jobState" -Severity 'WARNING' return $null } return $output } function Expand-CitrixDataToSend ([Object[]]$CitrixData, [hashtable]$HypervisorTypes, [hashtable]$DiskImages) { $citrixDataWithHypervisor = Set-HypervisorType -CitrixData $CitrixData -HypervisorTypes $HypervisorTypes return Set-DiskImage -CitrixData $citrixDataWithHypervisor -DiskImages $DiskImages } function Send-EnrichmentRequest ([Object[]]$FullCitrixData, [Boolean]$FullScan, [String]$CitrixEnvironment) { # Time in milliseconds since the script started up to when the enrichment request is sent $executionDuration = [Int](((Get-Date) - $EXECUTION_START_DATE).TotalMilliseconds) $clientTelemetry = Get-ClientTelemetry -ExecutionDuration $executionDuration -FullScan $FullScan $jwt = Get-Jwt $enrichmentEndpoint = [String]::Format($ENRICHMENT_URL, $ENRICHMENT_API_HOST) $enrichmentBody = Get-JsonFromCitrixData -CitrixData $FullCitrixData -ClientTelemetry $clientTelemetry -CitrixEnvironment $CitrixEnvironment $response = Invoke-SendDataToEnrichmentAPI -EndpointURL $enrichmentEndpoint -JsonPayload $enrichmentBody -Jwt $jwt switch ( $response.statusCode ) { '200' { Write-CustomLog -Message "[$TRACE_ID_VALUE] Batch with $($FullCitrixData.Count) devices successfully processed by Enrichment API." -Severity 'INFO' } '207' { Write-CustomLog -Message "[$TRACE_ID_VALUE] Batch with $($FullCitrixData.Count) devices partially processed by Enrichment API." -Severity 'INFO' Write-CustomLog -Message "[$TRACE_ID_VALUE] Partial success response: $($response.content)" -Severity 'INFO' } default { $message = "[$TRACE_ID_VALUE] Error sending request to Enrichment API with status code: $($response.statusCode)" Write-CustomLog -Message $message -Severity 'ERROR' throw $message } } } function Get-Jwt () { $jwtUrl = [String]::Format($JWT_URL, $ENRICHMENT_API_HOST) if (-not (Test-ValidWebUrl($jwtUrl))) { throw "Invalid URL to retrieve the token: $jwtUrl" } try { $credentials = Get-ClientCredentials $basicHeader = Get-StringAsBase64 -InputString "$($credentials.clientId):$($credentials.clientSecret)" $headers = New-Object "System.Collections.Generic.Dictionary[[String],[String]]" $headers.Add('Authorization', "Basic $basicHeader") $response = Invoke-WebRequest -Uri $jwtUrl -Method 'POST' -Headers $headers -UseBasicParsing $parsedResponse = ConvertFrom-Json $([String]::new($response.Content)) return $parsedResponse.access_token } catch [Net.WebException], [IO.IOException] { Write-CustomLog "Error sending request to get the JWT token. Details: [$($_.Exception.response.StatusCode.value__)] $($_.ErrorDetails)" -Severity "ERROR" throw "Unable to access token endpoint. Details: $($_.Exception.Message)" } catch { throw "An error occurred that could not be resolved. Details: $($_.Exception.Message)" } } function Test-ValidWebUrl($UrlToValidate) { $uri = $UrlToValidate -as [System.Uri] $null -ne $uri.AbsoluteURI -and $uri.Scheme -match '[http|https]' } function Get-StringAsBase64 ([String]$InputString) { $Bytes = [System.Text.Encoding]::UTF8.GetBytes($InputString) $EncodedText = [Convert]::ToBase64String($Bytes) return $EncodedText } function Get-JsonFromCitrixData ([Object[]]$CitrixData, [PSCustomObject]$ClientTelemetry, [String]$CitrixEnvironment) { $jsonResult = '{"enrichments": [' foreach ($device in $CitrixData) { try { if (Test-MachineIsValid($device)) { $deviceName = Get-MachineName($device.DnsName) $virtualizationType = Get-VirtualizationType -DesktopKind $device.DesktopGroup.DesktopKind -SessionSupport $device.DesktopGroup.SessionSupport $currentRow = '{"identification":[{"name":"device/device/name","value":"' + $deviceName + '"}],' $currentRow = $currentRow + '"fields":[{"name":"device/device/virtualization/desktop_pool","value":"' + $device.DesktopGroup.Name + '"}' $currentRow = $currentRow + ',{"name":"device/device/virtualization/type","value": ' + $virtualizationType + '}' $currentRow = $currentRow + ',{"name":"device/device/virtualization/hostname","value":"' + $device.HostingServerName + '"}' if ($null -ne $device.Hypervisor.Type) { $currentRow = $currentRow + ',{"name":"device/device/virtualization/hypervisor_name","value":"' + $device.Hypervisor.Type + '"}' } $currentRow = $currentRow + ',{"name":"device/device/virtualization/environment_name","value":"' + $CitrixEnvironment +'"}' $currentRow = $currentRow + ',{"name":"device/device/virtualization/desktop_broker","value": ' + $CITRIX_CVAD + '}' if ($false -eq $device.IsPendingUpdate -and $null -ne $device.Catalog.DiskImage) { $currentRow = $currentRow + ',{"name":"device/device/virtualization/disk_image","value":"' + $device.Catalog.DiskImage + '"}' } $currentRow = $currentRow + ',{"name":"device/device/virtualization/last_update","value":"' + $TIMESTAMP + '"}' $currentRow = $currentRow + ']},' $jsonResult = $jsonResult + $currentRow } else { Write-CustomLog -Message "Invalid device: '$($device | ConvertTo-Json -Compress)'" -Severity 'DEBUG' } } catch { Write-CustomLog -Message "Error processing device '$deviceName'. Details: $($_.Exception.Message)" -Severity 'ERROR' } } if ($jsonResult.EndsWith(',')) { $jsonResult = $jsonResult.Substring(0, $jsonResult.Length - 1) } $clientTelemetryJson = $clientTelemetry | ConvertTo-Json -Compress return $jsonResult + '], "domain":"citrix", "clientTelemetry":' + $clientTelemetryJson + '}' } function Get-ClientTelemetry([Int]$ExecutionDuration, [Boolean]$FullScan) { $Version = Get-ModuleVersion return [PSCustomObject]@{ version = $Version executionDurationInMs = $ExecutionDuration fullScan = $FullScan } } function Test-MachineIsValid([PSCustomObject]$Device) { if ($null -ne $Device.DnsName -and $null -ne $Device.DesktopGroup -and $null -ne $Device.DesktopGroup.Name -and '' -ne $Device.DesktopGroup.Name -and $null -ne $Device.DesktopGroup.DesktopKind -and $null -ne $Device.DesktopGroup.SessionSupport ) { $deviceName = Get-MachineName($Device.DnsName) if ('' -ne $deviceName) { return $true } } return $false } function Get-MachineName([String]$DnsName) { return $DnsName.Split('.')[0].ToUpper() } function Get-VirtualizationType([Int]$DesktopKind, [Int]$SessionSupport) { $separator = '@' $switchKey = '' + $DesktopKind + $separator + $SessionSupport $result = switch ( $switchKey ) { '1@2' { $SHARED } '0@1' { $PERSONAL } '1@1' { $POOLED } default { $UNKNOWN } } return $result } function Invoke-SendDataToEnrichmentAPI ([String]$EndpointUrl, [String]$JsonPayload, [String]$Jwt) { try { $headers = New-Object "System.Collections.Generic.Dictionary[[String],[String]]" $headers.Add('Content-Type', 'application/json') $headers.Add('Authorization', "Bearer $Jwt") $headers.Add($TRACE_ID_HEADER, $TRACE_ID_VALUE) $response = Invoke-WebRequest -Uri $EndpointUrl -Method 'POST' -Headers $headers -Body $JsonPayload -UseBasicParsing $statusCode = $response.StatusCode $content = $response.Content } catch { Write-CustomLog -Message "[$TRACE_ID_VALUE] Error sending request to Enrichment API. Details: $($_.Exception.Message)" -Severity 'ERROR' Write-CustomLog -Message "[$TRACE_ID_VALUE] Error message: $_" -Severity 'ERROR' $statusCode = $_.Exception.response.StatusCode.value__ } return @{ statusCode = $statusCode; content = $content } } # # Prerequisite checks # function Test-RequiredModulesInstalled() { $requiredModules = @('CredentialManager', 'Logging') $missingModules = @() foreach ($module in $requiredModules) { if (-not (Get-Module -ListAvailable -Name $module)) { $missingModules += $module } } if ($missingModules.Count -gt 0) { $formattedMissingModules = $missingModules -join ', ' Write-Error "Missing required modules: $formattedMissingModules" return $false } return $true } # # Module exports - Only export when running as a module # try { Export-ModuleMember -Function * -Alias Invoke-NexthinkCitrixConnector } catch [System.InvalidOperationException] { # Expected when dot-sourced as script during testing } catch { # Silently ignore any other export errors } # SIG # Begin signature block # MIIohQYJKoZIhvcNAQcCoIIodjCCKHICAQExDzANBglghkgBZQMEAgEFADB5Bgor # BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG # KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCC9nwsuxxoyaY6v # OhE6k7onZcR2iNB1z84n4n9AOgmTBqCCIZ0wggWNMIIEdaADAgECAhAOmxiO+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 # twGpn1eqXijiuZQwggawMIIEmKADAgECAhAIrUCyYNKcTJ9ezam9k67ZMA0GCSqG # SIb3DQEBDAUAMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMx # GTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0IFRy # dXN0ZWQgUm9vdCBHNDAeFw0yMTA0MjkwMDAwMDBaFw0zNjA0MjgyMzU5NTlaMGkx # CzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjFBMD8GA1UEAxM4 # RGlnaUNlcnQgVHJ1c3RlZCBHNCBDb2RlIFNpZ25pbmcgUlNBNDA5NiBTSEEzODQg # MjAyMSBDQTEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDVtC9C0Cit # eLdd1TlZG7GIQvUzjOs9gZdwxbvEhSYwn6SOaNhc9es0JAfhS0/TeEP0F9ce2vnS # 1WcaUk8OoVf8iJnBkcyBAz5NcCRks43iCH00fUyAVxJrQ5qZ8sU7H/Lvy0daE6ZM # swEgJfMQ04uy+wjwiuCdCcBlp/qYgEk1hz1RGeiQIXhFLqGfLOEYwhrMxe6TSXBC # Mo/7xuoc82VokaJNTIIRSFJo3hC9FFdd6BgTZcV/sk+FLEikVoQ11vkunKoAFdE3 # /hoGlMJ8yOobMubKwvSnowMOdKWvObarYBLj6Na59zHh3K3kGKDYwSNHR7OhD26j # q22YBoMbt2pnLdK9RBqSEIGPsDsJ18ebMlrC/2pgVItJwZPt4bRc4G/rJvmM1bL5 # OBDm6s6R9b7T+2+TYTRcvJNFKIM2KmYoX7BzzosmJQayg9Rc9hUZTO1i4F4z8ujo # 7AqnsAMrkbI2eb73rQgedaZlzLvjSFDzd5Ea/ttQokbIYViY9XwCFjyDKK05huzU # tw1T0PhH5nUwjewwk3YUpltLXXRhTT8SkXbev1jLchApQfDVxW0mdmgRQRNYmtwm # KwH0iU1Z23jPgUo+QEdfyYFQc4UQIyFZYIpkVMHMIRroOBl8ZhzNeDhFMJlP/2NP # TLuqDQhTQXxYPUez+rbsjDIJAsxsPAxWEQIDAQABo4IBWTCCAVUwEgYDVR0TAQH/ # BAgwBgEB/wIBADAdBgNVHQ4EFgQUaDfg67Y7+F8Rhvv+YXsIiGX0TkIwHwYDVR0j # BBgwFoAU7NfjgtJxXWRM3y5nP+e6mK4cD08wDgYDVR0PAQH/BAQDAgGGMBMGA1Ud # JQQMMAoGCCsGAQUFBwMDMHcGCCsGAQUFBwEBBGswaTAkBggrBgEFBQcwAYYYaHR0 # cDovL29jc3AuZGlnaWNlcnQuY29tMEEGCCsGAQUFBzAChjVodHRwOi8vY2FjZXJ0 # cy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkUm9vdEc0LmNydDBDBgNVHR8E # PDA6MDigNqA0hjJodHRwOi8vY3JsMy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVz # dGVkUm9vdEc0LmNybDAcBgNVHSAEFTATMAcGBWeBDAEDMAgGBmeBDAEEATANBgkq # hkiG9w0BAQwFAAOCAgEAOiNEPY0Idu6PvDqZ01bgAhql+Eg08yy25nRm95RysQDK # r2wwJxMSnpBEn0v9nqN8JtU3vDpdSG2V1T9J9Ce7FoFFUP2cvbaF4HZ+N3HLIvda # qpDP9ZNq4+sg0dVQeYiaiorBtr2hSBh+3NiAGhEZGM1hmYFW9snjdufE5BtfQ/g+ # lP92OT2e1JnPSt0o618moZVYSNUa/tcnP/2Q0XaG3RywYFzzDaju4ImhvTnhOE7a # brs2nfvlIVNaw8rpavGiPttDuDPITzgUkpn13c5UbdldAhQfQDN8A+KVssIhdXNS # y0bYxDQcoqVLjc1vdjcshT8azibpGL6QB7BDf5WIIIJw8MzK7/0pNVwfiThV9zeK # iwmhywvpMRr/LhlcOXHhvpynCgbWJme3kuZOX956rEnPLqR0kq3bPKSchh/jwVYb # KyP/j7XqiHtwa+aguv06P0WmxOgWkVKLQcBIhEuWTatEQOON8BUozu3xGFYHKi8Q # xAwIZDwzj64ojDzLj4gLDb879M4ee47vtevLt/B3E+bnKD+sEq6lLyJsQfmCXBVm # zGwOysWGw/YmMwwHS6DTBwJqakAwSEs0qFEgu60bhQjiWQ1tygVQK+pKHJ6l/aCn # HwZ05/LWUpD9r4VIIflXO7ScA+2GRfS0YW6/aOImYIbqyK+p/pQd52MbOoZWeE4w # gga0MIIEnKADAgECAhANx6xXBf8hmS5AQyIMOkmGMA0GCSqGSIb3DQEBCwUAMGIx # CzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3 # dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0IFRydXN0ZWQgUm9vdCBH # NDAeFw0yNTA1MDcwMDAwMDBaFw0zODAxMTQyMzU5NTlaMGkxCzAJBgNVBAYTAlVT # MRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjFBMD8GA1UEAxM4RGlnaUNlcnQgVHJ1 # c3RlZCBHNCBUaW1lU3RhbXBpbmcgUlNBNDA5NiBTSEEyNTYgMjAyNSBDQTEwggIi # MA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC0eDHTCphBcr48RsAcrHXbo0Zo # dLRRF51NrY0NlLWZloMsVO1DahGPNRcybEKq+RuwOnPhof6pvF4uGjwjqNjfEvUi # 6wuim5bap+0lgloM2zX4kftn5B1IpYzTqpyFQ/4Bt0mAxAHeHYNnQxqXmRinvuNg # xVBdJkf77S2uPoCj7GH8BLuxBG5AvftBdsOECS1UkxBvMgEdgkFiDNYiOTx4OtiF # cMSkqTtF2hfQz3zQSku2Ws3IfDReb6e3mmdglTcaarps0wjUjsZvkgFkriK9tUKJ # m/s80FiocSk1VYLZlDwFt+cVFBURJg6zMUjZa/zbCclF83bRVFLeGkuAhHiGPMvS # GmhgaTzVyhYn4p0+8y9oHRaQT/aofEnS5xLrfxnGpTXiUOeSLsJygoLPp66bkDX1 # ZlAeSpQl92QOMeRxykvq6gbylsXQskBBBnGy3tW/AMOMCZIVNSaz7BX8VtYGqLt9 # MmeOreGPRdtBx3yGOP+rx3rKWDEJlIqLXvJWnY0v5ydPpOjL6s36czwzsucuoKs7 # Yk/ehb//Wx+5kMqIMRvUBDx6z1ev+7psNOdgJMoiwOrUG2ZdSoQbU2rMkpLiQ6bG # RinZbI4OLu9BMIFm1UUl9VnePs6BaaeEWvjJSjNm2qA+sdFUeEY0qVjPKOWug/G6 # X5uAiynM7Bu2ayBjUwIDAQABo4IBXTCCAVkwEgYDVR0TAQH/BAgwBgEB/wIBADAd # BgNVHQ4EFgQU729TSunkBnx6yuKQVvYv1Ensy04wHwYDVR0jBBgwFoAU7NfjgtJx # XWRM3y5nP+e6mK4cD08wDgYDVR0PAQH/BAQDAgGGMBMGA1UdJQQMMAoGCCsGAQUF # BwMIMHcGCCsGAQUFBwEBBGswaTAkBggrBgEFBQcwAYYYaHR0cDovL29jc3AuZGln # aWNlcnQuY29tMEEGCCsGAQUFBzAChjVodHRwOi8vY2FjZXJ0cy5kaWdpY2VydC5j # b20vRGlnaUNlcnRUcnVzdGVkUm9vdEc0LmNydDBDBgNVHR8EPDA6MDigNqA0hjJo # dHRwOi8vY3JsMy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkUm9vdEc0LmNy # bDAgBgNVHSAEGTAXMAgGBmeBDAEEAjALBglghkgBhv1sBwEwDQYJKoZIhvcNAQEL # BQADggIBABfO+xaAHP4HPRF2cTC9vgvItTSmf83Qh8WIGjB/T8ObXAZz8OjuhUxj # aaFdleMM0lBryPTQM2qEJPe36zwbSI/mS83afsl3YTj+IQhQE7jU/kXjjytJgnn0 # hvrV6hqWGd3rLAUt6vJy9lMDPjTLxLgXf9r5nWMQwr8Myb9rEVKChHyfpzee5kH0 # F8HABBgr0UdqirZ7bowe9Vj2AIMD8liyrukZ2iA/wdG2th9y1IsA0QF8dTXqvcnT # mpfeQh35k5zOCPmSNq1UH410ANVko43+Cdmu4y81hjajV/gxdEkMx1NKU4uHQcKf # ZxAvBAKqMVuqte69M9J6A47OvgRaPs+2ykgcGV00TYr2Lr3ty9qIijanrUR3anzE # wlvzZiiyfTPjLbnFRsjsYg39OlV8cipDoq7+qNNjqFzeGxcytL5TTLL4ZaoBdqbh # OhZ3ZRDUphPvSRmMThi0vw9vODRzW6AxnJll38F0cuJG7uEBYTptMSbhdhGQDpOX # gpIUsWTjd6xpR6oaQf/DJbg3s6KCLPAlZ66RzIg9sC+NJpud/v4+7RWsWCiKi9EO # LLHfMR2ZyJ/+xhCx9yHbxtl5TPau1j/1MIDpMPx0LckTetiSuEtQvLsNz3Qbp7wG # WqbIiOWCnb5WqxL3/BAPvIXKUjPSxyZsq8WhbaM2tszWkPZPubdcMIIG7TCCBNWg # AwIBAgIQCoDvGEuN8QWC0cR2p5V0aDANBgkqhkiG9w0BAQsFADBpMQswCQYDVQQG # EwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4xQTA/BgNVBAMTOERpZ2lDZXJ0 # IFRydXN0ZWQgRzQgVGltZVN0YW1waW5nIFJTQTQwOTYgU0hBMjU2IDIwMjUgQ0Ex # MB4XDTI1MDYwNDAwMDAwMFoXDTM2MDkwMzIzNTk1OVowYzELMAkGA1UEBhMCVVMx # FzAVBgNVBAoTDkRpZ2lDZXJ0LCBJbmMuMTswOQYDVQQDEzJEaWdpQ2VydCBTSEEy # NTYgUlNBNDA5NiBUaW1lc3RhbXAgUmVzcG9uZGVyIDIwMjUgMTCCAiIwDQYJKoZI # hvcNAQEBBQADggIPADCCAgoCggIBANBGrC0Sxp7Q6q5gVrMrV7pvUf+GcAoB38o3 # zBlCMGMyqJnfFNZx+wvA69HFTBdwbHwBSOeLpvPnZ8ZN+vo8dE2/pPvOx/Vj8Tch # TySA2R4QKpVD7dvNZh6wW2R6kSu9RJt/4QhguSssp3qome7MrxVyfQO9sMx6ZAWj # FDYOzDi8SOhPUWlLnh00Cll8pjrUcCV3K3E0zz09ldQ//nBZZREr4h/GI6Dxb2Uo # yrN0ijtUDVHRXdmncOOMA3CoB/iUSROUINDT98oksouTMYFOnHoRh6+86Ltc5zjP # KHW5KqCvpSduSwhwUmotuQhcg9tw2YD3w6ySSSu+3qU8DD+nigNJFmt6LAHvH3KS # uNLoZLc1Hf2JNMVL4Q1OpbybpMe46YceNA0LfNsnqcnpJeItK/DhKbPxTTuGoX7w # JNdoRORVbPR1VVnDuSeHVZlc4seAO+6d2sC26/PQPdP51ho1zBp+xUIZkpSFA8vW # doUoHLWnqWU3dCCyFG1roSrgHjSHlq8xymLnjCbSLZ49kPmk8iyyizNDIXj//cOg # rY7rlRyTlaCCfw7aSUROwnu7zER6EaJ+AliL7ojTdS5PWPsWeupWs7NpChUk555K # 096V1hE0yZIXe+giAwW00aHzrDchIc2bQhpp0IoKRR7YufAkprxMiXAJQ1XCmnCf # gPf8+3mnAgMBAAGjggGVMIIBkTAMBgNVHRMBAf8EAjAAMB0GA1UdDgQWBBTkO/zy # Me39/dfzkXFjGVBDz2GM6DAfBgNVHSMEGDAWgBTvb1NK6eQGfHrK4pBW9i/USezL # TjAOBgNVHQ8BAf8EBAMCB4AwFgYDVR0lAQH/BAwwCgYIKwYBBQUHAwgwgZUGCCsG # AQUFBwEBBIGIMIGFMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5kaWdpY2VydC5j # b20wXQYIKwYBBQUHMAKGUWh0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0LmNvbS9EaWdp # Q2VydFRydXN0ZWRHNFRpbWVTdGFtcGluZ1JTQTQwOTZTSEEyNTYyMDI1Q0ExLmNy # dDBfBgNVHR8EWDBWMFSgUqBQhk5odHRwOi8vY3JsMy5kaWdpY2VydC5jb20vRGln # aUNlcnRUcnVzdGVkRzRUaW1lU3RhbXBpbmdSU0E0MDk2U0hBMjU2MjAyNUNBMS5j # cmwwIAYDVR0gBBkwFzAIBgZngQwBBAIwCwYJYIZIAYb9bAcBMA0GCSqGSIb3DQEB # CwUAA4ICAQBlKq3xHCcEua5gQezRCESeY0ByIfjk9iJP2zWLpQq1b4URGnwWBdEZ # D9gBq9fNaNmFj6Eh8/YmRDfxT7C0k8FUFqNh+tshgb4O6Lgjg8K8elC4+oWCqnU/ # ML9lFfim8/9yJmZSe2F8AQ/UdKFOtj7YMTmqPO9mzskgiC3QYIUP2S3HQvHG1FDu # +WUqW4daIqToXFE/JQ/EABgfZXLWU0ziTN6R3ygQBHMUBaB5bdrPbF6MRYs03h4o # bEMnxYOX8VBRKe1uNnzQVTeLni2nHkX/QqvXnNb+YkDFkxUGtMTaiLR9wjxUxu2h # ECZpqyU1d0IbX6Wq8/gVutDojBIFeRlqAcuEVT0cKsb+zJNEsuEB7O7/cuvTQasn # M9AWcIQfVjnzrvwiCZ85EE8LUkqRhoS3Y50OHgaY7T/lwd6UArb+BOVAkg2oOvol # /DJgddJ35XTxfUlQ+8Hggt8l2Yv7roancJIFcbojBcxlRcGG0LIhp6GvReQGgMgY # xQbV1S3CrWqZzBt1R9xJgKf47CdxVRd/ndUlQ05oxYy2zRWVFjF7mcr4C34Mj3oc # CVccAvlKV9jEnstrniLvUxxVZE/rptb7IRE2lskKPIJgbaP5t2nGj/ULLi49xTcB # ZU8atufk+EMF/cWuiC7POGT75qaL6vdCvHlshtjdNXOCIUjsarfNZzCCB6swggWT # oAMCAQICEA20PGu9jXp4I/Dy4yL8Si8wDQYJKoZIhvcNAQELBQAwaTELMAkGA1UE # BhMCVVMxFzAVBgNVBAoTDkRpZ2lDZXJ0LCBJbmMuMUEwPwYDVQQDEzhEaWdpQ2Vy # dCBUcnVzdGVkIEc0IENvZGUgU2lnbmluZyBSU0E0MDk2IFNIQTM4NCAyMDIxIENB # MTAeFw0yNTExMTMwMDAwMDBaFw0yODExMTQyMzU5NTlaMIGzMRMwEQYLKwYBBAGC # NzwCAQMTAkNIMRUwEwYLKwYBBAGCNzwCAQITBFZhdWQxHTAbBgNVBA8MFFByaXZh # dGUgT3JnYW5pemF0aW9uMRgwFgYDVQQFEw9DSEUtMTEyLjAwMC41NzkxCzAJBgNV # BAYTAkNIMQ8wDQYDVQQHEwZQcmlsbHkxFjAUBgNVBAoTDU5FWFRoaW5rIFMuQS4x # FjAUBgNVBAMTDU5FWFRoaW5rIFMuQS4wggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAw # ggIKAoICAQDIbNJpFq6aDbvQP7zFPymmt3WRPkvbmnRL4g0imzFNbaj+U/RIGPL0 # 3l9TtYxzraPbwDdidmT5P50mK1JqjF5FzcZEC4kZfMMojY4J/gOylo8jOdLjBscL # SYwsY+85ZRs2bvxibPXIu4AcZttxBx5XdbVyt2Bd1vtuJGj+wfU1vJu7NB3GHjPy # 4QE1BFrgidqQTL+1HASQaWwNMetW8B1U1POZjr6uWp6Ga3DF/MAKxpbvU3iVFI8b # YFpKiVIQ9Namj3DQ7Xj26+fSEP959+bukG8LyObHN5DDkRdpnCSVVMT6qxZ2QGri # DFkyjOVaGkjJTI6mbKY1JPUJhYVo+7Jqhu+2EFGRy6wSj9wZBubnbwfuvgr/XlH9 # /xQQuX9i/EJvxJRQQyz9IzLtB/DBSoEQvtyCh8Po87k9x9CGYn7k1HxUg1F6oBKa # kemv3G474RWKZwSEIbwUlygFydEpTDkRTqBDZKOQf3VaUvfUgGmBmEfq1m+cljHj # fLDBtkDIGaZ7SvyO72q9Uj0HrIWvJDHHIYK84gqhe58vKgaQ0uH9V5BCUT5cLvau # +1w93iq5AMCTtG96SEyjI/T9Jzh+2sM+PoUwlLNLqYFUvMI+l19X+5UtQnjU5k1c # ooxqCebatsS642gd3X1VoeNyfJ5FB67b6KGNfrDOXBt5FXgKZAUOHwIDAQABo4IC # AjCCAf4wHwYDVR0jBBgwFoAUaDfg67Y7+F8Rhvv+YXsIiGX0TkIwHQYDVR0OBBYE # FICGOh06CZAfMG3M4l2zy9590p0OMD0GA1UdIAQ2MDQwMgYFZ4EMAQMwKTAnBggr # BgEFBQcCARYbaHR0cDovL3d3dy5kaWdpY2VydC5jb20vQ1BTMA4GA1UdDwEB/wQE # AwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAzCBtQYDVR0fBIGtMIGqMFOgUaBPhk1o # dHRwOi8vY3JsMy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkRzRDb2RlU2ln # bmluZ1JTQTQwOTZTSEEzODQyMDIxQ0ExLmNybDBToFGgT4ZNaHR0cDovL2NybDQu # ZGlnaWNlcnQuY29tL0RpZ2lDZXJ0VHJ1c3RlZEc0Q29kZVNpZ25pbmdSU0E0MDk2 # U0hBMzg0MjAyMUNBMS5jcmwwgZQGCCsGAQUFBwEBBIGHMIGEMCQGCCsGAQUFBzAB # hhhodHRwOi8vb2NzcC5kaWdpY2VydC5jb20wXAYIKwYBBQUHMAKGUGh0dHA6Ly9j # YWNlcnRzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydFRydXN0ZWRHNENvZGVTaWduaW5n # UlNBNDA5NlNIQTM4NDIwMjFDQTEuY3J0MAkGA1UdEwQCMAAwDQYJKoZIhvcNAQEL # BQADggIBALSLCU45H6xjr2Ce9mmr6WtqdnykOcc2s1iqd/UN88f4GlmzVmtA9Dts # 9j8X77hxHYIAJwWM3NqKi8m55CB5HQBuOC3hOI8bWFdQLBsrAbBAApFh3cjRruGn # r0mu7wlZo9HLgpM1PEy+hf90+cANYRBITrRkfVT4o2rTc8mmJyYzZM8WVwxGx+wm # PAuvyjYKsjZNTeJ+miYqwJ/zHjc8LQP+/hYPNmP9ubYpMMkgC49vP9Uir0E4YI2C # VTbUuhxao4Pmy2MnqhK20lDu50ZCNDNFurYhzXWBhK1cel6Ku4yuT6TlsjNye0AY # v92FDyAaYH9VZuub7qZBNv/RwmcLUDqqh5rnEdeGMH7KFkUyN63RMEr7hLXV/Tav # 7K0kWlXKPLIFenfqJnPYceni6PdJS/7WlxnBE0UwgoSoe4HawnEF0YvYBU3uyK+v # TJWVau5Jptjp94uEQmW6PidWwUWNzVl4LQ7eEZe4ykZ0JLErbGnOeoUoZCeVzpx4 # uUEFiZv1KLeLEkB/8yFIrQp03sVMItqhNJ5mbDOoWW4y3Z8/jduhxVPwJMHhgglK # 3z3sZr5xrpY3n1Qcqn1xFsI6U7rOrkzyQBw+d/yF82MW3LldZ7tPyU8LTEO89oOC # EsGFwOfEb/wB7UJ1NulZ1R2nf5erYEnVsOsrGV0X1fYh2OoRQyxWMYIGPjCCBjoC # AQEwfTBpMQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4xQTA/ # BgNVBAMTOERpZ2lDZXJ0IFRydXN0ZWQgRzQgQ29kZSBTaWduaW5nIFJTQTQwOTYg # U0hBMzg0IDIwMjEgQ0ExAhANtDxrvY16eCPw8uMi/EovMA0GCWCGSAFlAwQCAQUA # oGowGQYJKoZIhvcNAQkDMQwGCisGAQQBgjcCAQQwHAYKKwYBBAGCNwIBCzEOMAwG # CisGAQQBgjcCARUwLwYJKoZIhvcNAQkEMSIEIAB5YKkR4FiqCzDTQ8UTbEQ2C7Jn # WBmJzBC6MY3ANOJKMA0GCSqGSIb3DQEBAQUABIICAJK8NtYMcRJdd2KJza6eEQI5 # zNqWbRaxaijyIy12EPRuqbGtmE4P69oaTdEUFxH2pQ3PCVwbiSMA831GaGVMfpci # 7BcJ8uY39Z8CWcB5ELMj8+8n+exhtfIqGFDaSRgMYakKE9/L3axihQtXBtm0tWdV # 1sJ7uDAtpJJdgU8HrIeUoxZhFsa9JKlrjAPDR/UZKegd3W4Dkfyp0SGWsNdPSIxC # SmuhpzuEL0nYFJRRRdkUYPaSbbUaxYvWGsmpTwDF2PoawsSNtcP5WqmwkQurCZW0 # 08kgkW6Nu9K9WYLAQ4ZFeqAIS/XI9DXLriW4Zp4Nl/mztHsprSOogvn1yh2m7ZjA # dsSG/ksQTEiQ927Dj6OFkfJne5N7FpSjvQbpwQqcRB2DjHkCWp+Oy7KJuZEH2s9T # jn4ZCFCWa//CU+bipWO05qMyt72EMA9DHF7qsoHNS6GAtP5NouGkLylxa8LixNuo # lkYzHzT2YDqAUqYXBd/JoRGFdU9MHc3JMl5Js8oa/LP4rStFWkwyq/UzbkDt0dcI # Zj0eHMyojRev0KSyceDT5gOrxABnup0JfMwQdJIWkInRkc/fTdH9WHMsmC3h6LRH # iAz91QQ30P8Q7FG+qDVnBYQLLYFZvpwjgi7quBVq6lWj9faKKO1lRfdH6L1aReOY # +NlDYf6tnRdoos21Ehr8oYIDJjCCAyIGCSqGSIb3DQEJBjGCAxMwggMPAgEBMH0w # aTELMAkGA1UEBhMCVVMxFzAVBgNVBAoTDkRpZ2lDZXJ0LCBJbmMuMUEwPwYDVQQD # EzhEaWdpQ2VydCBUcnVzdGVkIEc0IFRpbWVTdGFtcGluZyBSU0E0MDk2IFNIQTI1 # NiAyMDI1IENBMQIQCoDvGEuN8QWC0cR2p5V0aDANBglghkgBZQMEAgEFAKBpMBgG # CSqGSIb3DQEJAzELBgkqhkiG9w0BBwEwHAYJKoZIhvcNAQkFMQ8XDTI2MDMxOTEy # MzkzNlowLwYJKoZIhvcNAQkEMSIEIAw8nr//uO69GOzzN+HZchKsSD2bxK0thzeY # ApVGW9YHMA0GCSqGSIb3DQEBAQUABIICAAjQg880afGl6JLok87if9G+stfqVESJ # PsrG6me5fdeya6Y1aq+W7VVhvTCMA0hKjESngxMKMMSrliiGp7jbytaomQ+6ZmET # ybyvUAqoHod/VMeMayHre7QFjf2A3prZP30jBPmsKEG6S3aKZf7DeqHlIAovmfpy # y0uaar+Lc+MkOD9T/eTJWZBaiT32f1tgnU81t6pT3ep0a09adkc2Gw+BobHe3W7h # TGU9E2NGpzyB1rP5t4vSXUcDbSDNg3CH+hrVJ7zZAREl87auDLnbOXHtyp8MoCAq # STk6Xknq2620kb9OJIOLXP9N1cCTdTbNP8QPIGmvuIl4t7IbUC2NAzb5c236b+Ph # hrs6+6ic6MVw56qz8Els6/xIaq5Vxm5wJb2DH9dZ9nc6p3zRWCrlgp0ug+hrEfix # 8mhK3SO3FH/2Izf+dfWa+rmlUp3mju5cJYtsVVYIwEbgN4ncRuRyb3ClpQr/bWMu # hrckjF7tG7rWZaNqWY13LUSRoBWzJjpRW+FWAnTcKJ9SfSaOhwQCyt2jWT/fV83L # Z81YLbF9uJ0AVwQTu9+zEwGV7YQ/gLj0qyjEXrz2uk7djHds2166aQrCvDETOSAm # qmKv79G7YRCByoLW+a8kz1Llerio45XWM40/U6jPkv6eI+xY+K5sB4jCPhtN2NUy # K9dfRj39BsM1 # SIG # End signature block |