helpers/azure/AzureHelpers.ps1
|
# ============================================================================= # Azure Helpers for WhatsUpGoldPS # Requires the Az PowerShell modules. Install them first if not already present: # Install-Module -Name Az -Scope CurrentUser -Repository PSGallery -Force # This installs Az.Accounts, Az.Resources, Az.Monitor, Az.Compute, Az.Network, etc. # ============================================================================= function Connect-AzureServicePrincipal { <# .SYNOPSIS Authenticates to Azure using a service principal (App Registration). .DESCRIPTION Takes a TenantId, ApplicationId, and client secret, creates a PSCredential, and calls Connect-AzAccount with the service principal identity. Returns the context object on success. .PARAMETER TenantId The Azure AD tenant (directory) ID. .PARAMETER ApplicationId The Application (client) ID of the service principal. .PARAMETER ClientSecret The client secret string for the service principal. .EXAMPLE Connect-AzureServicePrincipal -TenantId "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" -ApplicationId "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy" -ClientSecret "MySecretValue" Authenticates to Azure using a service principal with a client secret. .EXAMPLE $ctx = Connect-AzureServicePrincipal -TenantId $tenantId -ApplicationId $appId -ClientSecret $secret Authenticates and stores the Azure context in a variable for later use. #> param( [Parameter(Mandatory)][string]$TenantId, [Parameter(Mandatory)][string]$ApplicationId, [Parameter(Mandatory)][string]$ClientSecret ) $secureSecret = ConvertTo-SecureString $ClientSecret -AsPlainText -Force $credential = [PSCredential]::new($ApplicationId, $secureSecret) try { $context = Connect-AzAccount -ServicePrincipal -TenantId $TenantId -Credential $credential -ErrorAction Stop Write-Verbose "Authenticated to Azure tenant $TenantId as $ApplicationId" return $context } catch { throw "Failed to authenticate to Azure: $($_.Exception.Message)" } } function Get-AzureSubscriptions { <# .SYNOPSIS Returns all accessible Azure subscriptions. .DESCRIPTION Wraps Get-AzSubscription and returns a simplified collection of subscription objects with Id, Name, State, and TenantId. .EXAMPLE Get-AzureSubscriptions Returns all Azure subscriptions accessible to the current identity. .EXAMPLE Get-AzureSubscriptions | Where-Object { $_.State -eq "Enabled" } Returns only enabled subscriptions. #> $subs = Get-AzSubscription -ErrorAction Stop foreach ($sub in $subs) { [PSCustomObject]@{ SubscriptionId = "$($sub.Id)" SubscriptionName = "$($sub.Name)" State = "$($sub.State)" TenantId = "$($sub.TenantId)" } } } function Get-AzureResourceGroups { <# .SYNOPSIS Returns all resource groups in the current subscription. .DESCRIPTION Wraps Get-AzResourceGroup and returns a simplified collection. .EXAMPLE Get-AzureResourceGroups Returns all resource groups in the current subscription. .EXAMPLE Get-AzureResourceGroups | Where-Object { $_.Location -eq "eastus" } Returns only resource groups located in East US. #> $rgs = Get-AzResourceGroup -ErrorAction Stop foreach ($rg in $rgs) { [PSCustomObject]@{ ResourceGroupName = "$($rg.ResourceGroupName)" Location = "$($rg.Location)" ProvisioningState = "$($rg.ProvisioningState)" Tags = if ($rg.Tags) { ($rg.Tags.GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" }) -join "; " } else { "" } } } } function Get-AzureResources { <# .SYNOPSIS Returns all resources within a resource group. .DESCRIPTION Wraps Get-AzResource for a given resource group and returns a simplified collection with key properties. .PARAMETER ResourceGroupName The name of the resource group to enumerate. .EXAMPLE Get-AzureResources -ResourceGroupName "Production-RG" Returns all resources within the Production-RG resource group. .EXAMPLE Get-AzureResources -ResourceGroupName "Production-RG" | Where-Object { $_.ResourceType -like "*virtualMachines*" } Returns only virtual machine resources from the specified resource group. #> param( [Parameter(Mandatory)][string]$ResourceGroupName ) $resources = Get-AzResource -ResourceGroupName $ResourceGroupName -ExpandProperties -ErrorAction Stop foreach ($r in $resources) { $provState = if ($r.Properties -and $r.Properties.provisioningState) { "$($r.Properties.provisioningState)" } elseif ($r.ProvisioningState) { "$($r.ProvisioningState)" } else { "N/A" } [PSCustomObject]@{ ResourceName = "$($r.Name)" ResourceId = "$($r.ResourceId)" ResourceType = "$($r.ResourceType)" Location = "$($r.Location)" Kind = if ($r.Kind) { "$($r.Kind)" } else { "N/A" } Sku = if ($r.Sku -and $r.Sku.Name) { "$($r.Sku.Name)" } else { "N/A" } ProvisioningState = $provState Tags = if ($r.Tags) { ($r.Tags.GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" }) -join "; " } else { "" } } } } function Get-AzureResourceMetrics { <# .SYNOPSIS Returns available metric definitions and recent values for an Azure resource. .DESCRIPTION Queries Azure Monitor for the specified resource's metric definitions, then retrieves the latest data point for each metric over the past hour. Returns a collection of metric objects suitable for storing as attributes. .PARAMETER ResourceId The full Azure resource ID. .PARAMETER MaxMetrics Maximum number of metrics to retrieve values for. Defaults to 20 to prevent excessive API calls on resources with many metrics. .EXAMPLE Get-AzureResourceMetrics -ResourceId "/subscriptions/xxxx/resourceGroups/myRG/providers/Microsoft.Compute/virtualMachines/myVM" Returns up to 20 recent metric values for the specified Azure VM. .EXAMPLE Get-AzureResourceMetrics -ResourceId $resource.ResourceId -MaxMetrics 5 Returns only the first 5 metric definitions and their latest values. #> param( [Parameter(Mandatory)][string]$ResourceId, [int]$MaxMetrics = 20 ) $metrics = @() try { $definitions = Get-AzMetricDefinition -ResourceId $ResourceId -ErrorAction Stop | Select-Object -First $MaxMetrics foreach ($def in $definitions) { $metricName = $def.Name.Value $metricUnit = "$($def.Unit)" $primaryAgg = if ($def.PrimaryAggregationType) { "$($def.PrimaryAggregationType)" } else { "Average" } try { $metricData = Get-AzMetric -ResourceId $ResourceId -MetricName $metricName ` -TimeGrain ([TimeSpan]::FromMinutes(5)) -StartTime (Get-Date).AddHours(-1) ` -EndTime (Get-Date) -AggregationType $primaryAgg -ErrorAction Stop $lastValue = "N/A" if ($metricData.Data) { $latest = $metricData.Data | Where-Object { $null -ne $_.$primaryAgg } | Select-Object -Last 1 if ($latest) { $lastValue = "$([math]::Round($latest.$primaryAgg, 4))" } } $metrics += [PSCustomObject]@{ MetricName = $metricName DisplayName = "$($def.Name.LocalizedValue)" Unit = $metricUnit Aggregation = $primaryAgg LastValue = $lastValue } } catch { Write-Verbose "Could not retrieve metric $metricName for resource: $($_.Exception.Message)" } } } catch { Write-Verbose "Could not retrieve metric definitions for $ResourceId : $($_.Exception.Message)" } return $metrics } function Get-AzureResourceDetail { <# .SYNOPSIS Builds a detailed summary object for an Azure resource including metrics. .DESCRIPTION Combines resource metadata with metric data into a single object suitable for display and attribute creation. .PARAMETER Resource A resource object from Get-AzureResources. .PARAMETER SubscriptionName The name of the subscription the resource belongs to. .PARAMETER SubscriptionId The subscription ID the resource belongs to. .PARAMETER ResourceGroupName The resource group name. .PARAMETER IncludeMetrics Whether to fetch metric data. Defaults to $true. .PARAMETER MaxMetrics Maximum number of metrics to retrieve. Defaults to 20. .EXAMPLE $resources = Get-AzureResources -ResourceGroupName "Production-RG" Get-AzureResourceDetail -Resource $resources[0] -SubscriptionName "MySub" -SubscriptionId "xxxx" -ResourceGroupName "Production-RG" Returns a detailed summary of the first resource including up to 20 metric values. .EXAMPLE Get-AzureResourceDetail -Resource $resource -SubscriptionName "MySub" -SubscriptionId "xxxx" -ResourceGroupName "myRG" -IncludeMetrics $false Returns resource details without fetching Azure Monitor metrics. #> param( [Parameter(Mandatory)]$Resource, [Parameter(Mandatory)][string]$SubscriptionName, [Parameter(Mandatory)][string]$SubscriptionId, [Parameter(Mandatory)][string]$ResourceGroupName, [bool]$IncludeMetrics = $true, [int]$MaxMetrics = 20 ) $metricsData = @() if ($IncludeMetrics) { $metricsData = Get-AzureResourceMetrics -ResourceId $Resource.ResourceId -MaxMetrics $MaxMetrics } $metricsSummary = if ($metricsData.Count -gt 0) { ($metricsData | ForEach-Object { "$($_.DisplayName): $($_.LastValue) $($_.Unit)" }) -join "; " } else { "No metrics available" } [PSCustomObject]@{ ResourceName = $Resource.ResourceName ResourceId = $Resource.ResourceId ResourceType = $Resource.ResourceType Location = $Resource.Location Kind = $Resource.Kind Sku = $Resource.Sku ProvisioningState = $Resource.ProvisioningState Tags = $Resource.Tags SubscriptionName = $SubscriptionName SubscriptionId = $SubscriptionId ResourceGroupName = $ResourceGroupName MetricCount = $metricsData.Count MetricsSummary = $metricsSummary Metrics = $metricsData } } function Resolve-AzureResourceIP { <# .SYNOPSIS Attempts to resolve an IP address for an Azure resource. .DESCRIPTION Tries to find a public or private IP associated with the resource. For VMs it checks network interfaces; for App Services / SQL / etc. it attempts DNS resolution of the FQDN. Returns $null if no IP found. .PARAMETER Resource A resource object from Get-AzureResources (needs ResourceId, ResourceType, ResourceName). .EXAMPLE $resources = Get-AzureResources -ResourceGroupName "Production-RG" $ip = Resolve-AzureResourceIP -Resource ($resources | Where-Object { $_.ResourceType -like "*virtualMachines*" } | Select-Object -First 1) Resolves the public or private IP of the first VM in the resource group. .EXAMPLE $resources | ForEach-Object { Resolve-AzureResourceIP -Resource $_ } Attempts IP resolution for every resource in the collection. #> param( [Parameter(Mandatory)]$Resource ) $ip = $null switch -Wildcard ($Resource.ResourceType) { "Microsoft.Compute/virtualMachines" { try { $vm = Get-AzVM -ResourceId $Resource.ResourceId -Status -ErrorAction Stop $nicIds = $vm.NetworkProfile.NetworkInterfaces | Select-Object -ExpandProperty Id foreach ($nicId in $nicIds) { $nic = Get-AzNetworkInterface -ResourceId $nicId -ErrorAction SilentlyContinue if ($nic) { # Prefer public IP foreach ($ipConfig in $nic.IpConfigurations) { if ($ipConfig.PublicIpAddress) { $pubIp = Get-AzPublicIpAddress -ResourceId $ipConfig.PublicIpAddress.Id -ErrorAction SilentlyContinue if ($pubIp.IpAddress -and $pubIp.IpAddress -ne "Not Assigned") { $ip = $pubIp.IpAddress break } } } # Fall back to private IP if (-not $ip) { $privateIp = ($nic.IpConfigurations | Select-Object -First 1).PrivateIpAddress if ($privateIp) { $ip = $privateIp } } } if ($ip) { break } } } catch { Write-Verbose "Could not resolve VM IP for $($Resource.ResourceName): $($_.Exception.Message)" } } "Microsoft.Network/publicIPAddresses" { try { $pubIp = Get-AzPublicIpAddress -Name $Resource.ResourceName -ResourceGroupName (($Resource.ResourceId -split '/')[4]) -ErrorAction Stop if ($pubIp.IpAddress -and $pubIp.IpAddress -ne "Not Assigned") { $ip = $pubIp.IpAddress } } catch { Write-Verbose "Could not resolve public IP for $($Resource.ResourceName): $($_.Exception.Message)" } } "Microsoft.Sql/servers" { try { $fqdn = "$($Resource.ResourceName).database.windows.net" $resolved = [System.Net.Dns]::GetHostAddresses($fqdn) | Select-Object -First 1 if ($resolved) { $ip = $resolved.IPAddressToString } } catch { Write-Verbose "Could not resolve SQL server FQDN for $($Resource.ResourceName): $($_.Exception.Message)" } } "Microsoft.Web/sites" { try { $fqdn = "$($Resource.ResourceName).azurewebsites.net" $resolved = [System.Net.Dns]::GetHostAddresses($fqdn) | Select-Object -First 1 if ($resolved) { $ip = $resolved.IPAddressToString } } catch { Write-Verbose "Could not resolve App Service FQDN for $($Resource.ResourceName): $($_.Exception.Message)" } } default { # Generic DNS attempt using resource name try { $resolved = [System.Net.Dns]::GetHostAddresses($Resource.ResourceName) | Select-Object -First 1 if ($resolved) { $ip = $resolved.IPAddressToString } } catch { Write-Verbose "No IP resolution available for $($Resource.ResourceType) : $($Resource.ResourceName)" } } } return $ip } function Get-AzureDashboard { <# .SYNOPSIS Builds a unified dashboard view of Azure resources across subscriptions. .DESCRIPTION Enumerates accessible subscriptions, iterates through their resource groups, and returns a flat collection of resources with metadata suitable for Bootstrap Table display. Each row contains resource name, type, provisioning state, resolved IP, location, subscription, resource group, SKU, tags, and optional Azure Monitor metrics. .PARAMETER SubscriptionIds Optional array of subscription IDs to limit scope. If omitted, scans all enabled subscriptions accessible to the current authenticated session. .PARAMETER IncludeMetrics Whether to fetch Azure Monitor metrics for each resource. Defaults to $false to avoid excessive API calls on large environments. .EXAMPLE Get-AzureDashboard Returns all resources across all accessible Azure subscriptions. .EXAMPLE Get-AzureDashboard -SubscriptionIds "xxxx-yyyy" -IncludeMetrics $true Returns resources from one subscription with metric data. .EXAMPLE Connect-AzureServicePrincipal -TenantId $tid -ApplicationId $aid -ClientSecret $secret $data = Get-AzureDashboard -SubscriptionIds $subId Export-AzureDashboardHtml -DashboardData $data -OutputPath "C:\Reports\azure.html" Start-Process "C:\Reports\azure.html" End-to-end: authenticate via service principal, gather data, export HTML, and open in browser. .OUTPUTS PSCustomObject[] Each object contains: ResourceName, ResourceType, ProvisioningState, IPAddress, Location, Subscription, ResourceGroup, Kind, Sku, Tags, Metrics. .NOTES Author : jason@wug.ninja Version : 1.0.0 Date : 2025-07-15 Requires: PowerShell 5.1+, Az PowerShell modules (Az.Accounts, Az.Resources, Az.Monitor). .LINK https://github.com/jayyx2/WhatsUpGoldPS #> param( [string[]]$SubscriptionIds, [bool]$IncludeMetrics = $false ) $subscriptions = Get-AzureSubscriptions | Where-Object { $_.State -eq 'Enabled' } if ($SubscriptionIds) { $subscriptions = $subscriptions | Where-Object { $_.SubscriptionId -in $SubscriptionIds } } $results = @() foreach ($sub in $subscriptions) { Set-AzContext -SubscriptionId $sub.SubscriptionId -ErrorAction SilentlyContinue | Out-Null try { $rgs = Get-AzureResourceGroups } catch { Write-Warning "Failed to list RGs for $($sub.SubscriptionName): $($_.Exception.Message)" continue } foreach ($rg in $rgs) { try { $resources = Get-AzureResources -ResourceGroupName $rg.ResourceGroupName } catch { Write-Warning "Failed to list resources in $($rg.ResourceGroupName): $($_.Exception.Message)" continue } foreach ($r in $resources) { $ip = "N/A" try { $ip = Resolve-AzureResourceIP -Resource $r; if (-not $ip) { $ip = "N/A" } } catch {} $metricsSummary = "N/A" if ($IncludeMetrics) { try { $metrics = Get-AzureResourceMetrics -ResourceId $r.ResourceId -MaxMetrics 5 if ($metrics) { $metricsSummary = ($metrics | ForEach-Object { "$($_.DisplayName): $($_.LastValue)" }) -join "; " } } catch {} } $results += [PSCustomObject]@{ ResourceName = $r.ResourceName ResourceType = ($r.ResourceType -split '/')[-1] ProvisioningState = $r.ProvisioningState IPAddress = $ip Location = $r.Location Subscription = $sub.SubscriptionName ResourceGroup = $rg.ResourceGroupName Kind = $r.Kind Sku = $r.Sku Tags = $r.Tags Metrics = $metricsSummary } } } } return $results } function Export-AzureDashboardHtml { <# .SYNOPSIS Renders Azure dashboard data into a self-contained HTML file. .DESCRIPTION Takes the output of Get-AzureDashboard and generates a Bootstrap-based HTML report with sortable, searchable, and exportable tables. The report uses Bootstrap 5 and Bootstrap-Table for interactive filtering, sorting, column toggling, and CSV/JSON export. .PARAMETER DashboardData Array of PSCustomObject from Get-AzureDashboard containing Azure resource details. .PARAMETER OutputPath File path for the output HTML file. Parent directory must exist. .PARAMETER ReportTitle Title shown in the report header. Defaults to "Azure Dashboard". .PARAMETER TemplatePath Optional path to a custom HTML template. If omitted, uses the Azure-Dashboard-Template.html in the same directory as this script. .EXAMPLE $data = Get-AzureDashboard Export-AzureDashboardHtml -DashboardData $data -OutputPath "C:\Reports\azure.html" Exports the dashboard data to an HTML file using the default template. .EXAMPLE Export-AzureDashboardHtml -DashboardData $data -OutputPath "$env:TEMP\azure.html" -ReportTitle "Prod Azure" Exports with a custom report title. .EXAMPLE Connect-AzAccount $data = Get-AzureDashboard -SubscriptionIds $subId -IncludeMetrics $true Export-AzureDashboardHtml -DashboardData $data -OutputPath "C:\Reports\azure.html" Start-Process "C:\Reports\azure.html" Full pipeline: authenticate, gather with metrics, export, and open the report in a browser. .OUTPUTS System.Void Writes an HTML file to the path specified by OutputPath. .NOTES Author : jason@wug.ninja Version : 1.0.0 Date : 2025-07-15 Requires: PowerShell 5.1+, Azure-Dashboard-Template.html in the script directory. .LINK https://github.com/jayyx2/WhatsUpGoldPS #> [CmdletBinding()] param( [Parameter(Mandatory)]$DashboardData, [Parameter(Mandatory)][string]$OutputPath, [string]$ReportTitle = "Azure Dashboard", [string]$TemplatePath ) if (-not $TemplatePath) { $TemplatePath = Join-Path $PSScriptRoot "Azure-Dashboard-Template.html" } if (-not (Test-Path $TemplatePath)) { throw "HTML template not found at $TemplatePath" } $firstObj = $DashboardData | Select-Object -First 1 $columns = @() foreach ($prop in $firstObj.PSObject.Properties) { $col = @{ field = $prop.Name title = ($prop.Name -creplace '([A-Z])', ' $1').Trim() sortable = $true searchable = $true } if ($prop.Name -eq 'ProvisioningState') { $col.formatter = 'formatState' } $columns += $col } $columnsJson = $columns | ConvertTo-Json -Depth 5 -Compress $dataJson = $DashboardData | ConvertTo-Json -Depth 5 -Compress $tableConfig = @" columns: $columnsJson, data: $dataJson "@ $html = Get-Content -Path $TemplatePath -Raw $html = $html -replace 'replaceThisHere', $tableConfig $html = $html -replace 'ReplaceYourReportNameHere', $ReportTitle $html = $html -replace 'ReplaceUpdateTimeHere', (Get-Date).ToString("yyyy-MM-dd HH:mm:ss") Set-Content -Path $OutputPath -Value $html -Encoding UTF8 Write-Verbose "Azure Dashboard HTML written to $OutputPath" } # SIG # Begin signature block # MIIVlwYJKoZIhvcNAQcCoIIViDCCFYQCAQExDzANBglghkgBZQMEAgEFADB5Bgor # BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG # KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCBU+bbTYMoqUIAX # 8akHdreUn7F59G7h2yHuv9cCwpPvZ6CCEdMwggVvMIIEV6ADAgECAhBI/JO0YFWU # jTanyYqJ1pQWMA0GCSqGSIb3DQEBDAUAMHsxCzAJBgNVBAYTAkdCMRswGQYDVQQI # DBJHcmVhdGVyIE1hbmNoZXN0ZXIxEDAOBgNVBAcMB1NhbGZvcmQxGjAYBgNVBAoM # EUNvbW9kbyBDQSBMaW1pdGVkMSEwHwYDVQQDDBhBQUEgQ2VydGlmaWNhdGUgU2Vy # dmljZXMwHhcNMjEwNTI1MDAwMDAwWhcNMjgxMjMxMjM1OTU5WjBWMQswCQYDVQQG # EwJHQjEYMBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMS0wKwYDVQQDEyRTZWN0aWdv # IFB1YmxpYyBDb2RlIFNpZ25pbmcgUm9vdCBSNDYwggIiMA0GCSqGSIb3DQEBAQUA # A4ICDwAwggIKAoICAQCN55QSIgQkdC7/FiMCkoq2rjaFrEfUI5ErPtx94jGgUW+s # hJHjUoq14pbe0IdjJImK/+8Skzt9u7aKvb0Ffyeba2XTpQxpsbxJOZrxbW6q5KCD # J9qaDStQ6Utbs7hkNqR+Sj2pcaths3OzPAsM79szV+W+NDfjlxtd/R8SPYIDdub7 # P2bSlDFp+m2zNKzBenjcklDyZMeqLQSrw2rq4C+np9xu1+j/2iGrQL+57g2extme # me/G3h+pDHazJyCh1rr9gOcB0u/rgimVcI3/uxXP/tEPNqIuTzKQdEZrRzUTdwUz # T2MuuC3hv2WnBGsY2HH6zAjybYmZELGt2z4s5KoYsMYHAXVn3m3pY2MeNn9pib6q # RT5uWl+PoVvLnTCGMOgDs0DGDQ84zWeoU4j6uDBl+m/H5x2xg3RpPqzEaDux5mcz # mrYI4IAFSEDu9oJkRqj1c7AGlfJsZZ+/VVscnFcax3hGfHCqlBuCF6yH6bbJDoEc # QNYWFyn8XJwYK+pF9e+91WdPKF4F7pBMeufG9ND8+s0+MkYTIDaKBOq3qgdGnA2T # OglmmVhcKaO5DKYwODzQRjY1fJy67sPV+Qp2+n4FG0DKkjXp1XrRtX8ArqmQqsV/ # AZwQsRb8zG4Y3G9i/qZQp7h7uJ0VP/4gDHXIIloTlRmQAOka1cKG8eOO7F/05QID # AQABo4IBEjCCAQ4wHwYDVR0jBBgwFoAUoBEKIz6W8Qfs4q8p74Klf9AwpLQwHQYD # VR0OBBYEFDLrkpr/NZZILyhAQnAgNpFcF4XmMA4GA1UdDwEB/wQEAwIBhjAPBgNV # HRMBAf8EBTADAQH/MBMGA1UdJQQMMAoGCCsGAQUFBwMDMBsGA1UdIAQUMBIwBgYE # VR0gADAIBgZngQwBBAEwQwYDVR0fBDwwOjA4oDagNIYyaHR0cDovL2NybC5jb21v # ZG9jYS5jb20vQUFBQ2VydGlmaWNhdGVTZXJ2aWNlcy5jcmwwNAYIKwYBBQUHAQEE # KDAmMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5jb21vZG9jYS5jb20wDQYJKoZI # hvcNAQEMBQADggEBABK/oe+LdJqYRLhpRrWrJAoMpIpnuDqBv0WKfVIHqI0fTiGF # OaNrXi0ghr8QuK55O1PNtPvYRL4G2VxjZ9RAFodEhnIq1jIV9RKDwvnhXRFAZ/ZC # J3LFI+ICOBpMIOLbAffNRk8monxmwFE2tokCVMf8WPtsAO7+mKYulaEMUykfb9gZ # pk+e96wJ6l2CxouvgKe9gUhShDHaMuwV5KZMPWw5c9QLhTkg4IUaaOGnSDip0TYl # d8GNGRbFiExmfS9jzpjoad+sPKhdnckcW67Y8y90z7h+9teDnRGWYpquRRPaf9xH # +9/DUp/mBlXpnYzyOmJRvOwkDynUWICE5EV7WtgwggYaMIIEAqADAgECAhBiHW0M # UgGeO5B5FSCJIRwKMA0GCSqGSIb3DQEBDAUAMFYxCzAJBgNVBAYTAkdCMRgwFgYD # VQQKEw9TZWN0aWdvIExpbWl0ZWQxLTArBgNVBAMTJFNlY3RpZ28gUHVibGljIENv # ZGUgU2lnbmluZyBSb290IFI0NjAeFw0yMTAzMjIwMDAwMDBaFw0zNjAzMjEyMzU5 # NTlaMFQxCzAJBgNVBAYTAkdCMRgwFgYDVQQKEw9TZWN0aWdvIExpbWl0ZWQxKzAp # BgNVBAMTIlNlY3RpZ28gUHVibGljIENvZGUgU2lnbmluZyBDQSBSMzYwggGiMA0G # CSqGSIb3DQEBAQUAA4IBjwAwggGKAoIBgQCbK51T+jU/jmAGQ2rAz/V/9shTUxjI # ztNsfvxYB5UXeWUzCxEeAEZGbEN4QMgCsJLZUKhWThj/yPqy0iSZhXkZ6Pg2A2NV # DgFigOMYzB2OKhdqfWGVoYW3haT29PSTahYkwmMv0b/83nbeECbiMXhSOtbam+/3 # 6F09fy1tsB8je/RV0mIk8XL/tfCK6cPuYHE215wzrK0h1SWHTxPbPuYkRdkP05Zw # mRmTnAO5/arnY83jeNzhP06ShdnRqtZlV59+8yv+KIhE5ILMqgOZYAENHNX9SJDm # +qxp4VqpB3MV/h53yl41aHU5pledi9lCBbH9JeIkNFICiVHNkRmq4TpxtwfvjsUe # dyz8rNyfQJy/aOs5b4s+ac7IH60B+Ja7TVM+EKv1WuTGwcLmoU3FpOFMbmPj8pz4 # 4MPZ1f9+YEQIQty/NQd/2yGgW+ufflcZ/ZE9o1M7a5Jnqf2i2/uMSWymR8r2oQBM # dlyh2n5HirY4jKnFH/9gRvd+QOfdRrJZb1sCAwEAAaOCAWQwggFgMB8GA1UdIwQY # MBaAFDLrkpr/NZZILyhAQnAgNpFcF4XmMB0GA1UdDgQWBBQPKssghyi47G9IritU # pimqF6TNDDAOBgNVHQ8BAf8EBAMCAYYwEgYDVR0TAQH/BAgwBgEB/wIBADATBgNV # HSUEDDAKBggrBgEFBQcDAzAbBgNVHSAEFDASMAYGBFUdIAAwCAYGZ4EMAQQBMEsG # A1UdHwREMEIwQKA+oDyGOmh0dHA6Ly9jcmwuc2VjdGlnby5jb20vU2VjdGlnb1B1 # YmxpY0NvZGVTaWduaW5nUm9vdFI0Ni5jcmwwewYIKwYBBQUHAQEEbzBtMEYGCCsG # AQUFBzAChjpodHRwOi8vY3J0LnNlY3RpZ28uY29tL1NlY3RpZ29QdWJsaWNDb2Rl # U2lnbmluZ1Jvb3RSNDYucDdjMCMGCCsGAQUFBzABhhdodHRwOi8vb2NzcC5zZWN0 # aWdvLmNvbTANBgkqhkiG9w0BAQwFAAOCAgEABv+C4XdjNm57oRUgmxP/BP6YdURh # w1aVcdGRP4Wh60BAscjW4HL9hcpkOTz5jUug2oeunbYAowbFC2AKK+cMcXIBD0Zd # OaWTsyNyBBsMLHqafvIhrCymlaS98+QpoBCyKppP0OcxYEdU0hpsaqBBIZOtBajj # cw5+w/KeFvPYfLF/ldYpmlG+vd0xqlqd099iChnyIMvY5HexjO2AmtsbpVn0OhNc # WbWDRF/3sBp6fWXhz7DcML4iTAWS+MVXeNLj1lJziVKEoroGs9Mlizg0bUMbOalO # hOfCipnx8CaLZeVme5yELg09Jlo8BMe80jO37PU8ejfkP9/uPak7VLwELKxAMcJs # zkyeiaerlphwoKx1uHRzNyE6bxuSKcutisqmKL5OTunAvtONEoteSiabkPVSZ2z7 # 6mKnzAfZxCl/3dq3dUNw4rg3sTCggkHSRqTqlLMS7gjrhTqBmzu1L90Y1KWN/Y5J # KdGvspbOrTfOXyXvmPL6E52z1NZJ6ctuMFBQZH3pwWvqURR8AgQdULUvrxjUYbHH # j95Ejza63zdrEcxWLDX6xWls/GDnVNueKjWUH3fTv1Y8Wdho698YADR7TNx8X8z2 # Bev6SivBBOHY+uqiirZtg0y9ShQoPzmCcn63Syatatvx157YK9hlcPmVoa1oDE5/ # L9Uo2bC5a4CH2RwwggY+MIIEpqADAgECAhAHnODk0RR/hc05c892LTfrMA0GCSqG # SIb3DQEBDAUAMFQxCzAJBgNVBAYTAkdCMRgwFgYDVQQKEw9TZWN0aWdvIExpbWl0 # ZWQxKzApBgNVBAMTIlNlY3RpZ28gUHVibGljIENvZGUgU2lnbmluZyBDQSBSMzYw # HhcNMjYwMjA5MDAwMDAwWhcNMjkwNDIxMjM1OTU5WjBVMQswCQYDVQQGEwJVUzEU # MBIGA1UECAwLQ29ubmVjdGljdXQxFzAVBgNVBAoMDkphc29uIEFsYmVyaW5vMRcw # FQYDVQQDDA5KYXNvbiBBbGJlcmlubzCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCC # AgoCggIBAPN6aN4B1yYWkI5b5TBj3I0VV/peETrHb6EY4BHGxt8Ap+eT+WpEpJyE # tRYPxEmNJL3A38Bkg7mwzPE3/1NK570ZBCuBjSAn4mSDIgIuXZnvyBO9W1OQs5d6 # 7MlJLUAEufl18tOr3ST1DeO9gSjQSAE5Nql0QDxPnm93OZBon+Fz3CmE+z3MwAe2 # h4KdtRAnCqwM+/V7iBdbw+JOxolpx+7RVjGyProTENIG3pe/hKvPb501lf8uBAAD # LdjZr5ip8vIWbf857Yw1Bu10nVI7HW3eE8Cl5//d1ribHlzTzQLfttW+k+DaFsKZ # BBL56l4YAlIVRsrOiE1kdHYYx6IGrEA809R7+TZA9DzGqyFiv9qmJAbL4fDwetDe # yIq+Oztz1LvEdy8Rcd0JBY+J4S0eDEFIA3X0N8VcLeAwabKb9AjulKXwUeqCJLvN # 79CJ90UTZb2+I+tamj0dn+IKMEsJ4v4Ggx72sxFr9+6XziodtTg5Luf2xd6+Phha # mOxF2px9LObhBLLEMyRsCHZIzVZOFKu9BpHQH7ufGB+Sa80Tli0/6LEyn9+bMYWi # 2ttn6lLOPThXMiQaooRUq6q2u3+F4SaPlxVFLI7OJVMhar6nW6joBvELTJPmANSM # jDSRFDfHRCdGbZsL/keELJNy+jZctF6VvxQEjFM8/bazu6qYhrA7AgMBAAGjggGJ # MIIBhTAfBgNVHSMEGDAWgBQPKssghyi47G9IritUpimqF6TNDDAdBgNVHQ4EFgQU # 6YF0o0D5AVhKHbVocr8GaSIBibAwDgYDVR0PAQH/BAQDAgeAMAwGA1UdEwEB/wQC # MAAwEwYDVR0lBAwwCgYIKwYBBQUHAwMwSgYDVR0gBEMwQTA1BgwrBgEEAbIxAQIB # AwIwJTAjBggrBgEFBQcCARYXaHR0cHM6Ly9zZWN0aWdvLmNvbS9DUFMwCAYGZ4EM # AQQBMEkGA1UdHwRCMEAwPqA8oDqGOGh0dHA6Ly9jcmwuc2VjdGlnby5jb20vU2Vj # dGlnb1B1YmxpY0NvZGVTaWduaW5nQ0FSMzYuY3JsMHkGCCsGAQUFBwEBBG0wazBE # BggrBgEFBQcwAoY4aHR0cDovL2NydC5zZWN0aWdvLmNvbS9TZWN0aWdvUHVibGlj # Q29kZVNpZ25pbmdDQVIzNi5jcnQwIwYIKwYBBQUHMAGGF2h0dHA6Ly9vY3NwLnNl # Y3RpZ28uY29tMA0GCSqGSIb3DQEBDAUAA4IBgQAEIsm4xnOd/tZMVrKwi3doAXvC # wOA/RYQnFJD7R/bSQRu3wXEK4o9SIefye18B/q4fhBkhNAJuEvTQAGfqbbpxow03 # J5PrDTp1WPCWbXKX8Oz9vGWJFyJxRGftkdzZ57JE00synEMS8XCwLO9P32MyR9Z9 # URrpiLPJ9rQjfHMb1BUdvaNayomm7aWLAnD+X7jm6o8sNT5An1cwEAob7obWDM6s # X93wphwJNBJAstH9Ozs6LwISOX6sKS7CKm9N3Kp8hOUue0ZHAtZdFl6o5u12wy+z # zieGEI50fKnN77FfNKFOWKlS6OJwlArcbFegB5K89LcE5iNSmaM3VMB2ADV1FEcj # GSHw4lTg1Wx+WMAMdl/7nbvfFxJ9uu5tNiT54B0s+lZO/HztwXYQUczdsFon3pjs # Nrsk9ZlalBi5SHkIu+F6g7tWiEv3rtVApmJRnLkUr2Xq2a4nbslUCt4jKs5UX4V1 # nSX8OM++AXoyVGO+iTj7z+pl6XE9Gw/Td6WKKKsxggMaMIIDFgIBATBoMFQxCzAJ # BgNVBAYTAkdCMRgwFgYDVQQKEw9TZWN0aWdvIExpbWl0ZWQxKzApBgNVBAMTIlNl # Y3RpZ28gUHVibGljIENvZGUgU2lnbmluZyBDQSBSMzYCEAec4OTRFH+FzTlzz3Yt # N+swDQYJYIZIAWUDBAIBBQCggYQwGAYKKwYBBAGCNwIBDDEKMAigAoAAoQKAADAZ # BgkqhkiG9w0BCQMxDAYKKwYBBAGCNwIBBDAcBgorBgEEAYI3AgELMQ4wDAYKKwYB # BAGCNwIBFTAvBgkqhkiG9w0BCQQxIgQgnIUpFfVPj4oX1yrn6iVKtN0zeIGE54zA # YNx1ZE2I0YAwDQYJKoZIhvcNAQEBBQAEggIAp57fL1Jq2/rPfWE4JextCLMmP7O5 # hTJCydqLwQoQj2i9jwl3uJtsogXRWxBj5SmTtVoKzZNiGVLl2eQlehZUnXcqa+ID # s+7nvz1S1tyyh0oerDLUGixoOsTMBXINOrCq7tiMlNnRV2uUIcOrgSIXkvo8pmI7 # nRfGsJnSlFEnanFsmlB1zhxTFehCMf773G/6ThpmF37XcJfWICupzzEGluyZvDCh # AJyzy83EysFjquObkVNbWiruq57/1Ke5Kj4vSLoui/WEqu/z9egEBvGa1I6ApGRe # mqGUSxbnDuZkgrpBhUAR4JQYMajCvKudJSaNAfmJK7Sg4S/PHJVHXwTMoxl+Q30t # ZGp2NNaq6zUEPAntkmDDmdaLy8Azs+dkOzYLX45htcd/iCSOKwaDu5CaDu6HZClu # DwqaKfw5rZRD6blRXHwxmAZDyWEwxhJOIOOvWqzzzU1O7AKJcB+aKRB5d0t+nxKl # GO9aRPCgdW7U2LBVECuVJUQdpCHPz1UQcNIP3AAdWB9Xo6FDLCSvPyKzcGy+IuGv # M7J8o3RaKX9cpM/QT0Qv+efqNipZt31rUGp80gJDXgN33KQPMD6QkkhUjnOlghEf # 30wBinyerxBsrpumGAA0vnRjW7aRcMzs4dyl6i6KURu+VeqnqQKZ7fPUE9lLf361 # vXvQvBtXQKvR3AU= # SIG # End signature block |