helpers/azure/AzureHelpers.ps1
|
# ============================================================================= # Azure Helpers for WhatsUpGoldPS # # Uses the Azure Resource Manager REST API via Invoke-RestMethod (built into PS 5.1). # Zero external module dependencies. # ============================================================================= # Enforce TLS 1.2 for Azure management API calls (PS 5.1 defaults to TLS 1.0/1.1) if ([System.Net.ServicePointManager]::SecurityProtocol -notmatch 'Tls12') { [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor [System.Net.SecurityProtocolType]::Tls12 } # --- REMOVED: Az PowerShell module functions --- # Previously this file contained wrapper functions that required Az.Accounts, # Az.Resources, Az.Compute, Az.Network, and Az.Monitor. Those have been # removed in favour of the REST API functions below which have zero external # dependencies and work identically on any machine with PowerShell 5.1+. function Get-AzureResourceDetail { <# .SYNOPSIS Builds a detailed summary object for an Azure resource including metrics. .DESCRIPTION Combines resource metadata with metric data from the REST API into a single object suitable for display and attribute creation. .PARAMETER Resource A resource object from Get-AzureSubscriptionResourcesREST or Get-AzureResourcesREST. .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 Get-AzureResourceDetail -Resource $resource -SubscriptionName "MySub" -SubscriptionId "xxxx" -ResourceGroupName "myRG" #> 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-AzureResourceMetricsREST -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 Get-AzureDashboard { <# .SYNOPSIS Builds a unified dashboard view of Azure resources across subscriptions. .DESCRIPTION Enumerates accessible subscriptions via REST API, pre-fetches resources and network data at the subscription level for speed, and returns a flat collection of resources with metadata suitable for Bootstrap Table display. .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 Connect-AzureServicePrincipalREST -TenantId $tid -ApplicationId $aid -ClientSecret $secret $data = Get-AzureDashboard -SubscriptionIds $subId Export-AzureDashboardHtml -DashboardData $data -OutputPath "C:\Reports\azure.html" .OUTPUTS PSCustomObject[] .NOTES Author : jason@wug.ninja Version : 2.0.0 Date : 2026-04-07 Requires: PowerShell 5.1+. Zero external module dependencies. .LINK https://github.com/jayyx2/WhatsUpGoldPS #> param( [string[]]$SubscriptionIds, [bool]$IncludeMetrics = $false ) $subscriptions = Get-AzureSubscriptionsREST | Where-Object { $_.State -eq 'Enabled' } if ($SubscriptionIds) { $subscriptions = $subscriptions | Where-Object { $_.SubscriptionId -in $SubscriptionIds } } $results = @() foreach ($sub in $subscriptions) { Write-Verbose "Processing subscription: $($sub.SubscriptionName)" # Bulk pre-fetch resources and network data per subscription try { $subResources = @(Get-AzureSubscriptionResourcesREST -SubscriptionId $sub.SubscriptionId) } catch { Write-Warning "Failed to list resources for $($sub.SubscriptionName): $($_.Exception.Message)" continue } $netData = $null try { $netData = Get-AzureNetworkDataREST -SubscriptionId $sub.SubscriptionId } catch { Write-Verbose "Network pre-fetch failed for $($sub.SubscriptionName): $($_.Exception.Message)" } foreach ($r in $subResources) { $rgName = '' if ($r.ResourceId -match '/resourceGroups/([^/]+)/') { $rgName = $Matches[1] } $ip = "N/A" if ($netData) { switch -Wildcard ($r.ResourceType) { 'Microsoft.Compute/virtualMachines' { if ($netData.VMIPs.ContainsKey($r.ResourceId)) { $ip = $netData.VMIPs[$r.ResourceId] } } 'Microsoft.Network/publicIPAddresses' { if ($netData.PIPs.ContainsKey($r.ResourceId)) { $ip = $netData.PIPs[$r.ResourceId] } } 'Microsoft.Network/loadBalancers' { if ($netData.LBIPs.ContainsKey($r.ResourceId)) { $ip = $netData.LBIPs[$r.ResourceId] } } 'Microsoft.Sql/servers' { try { $fqdn = "$($r.ResourceName).database.windows.net" $resolved = [System.Net.Dns]::GetHostAddresses($fqdn) | Select-Object -First 1 if ($resolved) { $ip = $resolved.IPAddressToString } } catch { } } 'Microsoft.Web/sites' { try { $fqdn = "$($r.ResourceName).azurewebsites.net" $resolved = [System.Net.Dns]::GetHostAddresses($fqdn) | Select-Object -First 1 if ($resolved) { $ip = $resolved.IPAddressToString } } catch { } } default { try { $resolved = [System.Net.Dns]::GetHostAddresses($r.ResourceName) | Select-Object -First 1 if ($resolved) { $ip = $resolved.IPAddressToString } } catch { } } } } else { try { $ip = Resolve-AzureResourceIPREST -Resource $r -SubscriptionId $sub.SubscriptionId; if (-not $ip) { $ip = "N/A" } } catch {} } $metricsSummary = "N/A" if ($IncludeMetrics) { try { $metrics = @(Get-AzureResourceMetricsREST -ResourceId $r.ResourceId -MaxMetrics 5) if ($metrics.Count -gt 0) { $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 = $rgName 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-AzureServicePrincipalREST -TenantId $tid -ApplicationId $aid -ClientSecret $secret $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" } # ============================================================================= # REST API Functions # Uses Azure Resource Manager REST API via Invoke-RestMethod (built into PS 5.1) # ============================================================================= # Script-scoped token cache for REST API calls if (-not (Get-Variable -Name '_AzureRESTToken' -Scope Script -ErrorAction SilentlyContinue)) { $script:_AzureRESTToken = $null $script:_AzureRESTTokenExpiry = [datetime]::MinValue } function Connect-AzureServicePrincipalREST { <# .SYNOPSIS Authenticates to Azure via OAuth2 client_credentials and caches the token. .DESCRIPTION Posts to the Azure AD token endpoint using a service principal's Application ID and Client Secret. Caches the bearer token in script scope so subsequent REST helper calls can reuse it. .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-AzureServicePrincipalREST -TenantId $tid -ApplicationId $aid -ClientSecret $secret #> param( [Parameter(Mandatory)][string]$TenantId, [Parameter(Mandatory)][string]$ApplicationId, [Parameter(Mandatory)][string]$ClientSecret ) $tokenUri = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token" $body = @{ grant_type = 'client_credentials' client_id = $ApplicationId client_secret = $ClientSecret scope = 'https://management.azure.com/.default' } try { $resp = Invoke-RestMethod -Uri $tokenUri -Method POST -Body $body -ContentType 'application/x-www-form-urlencoded' -ErrorAction Stop $script:_AzureRESTToken = $resp.access_token $script:_AzureRESTTokenExpiry = (Get-Date).AddSeconds($resp.expires_in - 60) Write-Verbose "Authenticated to Azure tenant $TenantId via REST (token valid for $($resp.expires_in)s)" return @{ TenantId = $TenantId; ApplicationId = $ApplicationId } } catch { throw "Failed to authenticate to Azure via REST: $($_.Exception.Message)" } } function Invoke-AzureREST { <# .SYNOPSIS Internal helper -- calls an Azure ARM REST endpoint with the cached token. .PARAMETER Uri Full URI to call. .PARAMETER Method HTTP method. Defaults to GET. .PARAMETER Body Request body (string or hashtable). Hashtables are auto-converted to JSON. Content-Type is set to application/json when Body is provided. .PARAMETER ApiVersion Appended as ?api-version= query parameter if the Uri does not already contain one. #> [CmdletBinding()] param( [Parameter(Mandatory)][string]$Uri, [string]$Method = 'GET', $Body, [string]$ApiVersion ) if (-not $script:_AzureRESTToken -or (Get-Date) -ge $script:_AzureRESTTokenExpiry) { throw "Azure REST token is not set or expired. Call Connect-AzureServicePrincipalREST first." } if ($ApiVersion -and $Uri -notmatch 'api-version=') { $sep = if ($Uri.Contains('?')) { '&' } else { '?' } $Uri = "${Uri}${sep}api-version=${ApiVersion}" } # Re-enforce TLS 1.2 before every request (PS 5.1 can lose the setting # when other code or .NET ServicePoints negotiate a different protocol). if ([System.Net.ServicePointManager]::SecurityProtocol -notmatch 'Tls12') { [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor [System.Net.SecurityProtocolType]::Tls12 } $headers = @{ Authorization = "Bearer $($script:_AzureRESTToken)" } # Build Invoke-RestMethod splat $irmSplat = @{ Uri = $null Method = $Method Headers = $headers ErrorAction = 'Stop' } if ($Body) { $jsonBody = if ($Body -is [string]) { $Body } else { $Body | ConvertTo-Json -Depth 20 } $irmSplat['Body'] = $jsonBody $irmSplat['ContentType'] = 'application/json' } # Handle pagination (nextLink) $allValues = @() $currentUri = $Uri $maxRetries = 2 do { $irmSplat['Uri'] = $currentUri $lastErr = $null for ($attempt = 1; $attempt -le $maxRetries; $attempt++) { try { $resp = Invoke-RestMethod @irmSplat $lastErr = $null break } catch { $lastErr = $_ $errMsg = "$($_.Exception.Message)" # Retry on transient connection errors (TLS reset, connection closed) if ($errMsg -match 'underlying connection was closed|unexpected error occurred on a send|unable to connect' -and $attempt -lt $maxRetries) { Write-Verbose "Connection error on attempt $attempt, retrying: $errMsg" # Force TLS 1.2 exclusively for the retry (remove TLS 1.0/1.1) [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12 } else { throw } } } if ($lastErr) { throw $lastErr } if ($resp.value) { $allValues += $resp.value } else { # Single-object response (not a list) return $resp } $currentUri = $resp.nextLink } while ($currentUri) return $allValues } function Get-AzureSubscriptionsREST { <# .SYNOPSIS Returns all accessible Azure subscriptions via REST API. .EXAMPLE Get-AzureSubscriptionsREST .EXAMPLE Get-AzureSubscriptionsREST | Where-Object { $_.State -eq "Enabled" } #> $subs = Invoke-AzureREST -Uri 'https://management.azure.com/subscriptions' -ApiVersion '2022-12-01' foreach ($sub in $subs) { [PSCustomObject]@{ SubscriptionId = "$($sub.subscriptionId)" SubscriptionName = "$($sub.displayName)" State = "$($sub.state)" TenantId = "$($sub.tenantId)" } } } function Get-AzureResourceGroupsREST { <# .SYNOPSIS Returns all resource groups in a subscription via REST API. .PARAMETER SubscriptionId The subscription ID to query. .EXAMPLE Get-AzureResourceGroupsREST -SubscriptionId "xxxx-yyyy" #> param( [Parameter(Mandatory)][string]$SubscriptionId ) $rgs = Invoke-AzureREST -Uri "https://management.azure.com/subscriptions/$SubscriptionId/resourcegroups" -ApiVersion '2024-03-01' foreach ($rg in $rgs) { $tags = '' if ($rg.tags -and $rg.tags -is [PSCustomObject]) { $tags = ($rg.tags.PSObject.Properties | Where-Object { $_.Name -ne '' } | ForEach-Object { "$($_.Name)=$($_.Value)" }) -join '; ' } [PSCustomObject]@{ ResourceGroupName = "$($rg.name)" Location = "$($rg.location)" ProvisioningState = "$($rg.properties.provisioningState)" Tags = $tags } } } function Get-AzureResourcesREST { <# .SYNOPSIS Returns all resources within a resource group via REST API. .PARAMETER SubscriptionId The subscription ID. .PARAMETER ResourceGroupName The name of the resource group to enumerate. .EXAMPLE Get-AzureResourcesREST -SubscriptionId "xxxx" -ResourceGroupName "Production-RG" #> param( [Parameter(Mandatory)][string]$SubscriptionId, [Parameter(Mandatory)][string]$ResourceGroupName ) $uri = "https://management.azure.com/subscriptions/$SubscriptionId/resourceGroups/$ResourceGroupName/resources" $resources = Invoke-AzureREST -Uri $uri -ApiVersion '2024-03-01' foreach ($r in $resources) { $provState = 'N/A' if ($r.properties -and $r.properties.provisioningState) { $provState = "$($r.properties.provisioningState)" } $tags = '' if ($r.tags -and $r.tags -is [PSCustomObject]) { $tags = ($r.tags.PSObject.Properties | Where-Object { $_.Name -ne '' } | ForEach-Object { "$($_.Name)=$($_.Value)" }) -join '; ' } [PSCustomObject]@{ ResourceName = "$($r.name)" ResourceId = "$($r.id)" ResourceType = "$($r.type)" 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 = $tags } } } function Get-AzureSubscriptionResourcesREST { <# .SYNOPSIS Returns all resources across all resource groups in a subscription via a single REST call (faster than per-RG enumeration). .PARAMETER SubscriptionId The subscription ID. .EXAMPLE Get-AzureSubscriptionResourcesREST -SubscriptionId "xxxx" #> param( [Parameter(Mandatory)][string]$SubscriptionId ) $uri = "https://management.azure.com/subscriptions/$SubscriptionId/resources" $resources = Invoke-AzureREST -Uri $uri -ApiVersion '2024-03-01' foreach ($r in $resources) { $provState = 'N/A' if ($r.properties -and $r.properties.provisioningState) { $provState = "$($r.properties.provisioningState)" } $tags = '' if ($r.tags -and $r.tags -is [PSCustomObject]) { $tags = ($r.tags.PSObject.Properties | Where-Object { $_.Name -ne '' } | ForEach-Object { "$($_.Name)=$($_.Value)" }) -join '; ' } [PSCustomObject]@{ ResourceName = "$($r.name)" ResourceId = "$($r.id)" ResourceType = "$($r.type)" 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 = $tags } } } function Get-AzureNetworkDataREST { <# .SYNOPSIS Pre-fetches all NICs, public IPs, and load balancers in a subscription and returns IP lookup hashtables for fast resolution without per-resource API calls. .PARAMETER SubscriptionId The subscription ID. .OUTPUTS Hashtable with keys: VMIPs, PIPs, LBIPs #> param( [Parameter(Mandatory)][string]$SubscriptionId ) $baseUri = "https://management.azure.com/subscriptions/$SubscriptionId/providers/Microsoft.Network" $apiVer = '2024-01-01' # 1) Public IP addresses $pipLookup = @{} try { $pips = @(Invoke-AzureREST -Uri "$baseUri/publicIPAddresses" -ApiVersion $apiVer) foreach ($pip in $pips) { if ($pip.properties.ipAddress -and $pip.properties.ipAddress -ne 'Not Assigned') { $pipLookup[$pip.id] = "$($pip.properties.ipAddress)" } } } catch { Write-Verbose "Could not pre-fetch public IPs: $_" } # 2) Network interfaces -> VM IP mapping $vmIpLookup = @{} try { $nics = @(Invoke-AzureREST -Uri "$baseUri/networkInterfaces" -ApiVersion $apiVer) foreach ($nic in $nics) { $vmId = $null if ($nic.properties.virtualMachine -and $nic.properties.virtualMachine.id) { $vmId = $nic.properties.virtualMachine.id } if (-not $vmId) { continue } $privateIp = $null $publicIp = $null if ($nic.properties.ipConfigurations) { foreach ($ipConfig in $nic.properties.ipConfigurations) { if (-not $privateIp -and $ipConfig.properties.privateIPAddress) { $privateIp = "$($ipConfig.properties.privateIPAddress)" } if (-not $publicIp -and $ipConfig.properties.publicIPAddress -and $ipConfig.properties.publicIPAddress.id) { $pipId = $ipConfig.properties.publicIPAddress.id if ($pipLookup.ContainsKey($pipId)) { $publicIp = $pipLookup[$pipId] } } } } if (-not $vmIpLookup.ContainsKey($vmId)) { $vmIpLookup[$vmId] = if ($publicIp) { $publicIp } else { $privateIp } } } } catch { Write-Verbose "Could not pre-fetch NICs: $_" } # 3) Load balancers $lbIpLookup = @{} try { $lbs = @(Invoke-AzureREST -Uri "$baseUri/loadBalancers" -ApiVersion $apiVer) foreach ($lb in $lbs) { if ($lb.properties.frontendIPConfigurations) { foreach ($feConfig in $lb.properties.frontendIPConfigurations) { if ($feConfig.properties.publicIPAddress -and $feConfig.properties.publicIPAddress.id) { $pipId = $feConfig.properties.publicIPAddress.id if ($pipLookup.ContainsKey($pipId)) { $lbIpLookup[$lb.id] = $pipLookup[$pipId] break } } if (-not $lbIpLookup.ContainsKey($lb.id) -and $feConfig.properties.privateIPAddress) { $lbIpLookup[$lb.id] = "$($feConfig.properties.privateIPAddress)" } } } } } catch { Write-Verbose "Could not pre-fetch load balancers: $_" } return @{ VMIPs = $vmIpLookup PIPs = $pipLookup LBIPs = $lbIpLookup } } function Resolve-AzureResourceIPREST { <# .SYNOPSIS Attempts to resolve an IP address for an Azure resource via REST API. .DESCRIPTION For VMs, queries the network interface REST endpoints. For App Services, SQL, and other resources, attempts DNS resolution. .PARAMETER Resource A resource object from Get-AzureResourcesREST. .PARAMETER SubscriptionId The subscription ID (needed for VM NIC lookups). .EXAMPLE Resolve-AzureResourceIPREST -Resource $resource -SubscriptionId "xxxx" #> param( [Parameter(Mandatory)]$Resource, [string]$SubscriptionId ) $ip = $null switch -Wildcard ($Resource.ResourceType) { "Microsoft.Compute/virtualMachines" { try { # Get VM details to find NIC references $vmUri = "https://management.azure.com$($Resource.ResourceId)" $vm = Invoke-AzureREST -Uri $vmUri -ApiVersion '2024-03-01' if ($vm.properties.networkProfile.networkInterfaces) { foreach ($nicRef in $vm.properties.networkProfile.networkInterfaces) { $nicUri = "https://management.azure.com$($nicRef.id)" $nic = Invoke-AzureREST -Uri $nicUri -ApiVersion '2024-01-01' if ($nic.properties.ipConfigurations) { foreach ($ipConfig in $nic.properties.ipConfigurations) { # Prefer public IP if ($ipConfig.properties.publicIPAddress) { $pubUri = "https://management.azure.com$($ipConfig.properties.publicIPAddress.id)" try { $pubIp = Invoke-AzureREST -Uri $pubUri -ApiVersion '2024-01-01' if ($pubIp.properties.ipAddress -and $pubIp.properties.ipAddress -ne 'Not Assigned') { $ip = $pubIp.properties.ipAddress break } } catch { } } # Fall back to private IP if (-not $ip -and $ipConfig.properties.privateIPAddress) { $ip = $ipConfig.properties.privateIPAddress } } } if ($ip) { break } } } } catch { Write-Verbose "Could not resolve VM IP via REST for $($Resource.ResourceName): $($_.Exception.Message)" } } "Microsoft.Network/publicIPAddresses" { try { $pubUri = "https://management.azure.com$($Resource.ResourceId)" $pubIp = Invoke-AzureREST -Uri $pubUri -ApiVersion '2024-01-01' if ($pubIp.properties.ipAddress -and $pubIp.properties.ipAddress -ne 'Not Assigned') { $ip = $pubIp.properties.ipAddress } } catch { Write-Verbose "Could not resolve public IP via REST 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)" } } "Microsoft.Network/loadBalancers" { try { $lbUri = "https://management.azure.com$($Resource.ResourceId)" $lb = Invoke-AzureREST -Uri $lbUri -ApiVersion '2024-01-01' if ($lb.properties.frontendIPConfigurations) { foreach ($feConfig in $lb.properties.frontendIPConfigurations) { # Prefer public IP if ($feConfig.properties.publicIPAddress) { try { $pubUri = "https://management.azure.com$($feConfig.properties.publicIPAddress.id)" $pubIp = Invoke-AzureREST -Uri $pubUri -ApiVersion '2024-01-01' if ($pubIp.properties.ipAddress -and $pubIp.properties.ipAddress -ne 'Not Assigned') { $ip = $pubIp.properties.ipAddress break } } catch { } } # Fall back to private IP if (-not $ip -and $feConfig.properties.privateIPAddress) { $ip = $feConfig.properties.privateIPAddress } if ($ip) { break } } } } catch { Write-Verbose "Could not resolve Load Balancer IP via REST for $($Resource.ResourceName): $($_.Exception.Message)" } } default { 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-AzureResourceMetricsREST { <# .SYNOPSIS Returns metric definitions and recent values for an Azure resource via REST API. .PARAMETER ResourceId The full Azure resource ID. .PARAMETER MaxMetrics Maximum number of metrics to retrieve. Defaults to 20. .EXAMPLE Get-AzureResourceMetricsREST -ResourceId "/subscriptions/xxxx/resourceGroups/myRG/providers/Microsoft.Compute/virtualMachines/myVM" #> param( [Parameter(Mandatory)][string]$ResourceId, [int]$MaxMetrics = 20 ) $metrics = @() try { # Get metric definitions $defUri = "https://management.azure.com${ResourceId}/providers/Microsoft.Insights/metricDefinitions" $definitions = Invoke-AzureREST -Uri $defUri -ApiVersion '2024-02-01' if (-not $definitions) { return $metrics } $definitions = @($definitions) | Select-Object -First $MaxMetrics foreach ($def in $definitions) { $metricName = $def.name.value $displayName = $def.name.localizedValue $metricUnit = "$($def.unit)" $primaryAgg = if ($def.primaryAggregationType) { "$($def.primaryAggregationType)" } else { "Average" } try { $endTime = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') $startTime = (Get-Date).AddHours(-1).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') $metricUri = "https://management.azure.com${ResourceId}/providers/Microsoft.Insights/metrics" $metricUri += "?api-version=2024-02-01&metricnames=$([uri]::EscapeDataString($metricName))" $metricUri += "×pan=${startTime}/${endTime}&interval=PT5M&aggregation=$primaryAgg" $metricResp = Invoke-AzureREST -Uri $metricUri $lastValue = 'N/A' if ($metricResp.value -and $metricResp.value[0].timeseries) { $dataPoints = $metricResp.value[0].timeseries[0].data if ($dataPoints) { $aggLower = $primaryAgg.Substring(0,1).ToLower() + $primaryAgg.Substring(1) $latest = $dataPoints | Where-Object { $null -ne $_.$aggLower } | Select-Object -Last 1 if ($latest) { $lastValue = "$([math]::Round($latest.$aggLower, 4))" } } } $metrics += [PSCustomObject]@{ MetricName = $metricName DisplayName = $displayName Unit = $metricUnit Aggregation = $primaryAgg LastValue = $lastValue } } catch { Write-Verbose "Could not retrieve metric $metricName via REST: $($_.Exception.Message)" } } } catch { Write-Verbose "Could not retrieve metric definitions via REST for $ResourceId : $($_.Exception.Message)" } return $metrics } function Get-AzureResourceHealthREST { <# .SYNOPSIS Returns the health/availability status for one or more Azure resources. .DESCRIPTION Queries the Azure Resource Health API to get the current availability status of each resource. Works for any resource type that supports Microsoft.ResourceHealth. Returns: Available, Unavailable, Degraded, or Unknown. For VMs, also queries the instance view to get the power state (Running, Stopped, Deallocated, etc.). .PARAMETER ResourceId One or more full Azure resource IDs. .EXAMPLE Get-AzureResourceHealthREST -ResourceId '/subscriptions/.../providers/Microsoft.Compute/virtualMachines/myvm' #> [CmdletBinding()] param( [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)] [string[]]$ResourceId ) process { foreach ($resId in $ResourceId) { $healthStatus = 'Unknown' $healthReason = '' $powerState = '' # Resource Health API try { $healthUri = "https://management.azure.com${resId}/providers/Microsoft.ResourceHealth/availabilityStatuses/current" $healthResp = Invoke-AzureREST -Uri $healthUri -ApiVersion '2023-07-01-preview' if ($healthResp.properties) { $healthStatus = "$($healthResp.properties.availabilityState)" $healthReason = "$($healthResp.properties.summary)" } } catch { Write-Verbose "Resource Health not available for ${resId}: $_" } # VM instance view for power state if ($resId -match 'Microsoft\.Compute/virtualMachines') { try { $ivUri = "https://management.azure.com${resId}/instanceView" $ivResp = Invoke-AzureREST -Uri $ivUri -ApiVersion '2024-03-01' if ($ivResp.statuses) { $pwStatus = $ivResp.statuses | Where-Object { $_.code -match '^PowerState/' } | Select-Object -First 1 if ($pwStatus) { $powerState = ($pwStatus.code -replace 'PowerState/', '') } } } catch { Write-Verbose "VM instanceView not available for ${resId}: $_" } } [PSCustomObject]@{ ResourceId = $resId ResourceName = ($resId -split '/')[-1] Health = $healthStatus Reason = $healthReason PowerState = $powerState } } } } # SIG # Begin signature block # MIIr+wYJKoZIhvcNAQcCoIIr7DCCK+gCAQExDzANBglghkgBZQMEAgEFADB5Bgor # BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG # KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCDoni/fs1Cur9WS # u++AVkT7j/dUt1EcORmLdVGcbpMu3qCCJQ0wggVvMIIEV6ADAgECAhBI/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/mBlXpnYzyOmJRvOwkDynUWICE5EV7WtgwggWNMIIEdaADAgECAhAOmxiO # +dAt5+/bUOIIQBhaMA0GCSqGSIb3DQEBDAUAMGUxCzAJBgNVBAYTAlVTMRUwEwYD # VQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xJDAi # BgNVBAMTG0RpZ2lDZXJ0IEFzc3VyZWQgSUQgUm9vdCBDQTAeFw0yMjA4MDEwMDAw # MDBaFw0zMTExMDkyMzU5NTlaMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdp # Q2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERp # Z2lDZXJ0IFRydXN0ZWQgUm9vdCBHNDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCC # AgoCggIBAL/mkHNo3rvkXUo8MCIwaTPswqclLskhPfKK2FnC4SmnPVirdprNrnsb # hA3EMB/zG6Q4FutWxpdtHauyefLKEdLkX9YFPFIPUh/GnhWlfr6fqVcWWVVyr2iT # cMKyunWZanMylNEQRBAu34LzB4TmdDttceItDBvuINXJIB1jKS3O7F5OyJP4IWGb # NOsFxl7sWxq868nPzaw0QF+xembud8hIqGZXV59UWI4MK7dPpzDZVu7Ke13jrclP # XuU15zHL2pNe3I6PgNq2kZhAkHnDeMe2scS1ahg4AxCN2NQ3pC4FfYj1gj4QkXCr # VYJBMtfbBHMqbpEBfCFM1LyuGwN1XXhm2ToxRJozQL8I11pJpMLmqaBn3aQnvKFP # ObURWBf3JFxGj2T3wWmIdph2PVldQnaHiZdpekjw4KISG2aadMreSx7nDmOu5tTv # kpI6nj3cAORFJYm2mkQZK37AlLTSYW3rM9nF30sEAMx9HJXDj/chsrIRt7t/8tWM # cCxBYKqxYxhElRp2Yn72gLD76GSmM9GJB+G9t+ZDpBi4pncB4Q+UDCEdslQpJYls # 5Q5SUUd0viastkF13nqsX40/ybzTQRESW+UQUOsxxcpyFiIJ33xMdT9j7CFfxCBR # a2+xq4aLT8LWRV+dIPyhHsXAj6KxfgommfXkaS+YHS312amyHeUbAgMBAAGjggE6 # MIIBNjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBTs1+OC0nFdZEzfLmc/57qY # rhwPTzAfBgNVHSMEGDAWgBRF66Kv9JLLgjEtUYunpyGd823IDzAOBgNVHQ8BAf8E # BAMCAYYweQYIKwYBBQUHAQEEbTBrMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5k # aWdpY2VydC5jb20wQwYIKwYBBQUHMAKGN2h0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0 # LmNvbS9EaWdpQ2VydEFzc3VyZWRJRFJvb3RDQS5jcnQwRQYDVR0fBD4wPDA6oDig # NoY0aHR0cDovL2NybDMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0QXNzdXJlZElEUm9v # dENBLmNybDARBgNVHSAECjAIMAYGBFUdIAAwDQYJKoZIhvcNAQEMBQADggEBAHCg # v0NcVec4X6CjdBs9thbX979XB72arKGHLOyFXqkauyL4hxppVCLtpIh3bb0aFPQT # SnovLbc47/T/gLn4offyct4kvFIDyE7QKt76LVbP+fT3rDB6mouyXtTP0UNEm0Mh # 65ZyoUi0mcudT6cGAxN3J0TU53/oWajwvy8LpunyNDzs9wPHh6jSTEAZNUZqaVSw # uKFWjuyk1T3osdz9HNj0d1pcVIxv76FQPfx2CWiEn2/K2yCNNWAcAgPLILCsWKAO # QGPFmCLBsln1VWvPJ6tsds5vIy30fnFqI2si/xK4VC0nftg62fC2h5b9W9FcrBjD # TZ9ztwGpn1eqXijiuZQwggYaMIIEAqADAgECAhBiHW0MUgGeO5B5FSCJIRwKMA0G # CSqGSIb3DQEBDAUAMFYxCzAJBgNVBAYTAkdCMRgwFgYDVQQKEw9TZWN0aWdvIExp # bWl0ZWQxLTArBgNVBAMTJFNlY3RpZ28gUHVibGljIENvZGUgU2lnbmluZyBSb290 # IFI0NjAeFw0yMTAzMjIwMDAwMDBaFw0zNjAzMjEyMzU5NTlaMFQxCzAJBgNVBAYT # AkdCMRgwFgYDVQQKEw9TZWN0aWdvIExpbWl0ZWQxKzApBgNVBAMTIlNlY3RpZ28g # UHVibGljIENvZGUgU2lnbmluZyBDQSBSMzYwggGiMA0GCSqGSIb3DQEBAQUAA4IB # jwAwggGKAoIBgQCbK51T+jU/jmAGQ2rAz/V/9shTUxjIztNsfvxYB5UXeWUzCxEe # AEZGbEN4QMgCsJLZUKhWThj/yPqy0iSZhXkZ6Pg2A2NVDgFigOMYzB2OKhdqfWGV # oYW3haT29PSTahYkwmMv0b/83nbeECbiMXhSOtbam+/36F09fy1tsB8je/RV0mIk # 8XL/tfCK6cPuYHE215wzrK0h1SWHTxPbPuYkRdkP05ZwmRmTnAO5/arnY83jeNzh # P06ShdnRqtZlV59+8yv+KIhE5ILMqgOZYAENHNX9SJDm+qxp4VqpB3MV/h53yl41 # aHU5pledi9lCBbH9JeIkNFICiVHNkRmq4TpxtwfvjsUedyz8rNyfQJy/aOs5b4s+ # ac7IH60B+Ja7TVM+EKv1WuTGwcLmoU3FpOFMbmPj8pz44MPZ1f9+YEQIQty/NQd/ # 2yGgW+ufflcZ/ZE9o1M7a5Jnqf2i2/uMSWymR8r2oQBMdlyh2n5HirY4jKnFH/9g # Rvd+QOfdRrJZb1sCAwEAAaOCAWQwggFgMB8GA1UdIwQYMBaAFDLrkpr/NZZILyhA # QnAgNpFcF4XmMB0GA1UdDgQWBBQPKssghyi47G9IritUpimqF6TNDDAOBgNVHQ8B # Af8EBAMCAYYwEgYDVR0TAQH/BAgwBgEB/wIBADATBgNVHSUEDDAKBggrBgEFBQcD # AzAbBgNVHSAEFDASMAYGBFUdIAAwCAYGZ4EMAQQBMEsGA1UdHwREMEIwQKA+oDyG # Omh0dHA6Ly9jcmwuc2VjdGlnby5jb20vU2VjdGlnb1B1YmxpY0NvZGVTaWduaW5n # Um9vdFI0Ni5jcmwwewYIKwYBBQUHAQEEbzBtMEYGCCsGAQUFBzAChjpodHRwOi8v # Y3J0LnNlY3RpZ28uY29tL1NlY3RpZ29QdWJsaWNDb2RlU2lnbmluZ1Jvb3RSNDYu # cDdjMCMGCCsGAQUFBzABhhdodHRwOi8vb2NzcC5zZWN0aWdvLmNvbTANBgkqhkiG # 9w0BAQwFAAOCAgEABv+C4XdjNm57oRUgmxP/BP6YdURhw1aVcdGRP4Wh60BAscjW # 4HL9hcpkOTz5jUug2oeunbYAowbFC2AKK+cMcXIBD0ZdOaWTsyNyBBsMLHqafvIh # rCymlaS98+QpoBCyKppP0OcxYEdU0hpsaqBBIZOtBajjcw5+w/KeFvPYfLF/ldYp # mlG+vd0xqlqd099iChnyIMvY5HexjO2AmtsbpVn0OhNcWbWDRF/3sBp6fWXhz7Dc # ML4iTAWS+MVXeNLj1lJziVKEoroGs9Mlizg0bUMbOalOhOfCipnx8CaLZeVme5yE # Lg09Jlo8BMe80jO37PU8ejfkP9/uPak7VLwELKxAMcJszkyeiaerlphwoKx1uHRz # NyE6bxuSKcutisqmKL5OTunAvtONEoteSiabkPVSZ2z76mKnzAfZxCl/3dq3dUNw # 4rg3sTCggkHSRqTqlLMS7gjrhTqBmzu1L90Y1KWN/Y5JKdGvspbOrTfOXyXvmPL6 # E52z1NZJ6ctuMFBQZH3pwWvqURR8AgQdULUvrxjUYbHHj95Ejza63zdrEcxWLDX6 # xWls/GDnVNueKjWUH3fTv1Y8Wdho698YADR7TNx8X8z2Bev6SivBBOHY+uqiirZt # g0y9ShQoPzmCcn63Syatatvx157YK9hlcPmVoa1oDE5/L9Uo2bC5a4CH2RwwggY+ # MIIEpqADAgECAhAHnODk0RR/hc05c892LTfrMA0GCSqGSIb3DQEBDAUAMFQxCzAJ # BgNVBAYTAkdCMRgwFgYDVQQKEw9TZWN0aWdvIExpbWl0ZWQxKzApBgNVBAMTIlNl # Y3RpZ28gUHVibGljIENvZGUgU2lnbmluZyBDQSBSMzYwHhcNMjYwMjA5MDAwMDAw # WhcNMjkwNDIxMjM1OTU5WjBVMQswCQYDVQQGEwJVUzEUMBIGA1UECAwLQ29ubmVj # dGljdXQxFzAVBgNVBAoMDkphc29uIEFsYmVyaW5vMRcwFQYDVQQDDA5KYXNvbiBB # bGJlcmlubzCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAPN6aN4B1yYW # kI5b5TBj3I0VV/peETrHb6EY4BHGxt8Ap+eT+WpEpJyEtRYPxEmNJL3A38Bkg7mw # zPE3/1NK570ZBCuBjSAn4mSDIgIuXZnvyBO9W1OQs5d67MlJLUAEufl18tOr3ST1 # DeO9gSjQSAE5Nql0QDxPnm93OZBon+Fz3CmE+z3MwAe2h4KdtRAnCqwM+/V7iBdb # w+JOxolpx+7RVjGyProTENIG3pe/hKvPb501lf8uBAADLdjZr5ip8vIWbf857Yw1 # Bu10nVI7HW3eE8Cl5//d1ribHlzTzQLfttW+k+DaFsKZBBL56l4YAlIVRsrOiE1k # dHYYx6IGrEA809R7+TZA9DzGqyFiv9qmJAbL4fDwetDeyIq+Oztz1LvEdy8Rcd0J # BY+J4S0eDEFIA3X0N8VcLeAwabKb9AjulKXwUeqCJLvN79CJ90UTZb2+I+tamj0d # n+IKMEsJ4v4Ggx72sxFr9+6XziodtTg5Luf2xd6+PhhamOxF2px9LObhBLLEMyRs # CHZIzVZOFKu9BpHQH7ufGB+Sa80Tli0/6LEyn9+bMYWi2ttn6lLOPThXMiQaooRU # q6q2u3+F4SaPlxVFLI7OJVMhar6nW6joBvELTJPmANSMjDSRFDfHRCdGbZsL/keE # LJNy+jZctF6VvxQEjFM8/bazu6qYhrA7AgMBAAGjggGJMIIBhTAfBgNVHSMEGDAW # gBQPKssghyi47G9IritUpimqF6TNDDAdBgNVHQ4EFgQU6YF0o0D5AVhKHbVocr8G # aSIBibAwDgYDVR0PAQH/BAQDAgeAMAwGA1UdEwEB/wQCMAAwEwYDVR0lBAwwCgYI # KwYBBQUHAwMwSgYDVR0gBEMwQTA1BgwrBgEEAbIxAQIBAwIwJTAjBggrBgEFBQcC # ARYXaHR0cHM6Ly9zZWN0aWdvLmNvbS9DUFMwCAYGZ4EMAQQBMEkGA1UdHwRCMEAw # PqA8oDqGOGh0dHA6Ly9jcmwuc2VjdGlnby5jb20vU2VjdGlnb1B1YmxpY0NvZGVT # aWduaW5nQ0FSMzYuY3JsMHkGCCsGAQUFBwEBBG0wazBEBggrBgEFBQcwAoY4aHR0 # cDovL2NydC5zZWN0aWdvLmNvbS9TZWN0aWdvUHVibGljQ29kZVNpZ25pbmdDQVIz # Ni5jcnQwIwYIKwYBBQUHMAGGF2h0dHA6Ly9vY3NwLnNlY3RpZ28uY29tMA0GCSqG # SIb3DQEBDAUAA4IBgQAEIsm4xnOd/tZMVrKwi3doAXvCwOA/RYQnFJD7R/bSQRu3 # wXEK4o9SIefye18B/q4fhBkhNAJuEvTQAGfqbbpxow03J5PrDTp1WPCWbXKX8Oz9 # vGWJFyJxRGftkdzZ57JE00synEMS8XCwLO9P32MyR9Z9URrpiLPJ9rQjfHMb1BUd # vaNayomm7aWLAnD+X7jm6o8sNT5An1cwEAob7obWDM6sX93wphwJNBJAstH9Ozs6 # LwISOX6sKS7CKm9N3Kp8hOUue0ZHAtZdFl6o5u12wy+zzieGEI50fKnN77FfNKFO # WKlS6OJwlArcbFegB5K89LcE5iNSmaM3VMB2ADV1FEcjGSHw4lTg1Wx+WMAMdl/7 # nbvfFxJ9uu5tNiT54B0s+lZO/HztwXYQUczdsFon3pjsNrsk9ZlalBi5SHkIu+F6 # g7tWiEv3rtVApmJRnLkUr2Xq2a4nbslUCt4jKs5UX4V1nSX8OM++AXoyVGO+iTj7 # z+pl6XE9Gw/Td6WKKKswgga0MIIEnKADAgECAhANx6xXBf8hmS5AQyIMOkmGMA0G # CSqGSIb3DQEBCwUAMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJ # bmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0 # IFRydXN0ZWQgUm9vdCBHNDAeFw0yNTA1MDcwMDAwMDBaFw0zODAxMTQyMzU5NTla # MGkxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjFBMD8GA1UE # AxM4RGlnaUNlcnQgVHJ1c3RlZCBHNCBUaW1lU3RhbXBpbmcgUlNBNDA5NiBTSEEy # NTYgMjAyNSBDQTEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC0eDHT # CphBcr48RsAcrHXbo0ZodLRRF51NrY0NlLWZloMsVO1DahGPNRcybEKq+RuwOnPh # of6pvF4uGjwjqNjfEvUi6wuim5bap+0lgloM2zX4kftn5B1IpYzTqpyFQ/4Bt0mA # xAHeHYNnQxqXmRinvuNgxVBdJkf77S2uPoCj7GH8BLuxBG5AvftBdsOECS1UkxBv # MgEdgkFiDNYiOTx4OtiFcMSkqTtF2hfQz3zQSku2Ws3IfDReb6e3mmdglTcaarps # 0wjUjsZvkgFkriK9tUKJm/s80FiocSk1VYLZlDwFt+cVFBURJg6zMUjZa/zbCclF # 83bRVFLeGkuAhHiGPMvSGmhgaTzVyhYn4p0+8y9oHRaQT/aofEnS5xLrfxnGpTXi # UOeSLsJygoLPp66bkDX1ZlAeSpQl92QOMeRxykvq6gbylsXQskBBBnGy3tW/AMOM # CZIVNSaz7BX8VtYGqLt9MmeOreGPRdtBx3yGOP+rx3rKWDEJlIqLXvJWnY0v5ydP # pOjL6s36czwzsucuoKs7Yk/ehb//Wx+5kMqIMRvUBDx6z1ev+7psNOdgJMoiwOrU # G2ZdSoQbU2rMkpLiQ6bGRinZbI4OLu9BMIFm1UUl9VnePs6BaaeEWvjJSjNm2qA+ # sdFUeEY0qVjPKOWug/G6X5uAiynM7Bu2ayBjUwIDAQABo4IBXTCCAVkwEgYDVR0T # AQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQU729TSunkBnx6yuKQVvYv1Ensy04wHwYD # VR0jBBgwFoAU7NfjgtJxXWRM3y5nP+e6mK4cD08wDgYDVR0PAQH/BAQDAgGGMBMG # A1UdJQQMMAoGCCsGAQUFBwMIMHcGCCsGAQUFBwEBBGswaTAkBggrBgEFBQcwAYYY # aHR0cDovL29jc3AuZGlnaWNlcnQuY29tMEEGCCsGAQUFBzAChjVodHRwOi8vY2Fj # ZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkUm9vdEc0LmNydDBDBgNV # HR8EPDA6MDigNqA0hjJodHRwOi8vY3JsMy5kaWdpY2VydC5jb20vRGlnaUNlcnRU # cnVzdGVkUm9vdEc0LmNybDAgBgNVHSAEGTAXMAgGBmeBDAEEAjALBglghkgBhv1s # BwEwDQYJKoZIhvcNAQELBQADggIBABfO+xaAHP4HPRF2cTC9vgvItTSmf83Qh8WI # GjB/T8ObXAZz8OjuhUxjaaFdleMM0lBryPTQM2qEJPe36zwbSI/mS83afsl3YTj+ # IQhQE7jU/kXjjytJgnn0hvrV6hqWGd3rLAUt6vJy9lMDPjTLxLgXf9r5nWMQwr8M # yb9rEVKChHyfpzee5kH0F8HABBgr0UdqirZ7bowe9Vj2AIMD8liyrukZ2iA/wdG2 # th9y1IsA0QF8dTXqvcnTmpfeQh35k5zOCPmSNq1UH410ANVko43+Cdmu4y81hjaj # V/gxdEkMx1NKU4uHQcKfZxAvBAKqMVuqte69M9J6A47OvgRaPs+2ykgcGV00TYr2 # Lr3ty9qIijanrUR3anzEwlvzZiiyfTPjLbnFRsjsYg39OlV8cipDoq7+qNNjqFze # GxcytL5TTLL4ZaoBdqbhOhZ3ZRDUphPvSRmMThi0vw9vODRzW6AxnJll38F0cuJG # 7uEBYTptMSbhdhGQDpOXgpIUsWTjd6xpR6oaQf/DJbg3s6KCLPAlZ66RzIg9sC+N # Jpud/v4+7RWsWCiKi9EOLLHfMR2ZyJ/+xhCx9yHbxtl5TPau1j/1MIDpMPx0LckT # etiSuEtQvLsNz3Qbp7wGWqbIiOWCnb5WqxL3/BAPvIXKUjPSxyZsq8WhbaM2tszW # kPZPubdcMIIG7TCCBNWgAwIBAgIQCoDvGEuN8QWC0cR2p5V0aDANBgkqhkiG9w0B # AQsFADBpMQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4xQTA/ # BgNVBAMTOERpZ2lDZXJ0IFRydXN0ZWQgRzQgVGltZVN0YW1waW5nIFJTQTQwOTYg # U0hBMjU2IDIwMjUgQ0ExMB4XDTI1MDYwNDAwMDAwMFoXDTM2MDkwMzIzNTk1OVow # YzELMAkGA1UEBhMCVVMxFzAVBgNVBAoTDkRpZ2lDZXJ0LCBJbmMuMTswOQYDVQQD # EzJEaWdpQ2VydCBTSEEyNTYgUlNBNDA5NiBUaW1lc3RhbXAgUmVzcG9uZGVyIDIw # MjUgMTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANBGrC0Sxp7Q6q5g # VrMrV7pvUf+GcAoB38o3zBlCMGMyqJnfFNZx+wvA69HFTBdwbHwBSOeLpvPnZ8ZN # +vo8dE2/pPvOx/Vj8TchTySA2R4QKpVD7dvNZh6wW2R6kSu9RJt/4QhguSssp3qo # me7MrxVyfQO9sMx6ZAWjFDYOzDi8SOhPUWlLnh00Cll8pjrUcCV3K3E0zz09ldQ/ # /nBZZREr4h/GI6Dxb2UoyrN0ijtUDVHRXdmncOOMA3CoB/iUSROUINDT98oksouT # MYFOnHoRh6+86Ltc5zjPKHW5KqCvpSduSwhwUmotuQhcg9tw2YD3w6ySSSu+3qU8 # DD+nigNJFmt6LAHvH3KSuNLoZLc1Hf2JNMVL4Q1OpbybpMe46YceNA0LfNsnqcnp # JeItK/DhKbPxTTuGoX7wJNdoRORVbPR1VVnDuSeHVZlc4seAO+6d2sC26/PQPdP5 # 1ho1zBp+xUIZkpSFA8vWdoUoHLWnqWU3dCCyFG1roSrgHjSHlq8xymLnjCbSLZ49 # kPmk8iyyizNDIXj//cOgrY7rlRyTlaCCfw7aSUROwnu7zER6EaJ+AliL7ojTdS5P # WPsWeupWs7NpChUk555K096V1hE0yZIXe+giAwW00aHzrDchIc2bQhpp0IoKRR7Y # ufAkprxMiXAJQ1XCmnCfgPf8+3mnAgMBAAGjggGVMIIBkTAMBgNVHRMBAf8EAjAA # MB0GA1UdDgQWBBTkO/zyMe39/dfzkXFjGVBDz2GM6DAfBgNVHSMEGDAWgBTvb1NK # 6eQGfHrK4pBW9i/USezLTjAOBgNVHQ8BAf8EBAMCB4AwFgYDVR0lAQH/BAwwCgYI # KwYBBQUHAwgwgZUGCCsGAQUFBwEBBIGIMIGFMCQGCCsGAQUFBzABhhhodHRwOi8v # b2NzcC5kaWdpY2VydC5jb20wXQYIKwYBBQUHMAKGUWh0dHA6Ly9jYWNlcnRzLmRp # Z2ljZXJ0LmNvbS9EaWdpQ2VydFRydXN0ZWRHNFRpbWVTdGFtcGluZ1JTQTQwOTZT # SEEyNTYyMDI1Q0ExLmNydDBfBgNVHR8EWDBWMFSgUqBQhk5odHRwOi8vY3JsMy5k # aWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkRzRUaW1lU3RhbXBpbmdSU0E0MDk2 # U0hBMjU2MjAyNUNBMS5jcmwwIAYDVR0gBBkwFzAIBgZngQwBBAIwCwYJYIZIAYb9 # bAcBMA0GCSqGSIb3DQEBCwUAA4ICAQBlKq3xHCcEua5gQezRCESeY0ByIfjk9iJP # 2zWLpQq1b4URGnwWBdEZD9gBq9fNaNmFj6Eh8/YmRDfxT7C0k8FUFqNh+tshgb4O # 6Lgjg8K8elC4+oWCqnU/ML9lFfim8/9yJmZSe2F8AQ/UdKFOtj7YMTmqPO9mzskg # iC3QYIUP2S3HQvHG1FDu+WUqW4daIqToXFE/JQ/EABgfZXLWU0ziTN6R3ygQBHMU # BaB5bdrPbF6MRYs03h4obEMnxYOX8VBRKe1uNnzQVTeLni2nHkX/QqvXnNb+YkDF # kxUGtMTaiLR9wjxUxu2hECZpqyU1d0IbX6Wq8/gVutDojBIFeRlqAcuEVT0cKsb+ # zJNEsuEB7O7/cuvTQasnM9AWcIQfVjnzrvwiCZ85EE8LUkqRhoS3Y50OHgaY7T/l # wd6UArb+BOVAkg2oOvol/DJgddJ35XTxfUlQ+8Hggt8l2Yv7roancJIFcbojBcxl # RcGG0LIhp6GvReQGgMgYxQbV1S3CrWqZzBt1R9xJgKf47CdxVRd/ndUlQ05oxYy2 # zRWVFjF7mcr4C34Mj3ocCVccAvlKV9jEnstrniLvUxxVZE/rptb7IRE2lskKPIJg # baP5t2nGj/ULLi49xTcBZU8atufk+EMF/cWuiC7POGT75qaL6vdCvHlshtjdNXOC # IUjsarfNZzGCBkQwggZAAgEBMGgwVDELMAkGA1UEBhMCR0IxGDAWBgNVBAoTD1Nl # Y3RpZ28gTGltaXRlZDErMCkGA1UEAxMiU2VjdGlnbyBQdWJsaWMgQ29kZSBTaWdu # aW5nIENBIFIzNgIQB5zg5NEUf4XNOXPPdi036zANBglghkgBZQMEAgEFAKCBhDAY # BgorBgEEAYI3AgEMMQowCKACgAChAoAAMBkGCSqGSIb3DQEJAzEMBgorBgEEAYI3 # AgEEMBwGCisGAQQBgjcCAQsxDjAMBgorBgEEAYI3AgEVMC8GCSqGSIb3DQEJBDEi # BCCPr+LYUnSwXfgWCxf5A+1Gdk/MOgzKt++2lu8S+oHyaDANBgkqhkiG9w0BAQEF # AASCAgBE8dXFnqNrz8mRbtPV3l1p0UU3hQc/JX7zt3kmhpQQQVIwfnQ5qA1+bBt/ # DVgorlGzQjCNm434yU6uqpINr9xjBsYHC//Uvb9RiPxaFYJKNjpjztqV+9M9N5tV # LvaJSMq7g0TJzIOYLfqHxMj/yGDscZjwFDp+GQBxsAtZXh1EGxqtjWvcx4PkMVH4 # 8t/QZVwoo4n6kcf10jzTHPDJ1O62GY1t2que/9gwhIfuUvVF8VoyTJLKlIS4p14n # /fIiUgzmAblhDeXqrdNg5zjgOzJx61wF0hgqySpN5QVkLoOnoNvSpxcw66c/8jiS # dlwDlNZnhQmUqE5pLxnS4v+WB48y6d4HsR9BldpnZoECJKWUR9xOgX5ar6ZNrimx # 8ZbtuJjomrMZXoaoD+LYr/FPM7wSPkL1zaetqP8qvgcZZJLDXHlSG43+crsNYcJy # ZjEWEqiFvc3mw+BhQwBsDdKnmDSLIl6aY8gs0LsusCh8QuCDkvW2+Y8dpSt99oDj # VhGluTrL1moUPfxX18j8Wu6SwS+Gsd18h30NZymnPVKEKL0N9IWQ3y1O7tkcJtjB # T73AOm+HCFC9nmHuVnujIka0ib1FNrydTRwyxx8iPHFZ4T94+xqGWd4hO6lRxRM0 # zFaxe/xr/CkpsPXcZgGeKMwFIk1olteSAcK05axb0AN4RCyj0aGCAyYwggMiBgkq # hkiG9w0BCQYxggMTMIIDDwIBATB9MGkxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5E # aWdpQ2VydCwgSW5jLjFBMD8GA1UEAxM4RGlnaUNlcnQgVHJ1c3RlZCBHNCBUaW1l # U3RhbXBpbmcgUlNBNDA5NiBTSEEyNTYgMjAyNSBDQTECEAqA7xhLjfEFgtHEdqeV # dGgwDQYJYIZIAWUDBAIBBQCgaTAYBgkqhkiG9w0BCQMxCwYJKoZIhvcNAQcBMBwG # CSqGSIb3DQEJBTEPFw0yNjA0MjYxNDQyMzZaMC8GCSqGSIb3DQEJBDEiBCBkQsGW # /FMspp4O9nmsTCzC7JVcqAu4cnUGFxYHnzFxvzANBgkqhkiG9w0BAQEFAASCAgCC # rrLfoCjwg+prIaAa3z6iY9FspNy5sisugaE1BWua/Wz/91iOnTl+vwPS+RRAiUKv # +oz2uANlncMu02ZZi2FIb404pQaokYV9RxJsHs35MGNs9Flya635SS9rcCu8T18t # Mwrzk1x89S1tH8ZgFivh2d7V+/rd+rVoqAtj4i758GLdSKcYEAO7Ctz12bG69r3/ # dlLne4gj0oXWC5PzwX+vyoRmp4Cb+0xR+ofLD/B2zTIfKcWZyF78zd7O9k/1jxBl # at/HjgYzq6bPsIAPpOa1+i+zOzvngaRKUzeqkV02mOtPzzdMSsLt8XaWLmCF3PN8 # v4SKfPVQ/XMAzEmL9toFdKATKPpUKUJKOilwdKffRnCgVw5hHhhYA7iLSqWY4Y9T # 4zrgNLPP1UfYVid/PfD5qEgjIocRWYuznGvhOiovsEgrhMCsD2jkTPWeJwTYqfFX # nA4VE7e7GrCIWaikLjk4EhhFr12T2P0wagg+Hvvc6BgpA4ngkcDCzG/Kf7Mt1gqI # oKGHx3L+vezzVOZ6eGLOa6a5dx3g5+zhr57ZgrHdS6oZf2Opo6so0MB9i6tRw0lu # WlW/cXYup1aG0v3fLcWY25rAHEvs/3IqvQPbqejk/ku5+1DM3oYtw3UytJaCjal4 # YnA/PaxSnGJ9NTA9wyfyjx/8gChydVw/f3RTyKDuEQ== # SIG # End signature block |