helpers/geolocation/GeolocationHelpers.ps1
|
# ============================================================================= # Geolocation Helpers for WhatsUpGoldPS # Queries the WhatsUp Gold REST API for devices/groups with latitude/longitude # data and generates a Leaflet-based interactive HTML map. # # Devices : lat/lng stored in a device attribute named "LatLong" (format: "lat,lng") # OR built-in attributes "Latitude" and "Longitude" # Groups : lat/lng stored in the group description/note field (format: "lat,lng") # # Reference: https://github.com/jayyx2/wug-geolocation # ============================================================================= # --- Internal: SSL bypass for self-signed certs --- function Initialize-GeoSSLBypass { if ($PSVersionTable.PSEdition -eq 'Core') { $PSDefaultParameterValues["Invoke-RestMethod:SkipCertificateCheck"] = $true $PSDefaultParameterValues["Invoke-WebRequest:SkipCertificateCheck"] = $true } else { if (-not ([System.Management.Automation.PSTypeName]'GeoSSLValidator').Type) { Add-Type -TypeDefinition @" using System.Net; using System.Net.Security; using System.Security.Cryptography.X509Certificates; public static class GeoSSLValidator { private static bool OnValidateCertificate( object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors sslPolicyErrors) { return true; } public static void OverrideValidation() { ServicePointManager.ServerCertificateValidationCallback = new RemoteCertificateValidationCallback(OnValidateCertificate); ServicePointManager.Expect100Continue = false; ServicePointManager.DefaultConnectionLimit = 64; ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12; } } "@ } [GeoSSLValidator]::OverrideValidation() } Write-Warning "Ignoring SSL certificate validation errors. Use this option with caution." } # --- Internal: REST wrapper that handles token refresh --- function Invoke-GeoAPI { <# .SYNOPSIS Sends a request to the WhatsUp Gold REST API with automatic token refresh. .DESCRIPTION When the WhatsUpGoldPS module is loaded and connected (global WUG bearer headers exist), calls are routed through Get-WUGAPIResponse for unified logging, token management, and integrity. When the module is not available (e.g. standalone scheduled-task execution), the function falls back to direct Invoke-RestMethod with its own token refresh logic. .PARAMETER Config The configuration hashtable (from Import-GeolocationConfig). .PARAMETER Endpoint The API path (e.g. /api/v1/devices/-). .PARAMETER Method HTTP method. Defaults to GET. .PARAMETER Body Optional string body for POST/PUT requests. #> param( [Parameter(Mandatory)][hashtable]$Config, [Parameter(Mandatory)][string]$Endpoint, [string]$Method = 'GET', [string]$Body ) $uri = "$($Config.BaseUri)$Endpoint" # --- Prefer WhatsUpGoldPS module when loaded --- if ($global:WUGBearerHeaders -and $global:WhatsUpServerBaseURI -and (Get-Command 'Get-WUGAPIResponse' -ErrorAction SilentlyContinue)) { try { Write-Verbose "Invoke-GeoAPI: routing through Get-WUGAPIResponse -> $Method $Endpoint" $moduleParams = @{ Uri = $uri; Method = $Method } if ($Body) { $moduleParams.Body = $Body } return (Get-WUGAPIResponse @moduleParams) } catch { Write-Verbose "Get-WUGAPIResponse failed ($($_.Exception.Message)), falling back to direct REST." } } # --- Fallback: direct Invoke-RestMethod with own token refresh --- # Check if token needs refreshing (within 5 minutes of expiry) if ((Get-Date) -ge $Config._Expiry.AddMinutes(-5)) { Write-Verbose "Token expired or expiring soon. Refreshing..." $refreshBody = "grant_type=refresh_token&refresh_token=$($Config._RefreshToken)" $refreshHeaders = @{ "Content-Type" = "application/json" } try { $newToken = Invoke-RestMethod -Uri "$($Config.BaseUri)/api/v1/token" ` -Method Post -Headers $refreshHeaders -Body $refreshBody -ErrorAction Stop $Config._AccessToken = $newToken.access_token $Config._RefreshToken = $newToken.refresh_token $Config._TokenType = $newToken.token_type $Config._Expiry = (Get-Date).AddSeconds($newToken.expires_in) Write-Verbose "Token refreshed. Expires at $($Config._Expiry)." } catch { throw "Failed to refresh WUG auth token: $($_.Exception.Message)" } } $headers = @{ "Content-Type" = "application/json" "Authorization" = "$($Config._TokenType) $($Config._AccessToken)" } $params = @{ Uri = $uri; Method = $Method; Headers = $headers; ErrorAction = 'Stop' } if ($Body) { $params.Body = $Body } $maxRetries = if ($PSVersionTable.PSEdition -eq 'Core') { 0 } else { 2 } for ($attempt = 0; $attempt -le $maxRetries; $attempt++) { try { return (Invoke-RestMethod @params) } catch { $isClosed = $_.Exception.Message -match 'underlying connection was closed|unexpected error occurred on a send' if ($isClosed -and $attempt -lt $maxRetries) { try { $sp = [System.Net.ServicePointManager]::FindServicePoint([System.Uri]$uri) $sp.CloseConnectionGroup('') } catch {} Start-Sleep -Milliseconds (300 * ($attempt + 1)) } else { throw } } } } # --- Connect to the WUG server and get initial auth token --- function Connect-GeoWUGServer { <# .SYNOPSIS Authenticates to a WhatsUp Gold server and returns a config hashtable. .DESCRIPTION Obtains an OAuth 2.0 token using password grant type from the WUG REST API. Returns a hashtable containing the base URI, tokens, and expiry for use with other Geolocation helper functions. .PARAMETER ServerUri The hostname or IP of the WhatsUp Gold server. .PARAMETER Username The username for authentication. .PARAMETER Password The password for authentication. .PARAMETER Protocol http or https (default: https). .PARAMETER Port The WUG API port (default: 9644). .PARAMETER IgnoreSSLErrors Bypass SSL certificate validation. .OUTPUTS Hashtable with keys: BaseUri, _AccessToken, _RefreshToken, _TokenType, _Expiry .EXAMPLE $config = Connect-GeoWUGServer -ServerUri "wug.example.com" -Username "admin" -Password "pass" #> param( [Parameter(Mandatory)][string]$ServerUri, [Parameter(Mandatory)][string]$Username, [Parameter(Mandatory)][string]$Password, [ValidateSet("http","https")][string]$Protocol = "https", [ValidateRange(1,65535)][int]$Port = 9644, [switch]$IgnoreSSLErrors ) if ($IgnoreSSLErrors) { Initialize-GeoSSLBypass } $baseUri = "${Protocol}://${ServerUri}:${Port}" $tokenUri = "$baseUri/api/v1/token" $headers = @{ "Content-Type" = "application/json" } $body = "grant_type=password&username=${Username}&password=${Password}" try { $token = Invoke-RestMethod -Uri $tokenUri -Method Post -Headers $headers -Body $body -ErrorAction Stop } catch { throw "Failed to authenticate to ${baseUri}: $($_.Exception.Message)" } if (-not $token.access_token -or -not $token.refresh_token) { throw "Token response missing required fields." } $config = @{ BaseUri = $baseUri ServerUri = $ServerUri Protocol = $Protocol Port = $Port IgnoreSSL = [bool]$IgnoreSSLErrors _AccessToken = $token.access_token _RefreshToken = $token.refresh_token _TokenType = $token.token_type _Expiry = (Get-Date).AddSeconds($token.expires_in) } Write-Verbose "Connected to ${ServerUri}. Token expires at $($config._Expiry)." return $config } # --- Get devices with LatLong attribute --- function Get-GeoDevicesWithLocation { <# .SYNOPSIS Retrieves devices that have geolocation data (LatLong attribute). .DESCRIPTION Queries all devices in WUG, then fetches their attributes looking for "LatLong" (single attribute with "lat,lng") or built-in "Latitude"/"Longitude". Returns an array of objects with device info and parsed coordinates. Uses a JSON location cache to avoid re-fetching coordinates for devices whose locations haven't changed (coordinates are static; status comes from the overview endpoint and is always fresh). New or uncached devices are fetched with a PS 5.1-compatible runspace pool for parallelism. .PARAMETER Config The configuration hashtable from Connect-GeoWUGServer. .PARAMETER GroupName Optional device group name to filter devices. Default is all devices. .PARAMETER UseBuiltinCoords If set, uses separate "Latitude" and "Longitude" attributes instead of "LatLong". .PARAMETER RefreshCache Force re-fetch all device coordinates from the API, ignoring the cache. .OUTPUTS Array of PSCustomObject: Name, DeviceId, Latitude, Longitude, State, Status #> param( [Parameter(Mandatory)][hashtable]$Config, [string]$GroupName, [switch]$UseBuiltinCoords, [switch]$RefreshCache ) $devices = @() # Get device list - if group specified, get from group; otherwise get all if ($GroupName -and $GroupName -ne 'All') { # Find the group $groupResult = Invoke-GeoAPI -Config $Config -Endpoint "/api/v1/device-groups/-?search=$([uri]::EscapeDataString($GroupName))&limit=250" $groupId = $null if ($groupResult.data.groups) { foreach ($g in $groupResult.data.groups) { if ($g.name -eq $GroupName) { $groupId = $g.id; break } } } if (-not $groupId) { Write-Warning "Device group '$GroupName' not found. Falling back to all devices." } else { $deviceResult = Invoke-GeoAPI -Config $Config -Endpoint "/api/v1/device-groups/$groupId/devices/-?view=overview&limit=250" if ($deviceResult.data.devices) { $devices = $deviceResult.data.devices } } } if ($devices.Count -eq 0 -and (-not $GroupName -or $GroupName -eq 'All' -or -not $groupId)) { # Get all devices from root group (id=0) with overview view for inline status $pageId = $null do { $endpoint = "/api/v1/device-groups/0/devices/-?view=overview&limit=250" if ($pageId) { $endpoint += "&pageId=$pageId" } $result = Invoke-GeoAPI -Config $Config -Endpoint $endpoint if ($result.data.devices) { $devices += $result.data.devices } $pageId = $result.paging.nextPageId } while ($pageId) } if ($devices.Count -eq 0) { Write-Warning "No devices found." return @() } Write-Verbose "Found $($devices.Count) devices. Checking for location attributes..." # --- Location cache: device coordinates rarely change --- $cacheDir = Join-Path $env:LOCALAPPDATA 'WhatsUpGoldPS\DiscoveryHelpers\Cache' $cacheFile = Join-Path $cacheDir 'geolocation-coords.json' $noCoordsFile = Join-Path $cacheDir 'geolocation-no-coords.json' $cache = @{} $noCoordsCache = @{} if (-not $RefreshCache -and (Test-Path $cacheFile)) { try { $cacheRaw = Get-Content -Path $cacheFile -Raw -Encoding UTF8 | ConvertFrom-Json foreach ($entry in $cacheRaw) { $cache["$($entry.Id)"] = @{ Lat = [double]$entry.Lat; Lng = [double]$entry.Lng } } Write-Verbose "Loaded $($cache.Count) cached device coordinates." } catch { Write-Verbose "Cache read failed, will re-fetch all: $_" } } if (-not $RefreshCache -and (Test-Path $noCoordsFile)) { try { $noCoordsRaw = Get-Content -Path $noCoordsFile -Raw -Encoding UTF8 | ConvertFrom-Json foreach ($id in $noCoordsRaw) { $noCoordsCache["$id"] = $true } Write-Verbose "Loaded $($noCoordsCache.Count) negative-cached device IDs (no coordinates)." } catch { Write-Verbose "Negative cache read failed: $_" } } # Separate devices into cached (coords known), negative-cached (no coords), vs uncached $cachedDevices = @() $uncachedDevices = @() $skippedNoCoords = 0 foreach ($device in $devices) { $deviceId = if ($device.id) { $device.id } else { $device.deviceId } if (-not $deviceId) { continue } if ($cache.ContainsKey("$deviceId")) { $cachedDevices += $device } elseif ($noCoordsCache.ContainsKey("$deviceId")) { $skippedNoCoords++ } else { $uncachedDevices += $device } } Write-Verbose "Cache hit: $($cachedDevices.Count), no-coords: $skippedNoCoords, miss: $($uncachedDevices.Count)" # --- Fetch coordinates for uncached devices via runspace pool (PS 5.1 parallel) --- $fetchedCoords = @{} # deviceId -> @{Lat;Lng} if ($uncachedDevices.Count -gt 0) { $totalUncached = $uncachedDevices.Count $poolSize = [Math]::Min(8, $totalUncached) $baseUri = $Config.BaseUri $accessToken = $Config._AccessToken $tokenType = $Config._TokenType $useBuiltin = [bool]$UseBuiltinCoords $pool = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspacePool(1, $poolSize) $pool.Open() $scriptBlock = { param($BaseUri, $DeviceId, $TokenType, $AccessToken, $UseBuiltin) $headers = @{ "Content-Type" = "application/json" "Authorization" = "$TokenType $AccessToken" } $lat = $null; $lng = $null; $apiOk = $true try { if ($UseBuiltin) { $uri = "$BaseUri/api/v1/devices/$DeviceId/attributes/-?names=Latitude&names=Longitude&limit=10" $result = Invoke-RestMethod -Uri $uri -Headers $headers -TimeoutSec 15 -ErrorAction Stop if ($result.data) { foreach ($attr in $result.data) { if ($attr.name -eq 'Latitude') { $lat = [double]$attr.value } if ($attr.name -eq 'Longitude') { $lng = [double]$attr.value } } } } else { $uri = "$BaseUri/api/v1/devices/$DeviceId/attributes/-?names=LatLong&limit=10" $result = Invoke-RestMethod -Uri $uri -Headers $headers -TimeoutSec 15 -ErrorAction Stop if ($result.data) { foreach ($attr in $result.data) { if ($attr.name -eq 'LatLong' -and $attr.value) { $parts = $attr.value -split ',' if ($parts.Count -ge 2) { $lat = [double]$parts[0].Trim() $lng = [double]$parts[1].Trim() } } } } } } catch { $apiOk = $false } return @{ DeviceId = $DeviceId; Lat = $lat; Lng = $lng; ApiOk = $apiOk } } $jobs = [System.Collections.Generic.List[object]]::new() foreach ($device in $uncachedDevices) { $deviceId = if ($device.id) { $device.id } else { $device.deviceId } $ps = [System.Management.Automation.PowerShell]::Create() $ps.RunspacePool = $pool [void]$ps.AddScript($scriptBlock) [void]$ps.AddArgument($baseUri) [void]$ps.AddArgument($deviceId) [void]$ps.AddArgument($tokenType) [void]$ps.AddArgument($accessToken) [void]$ps.AddArgument($useBuiltin) $handle = $ps.BeginInvoke() $jobs.Add(@{ PS = $ps; Handle = $handle; DeviceId = $deviceId }) } $completed = 0 $apiFailedIds = @{} $completedJobs = [System.Collections.Generic.List[object]]::new() foreach ($job in $jobs) { $completed++ $pct = [Math]::Round(($completed / $totalUncached) * 100, 0) Write-Progress -Activity "Fetching device locations (uncached)" ` -Status "$completed of $totalUncached" -PercentComplete $pct if (-not $job.Handle.AsyncWaitHandle.WaitOne(20000)) { $apiFailedIds["$($job.DeviceId)"] = $true try { $job.PS.Stop() } catch { } continue } try { $resultCollection = $job.PS.EndInvoke($job.Handle) $r = if ($resultCollection -and $resultCollection.Count -gt 0) { $resultCollection[0] } else { $null } if ($r -and $null -ne $r.Lat -and $null -ne $r.Lng) { $fetchedCoords["$($r.DeviceId)"] = @{ Lat = $r.Lat; Lng = $r.Lng } } elseif ($r -and $r.ApiOk) { $noCoordsCache["$($r.DeviceId)"] = $true } else { $apiFailedIds["$($job.DeviceId)"] = $true } } catch { Write-Verbose "Runspace error for device $($job.DeviceId): $_" $apiFailedIds["$($job.DeviceId)"] = $true } $completedJobs.Add($job) } # Close pool first (kills hung runspaces), then dispose completed jobs $pool.Close() $pool.Dispose() foreach ($j in $completedJobs) { try { $j.PS.Dispose() } catch { } } Write-Progress -Activity "Fetching device locations (uncached)" -Completed Write-Verbose "Fetched $($fetchedCoords.Count) new coordinates via runspace pool." # --- Sequential retry for devices where the runspace API call failed --- if ($apiFailedIds.Count -gt 0) { Write-Verbose "Retrying $($apiFailedIds.Count) API-failed devices sequentially..." foreach ($deviceId in $apiFailedIds.Keys) { try { if ($useBuiltin) { $endpoint = "/api/v1/devices/$deviceId/attributes/-?names=Latitude&names=Longitude&limit=10" $attrResult = Invoke-GeoAPI -Config $Config -Endpoint $endpoint $fLat = $null; $fLng = $null if ($attrResult.data) { foreach ($attr in $attrResult.data) { if ($attr.name -eq 'Latitude') { $fLat = [double]$attr.value } if ($attr.name -eq 'Longitude') { $fLng = [double]$attr.value } } } if ($null -ne $fLat -and $null -ne $fLng) { $fetchedCoords["$deviceId"] = @{ Lat = $fLat; Lng = $fLng } } else { $noCoordsCache["$deviceId"] = $true } } else { $endpoint = "/api/v1/devices/$deviceId/attributes/-?names=LatLong&limit=10" $attrResult = Invoke-GeoAPI -Config $Config -Endpoint $endpoint $foundCoord = $false if ($attrResult.data) { foreach ($attr in $attrResult.data) { if ($attr.name -eq 'LatLong' -and $attr.value) { $parts = $attr.value -split ',' if ($parts.Count -ge 2) { $fetchedCoords["$deviceId"] = @{ Lat = [double]$parts[0].Trim() Lng = [double]$parts[1].Trim() } $foundCoord = $true } } } } if (-not $foundCoord) { $noCoordsCache["$deviceId"] = $true } } } catch { Write-Verbose "Sequential retry error for device ${deviceId}: $_" } } Write-Verbose "After sequential retry: $($fetchedCoords.Count) total coordinates found." } } # --- Merge cached + freshly fetched coordinates --- $allCoords = @{} foreach ($k in $cache.Keys) { $allCoords[$k] = $cache[$k] } foreach ($k in $fetchedCoords.Keys) { $allCoords[$k] = $fetchedCoords[$k] } # --- Build result objects (status from overview, coords from cache/fetch) --- $geoDevices = @() foreach ($device in $devices) { $deviceId = if ($device.id) { $device.id } else { $device.deviceId } if (-not $deviceId) { continue } $coords = $allCoords["$deviceId"] if (-not $coords) { continue } $bestState = if ($device.bestState) { $device.bestState } else { 'Unknown' } $worstState = if ($device.worstState) { $device.worstState } else { 'Unknown' } $geoDevices += [PSCustomObject]@{ Type = 'Device' Name = if ($device.name) { $device.name } elseif ($device.displayName) { $device.displayName } else { "Device $deviceId" } DeviceId = $deviceId Latitude = $coords.Lat Longitude = $coords.Lng BestState = $bestState WorstState = $worstState TotalMonitors = if ($device.totalActiveMonitors) { $device.totalActiveMonitors } else { 0 } DownMonitors = if ($device.totalActiveMonitorsDown) { $device.totalActiveMonitorsDown } else { 0 } DownMonitorDetails = if ($device.downActiveMonitors) { $device.downActiveMonitors } else { @() } } } # --- Update cache on disk --- try { if (-not (Test-Path $cacheDir)) { New-Item -ItemType Directory -Path $cacheDir -Force | Out-Null } if ($allCoords.Count -gt 0) { $cacheData = $allCoords.Keys | ForEach-Object { [PSCustomObject]@{ Id = $_; Lat = $allCoords[$_].Lat; Lng = $allCoords[$_].Lng } } $cacheData | ConvertTo-Json -Depth 3 | Set-Content -Path $cacheFile -Encoding UTF8 -Force Write-Verbose "Saved $($allCoords.Count) coordinates to cache." } if ($noCoordsCache.Count -gt 0) { @($noCoordsCache.Keys) | ConvertTo-Json -Depth 1 | Set-Content -Path $noCoordsFile -Encoding UTF8 -Force Write-Verbose "Saved $($noCoordsCache.Count) negative-cached device IDs." } } catch { Write-Verbose "Failed to write location cache: $_" } Write-Progress -Activity "Fetching device locations" -Completed Write-Verbose "Found $($geoDevices.Count) devices with location data." return $geoDevices } # --- Get groups with lat/lng in description/note --- function Get-GeoGroupsWithLocation { <# .SYNOPSIS Retrieves device groups that have geolocation data in their description. .DESCRIPTION Scans all device groups and parses their description for a "lat,lng" pattern. If the description field contains a valid coordinate pair, the group is included. .PARAMETER Config The configuration hashtable from Connect-GeoWUGServer. .OUTPUTS Array of PSCustomObject: Name, GroupId, Latitude, Longitude, State #> param( [Parameter(Mandatory)][hashtable]$Config ) $groups = @() $pageId = $null do { $endpoint = "/api/v1/device-groups/-?limit=250&view=detail" if ($pageId) { $endpoint += "&pageId=$pageId" } $result = Invoke-GeoAPI -Config $Config -Endpoint $endpoint if ($result.data.groups) { $groups += $result.data.groups } $pageId = $result.paging.nextPageId } while ($pageId) if ($groups.Count -eq 0) { Write-Warning "No device groups found." return @() } Write-Verbose "Found $($groups.Count) groups. Checking descriptions for coordinates..." $geoGroups = @() foreach ($group in $groups) { $groupId = $group.id $groupName = $group.name # Get group definition to read description try { $defResult = Invoke-GeoAPI -Config $Config -Endpoint "/api/v1/device-groups/$groupId/definition" } catch { Write-Verbose "Error fetching definition for group $groupId : $($_.Exception.Message)" continue } $description = if ($defResult.data) { $defResult.data.description } else { $null } if (-not $description) { continue } # Try to parse "lat,lng" from the description if ($description -match '^(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)$') { $lat = [double]$Matches[1] $lng = [double]$Matches[2] # Validate coordinate ranges if ($lat -ge -90 -and $lat -le 90 -and $lng -ge -180 -and $lng -le 180) { $monitorState = if ($group.monitorState) { $group.monitorState } else { 'Unknown' } $geoGroups += [PSCustomObject]@{ Type = 'Group' Name = $groupName GroupId = $groupId Latitude = $lat Longitude = $lng State = $monitorState } } } } Write-Verbose "Found $($geoGroups.Count) groups with location data." return $geoGroups } # --- Build combined geolocation dataset --- function Get-GeolocationData { <# .SYNOPSIS Gathers all geolocated devices and groups from WhatsUp Gold. .DESCRIPTION Combines the output of Get-GeoDevicesWithLocation and Get-GeoGroupsWithLocation into a single dataset suitable for map rendering. .PARAMETER Config The configuration hashtable from Connect-GeoWUGServer. .PARAMETER GroupName Optional device group filter for devices. Default: all. .PARAMETER UseBuiltinCoords Use separate Latitude/Longitude attributes for devices. .PARAMETER IncludeDevices Include devices on the map (default: $true). .PARAMETER IncludeGroups Include groups on the map (default: $true). .OUTPUTS Array of PSCustomObject with Type, Name, Latitude, Longitude, and state fields. #> param( [Parameter(Mandatory)][hashtable]$Config, [string]$GroupName, [switch]$UseBuiltinCoords, [bool]$IncludeDevices = $true, [bool]$IncludeGroups = $true ) $allData = @() if ($IncludeDevices) { $splat = @{ Config = $Config } if ($GroupName) { $splat.GroupName = $GroupName } if ($UseBuiltinCoords) { $splat.UseBuiltinCoords = $true } $allData += @(Get-GeoDevicesWithLocation @splat) } if ($IncludeGroups) { $allData += @(Get-GeoGroupsWithLocation -Config $Config) } Write-Verbose "Geolocation dataset: $($allData.Count) total markers." return $allData } # --- Export Leaflet HTML map --- function Export-GeolocationMapHtml { <# .SYNOPSIS Generates a self-contained Leaflet HTML map from geolocation data. .DESCRIPTION Takes geolocation data and a template HTML file and produces a standalone HTML page with interactive Leaflet map showing device and group markers. The data is embedded as inline JSON so the HTML file can be opened directly in a browser with no server required. .PARAMETER Data Array of geolocation objects from Get-GeolocationData. .PARAMETER OutputPath Full path for the output HTML file. .PARAMETER TemplatePath Path to the Geolocation-Map-Template.html file. Defaults to same directory as this script. .PARAMETER WugBaseUrl The base URL of the WhatsUp Gold web console (e.g. https://wug.example.com:443). Used to make markers clickable - links to device/group dashboards. .PARAMETER DefaultLat Default map center latitude (default: 39.8283 - center of US). .PARAMETER DefaultLng Default map center longitude (default: -98.5795 - center of US). .PARAMETER DefaultZoom Default map zoom level (default: 5). .PARAMETER TileApiKeys Optional hashtable of tile provider API keys to embed in the HTML file. Supported keys: thunderforest, stadia, maptiler, here, mapbox, jawg, tomtom, openweathermap. NOTE: Keys passed here are written in plaintext to the output HTML (opt-in). For better security, omit this parameter and enter keys via the map's Settings > API Keys panel instead. Keys entered in-browser are stored in localStorage (never written to disk in the HTML file). .PARAMETER RefreshIntervalSeconds If specified, adds a meta refresh tag so the browser reloads the page automatically at the given interval. Useful when a scheduled task regenerates the HTML file. Default is 0 (disabled). .EXAMPLE Export-GeolocationMapHtml -Data $geoData -OutputPath "C:\Maps\WUG-Map.html" .EXAMPLE $keys = @{ thunderforest = 'abc123'; stadia = 'xyz789' } Export-GeolocationMapHtml -Data $geoData -OutputPath "C:\Maps\WUG-Map.html" -TileApiKeys $keys #> param( [Parameter(Mandatory)][array]$Data, [Parameter(Mandatory)][string]$OutputPath, [string]$TemplatePath, [string]$WugBaseUrl = '', [double]$DefaultLat = 39.8283, [double]$DefaultLng = -98.5795, [int]$DefaultZoom = 5, [hashtable]$TileApiKeys, [int]$RefreshIntervalSeconds = 0 ) if (-not $TemplatePath) { $TemplatePath = Join-Path $PSScriptRoot 'Geolocation-Map-Template.html' } if (-not (Test-Path $TemplatePath)) { throw "Template not found: $TemplatePath" } $template = [System.IO.File]::ReadAllText($TemplatePath) # Build JSON data $jsonData = ConvertTo-Json -InputObject @($Data) -Depth 5 -Compress # Replace placeholders in template $html = $template # Use .Replace() (not -replace) to avoid regex interpretation of JSON special chars $safeJson = $jsonData -replace '\\', '\\\\' -replace "'", "\\'" $html = $html.Replace('%%MARKER_DATA%%', $safeJson) $html = $html.Replace('%%DEFAULT_LAT%%', $DefaultLat.ToString([System.Globalization.CultureInfo]::InvariantCulture)) $html = $html.Replace('%%DEFAULT_LNG%%', $DefaultLng.ToString([System.Globalization.CultureInfo]::InvariantCulture)) $html = $html.Replace('%%DEFAULT_ZOOM%%', $DefaultZoom.ToString()) $html = $html.Replace('%%WUG_BASE_URL%%', $WugBaseUrl) $html = $html.Replace('%%GENERATED_AT%%', (Get-Date -Format 'yyyy-MM-dd HH:mm:ss')) # Auto-refresh meta tag (0 = disabled) if ($RefreshIntervalSeconds -gt 0) { $html = $html.Replace('%%AUTO_REFRESH%%', "<meta http-equiv=`"refresh`" content=`"$RefreshIntervalSeconds`" />") } else { $html = $html.Replace('%%AUTO_REFRESH%%', '') } # Inject tile provider API keys if ($TileApiKeys -and $TileApiKeys.Count -gt 0) { $keysObj = @{} foreach ($k in $TileApiKeys.Keys) { $keysObj[$k.ToLower()] = $TileApiKeys[$k] } $keysJson = (ConvertTo-Json -InputObject $keysObj -Compress) -replace "'", "\\'" $html = $html.Replace('%%API_KEYS%%', $keysJson) } else { $html = $html.Replace('%%API_KEYS%%', '') } # Write output $outputDir = Split-Path $OutputPath -Parent if ($outputDir -and -not (Test-Path $outputDir)) { New-Item -ItemType Directory -Path $outputDir -Force | Out-Null } Set-Content -Path $OutputPath -Value $html -Encoding UTF8 Write-Verbose "Geolocation map exported to: $OutputPath" return $OutputPath } # --- Import device locations from CSV and set attributes --- function Set-GeoDeviceLocations { <# .SYNOPSIS Reads a CSV file and sets geolocation attributes on matching WhatsUp Gold devices. .DESCRIPTION Imports a CSV with columns DeviceName (or IP), Latitude, Longitude and writes those coordinates to each matched device as custom attributes in WhatsUp Gold. Supports two attribute modes: - LatLong (default): writes a single "LatLong" attribute with value "lat,lng" - Separate: writes individual "Latitude" and "Longitude" attributes Device matching is done by searching the WUG device list by name or IP. A detailed summary report is returned showing what was set and any misses. .PARAMETER Config The configuration hashtable from Connect-GeoWUGServer. .PARAMETER CsvPath Full path to the CSV file. Required columns: DeviceName, Latitude, Longitude. Optional column: IP (used as fallback search if DeviceName match fails). .PARAMETER UseSeparateAttributes If set, writes "Latitude" and "Longitude" as separate attributes instead of a single "LatLong" attribute. .PARAMETER WhatIf Show what would be changed without making any API calls. .OUTPUTS Array of PSCustomObject summarising each row: DeviceName, DeviceId, Status, Detail. .EXAMPLE $results = Set-GeoDeviceLocations -Config $geo -CsvPath "C:\Data\device-locations.csv" .EXAMPLE Set-GeoDeviceLocations -Config $geo -CsvPath ".\locations.csv" -UseSeparateAttributes -WhatIf #> param( [Parameter(Mandatory)][hashtable]$Config, [Parameter(Mandatory)][string]$CsvPath, [switch]$UseSeparateAttributes, [switch]$WhatIf ) if (-not (Test-Path $CsvPath)) { throw "CSV file not found: $CsvPath" } $csv = @(Import-Csv -Path $CsvPath) if ($csv.Count -eq 0) { Write-Warning "CSV file is empty." return @() } # Validate required columns $headers = $csv[0].PSObject.Properties.Name $hasName = $headers -contains 'DeviceName' $hasIP = $headers -contains 'IP' $hasLat = $headers -contains 'Latitude' $hasLng = $headers -contains 'Longitude' if (-not $hasLat -or -not $hasLng) { throw "CSV must have 'Latitude' and 'Longitude' columns." } if (-not $hasName -and -not $hasIP) { throw "CSV must have at least a 'DeviceName' or 'IP' column." } # Pre-fetch all devices for matching (avoids N search calls) Write-Verbose "Fetching full device list for matching..." $allDevices = @() $pageId = $null do { $endpoint = "/api/v1/device-groups/0/devices/-?view=overview&limit=250" if ($pageId) { $endpoint += "&pageId=$pageId" } $result = Invoke-GeoAPI -Config $Config -Endpoint $endpoint if ($result.data.devices) { $allDevices += $result.data.devices } $pageId = $result.paging.nextPageId } while ($pageId) Write-Verbose "Loaded $($allDevices.Count) devices for matching." # Build lookup tables (name -> device, IP -> device) $byName = @{} $byIP = @{} foreach ($d in $allDevices) { $devId = if ($d.id) { $d.id } else { $d.deviceId } $devName = if ($d.name) { $d.name } elseif ($d.displayName) { $d.displayName } else { '' } $devIP = if ($d.networkAddress) { $d.networkAddress } elseif ($d.hostName) { $d.hostName } else { '' } if ($devName) { $byName[$devName.ToLower()] = @{ Id = $devId; Name = $devName; IP = $devIP } } if ($devIP) { $byIP[$devIP.ToLower()] = @{ Id = $devId; Name = $devName; IP = $devIP } } } $results = @() $totalRows = $csv.Count $currentRow = 0 foreach ($row in $csv) { $currentRow++ $pct = [Math]::Round(($currentRow / $totalRows) * 100, 0) Write-Progress -Activity "Syncing device locations" -Status "$currentRow of $totalRows" -PercentComplete $pct $csvName = if ($hasName -and $row.DeviceName) { $row.DeviceName.Trim() } else { '' } $csvIP = if ($hasIP -and $row.IP) { $row.IP.Trim() } else { '' } $lat = $row.Latitude.Trim() $lng = $row.Longitude.Trim() # Validate coordinates $latNum = 0.0; $lngNum = 0.0 if (-not [double]::TryParse($lat, [ref]$latNum) -or -not [double]::TryParse($lng, [ref]$lngNum)) { $results += [PSCustomObject]@{ DeviceName = if ($csvName) { $csvName } else { $csvIP } DeviceId = $null Status = 'Skipped' Detail = "Invalid coordinates: $lat, $lng" } continue } if ($latNum -lt -90 -or $latNum -gt 90 -or $lngNum -lt -180 -or $lngNum -gt 180) { $results += [PSCustomObject]@{ DeviceName = if ($csvName) { $csvName } else { $csvIP } DeviceId = $null Status = 'Skipped' Detail = "Coordinates out of range: $lat, $lng" } continue } # Match device $matched = $null if ($csvName) { $matched = $byName[$csvName.ToLower()] } if (-not $matched -and $csvIP) { $matched = $byIP[$csvIP.ToLower()] } if (-not $matched) { $results += [PSCustomObject]@{ DeviceName = if ($csvName) { $csvName } else { $csvIP } DeviceId = $null Status = 'NotFound' Detail = "No matching device in WUG" } continue } $deviceId = $matched.Id $displayLabel = if ($csvName) { $csvName } else { "$csvIP ($($matched.Name))" } if ($WhatIf) { $attrDesc = if ($UseSeparateAttributes) { "Latitude=$lat, Longitude=$lng" } else { "LatLong=$lat,$lng" } $results += [PSCustomObject]@{ DeviceName = $displayLabel DeviceId = $deviceId Status = 'WhatIf' Detail = "Would set $attrDesc" } continue } # Set attributes via PATCH (upsert) try { if ($UseSeparateAttributes) { $body = ConvertTo-Json -InputObject @{ attributesToAdd = @( @{ name = 'Latitude'; value = $lat } @{ name = 'Longitude'; value = $lng } ) } -Depth 5 -Compress } else { $body = ConvertTo-Json -InputObject @{ attributesToAdd = @( @{ name = 'LatLong'; value = "$lat,$lng" } ) } -Depth 5 -Compress } $null = Invoke-GeoAPI -Config $Config ` -Endpoint "/api/v1/devices/$deviceId/attributes/-" ` -Method 'PATCH' -Body $body $results += [PSCustomObject]@{ DeviceName = $displayLabel DeviceId = $deviceId Status = 'Updated' Detail = if ($UseSeparateAttributes) { "Lat=$lat, Lng=$lng" } else { "LatLong=$lat,$lng" } } } catch { $results += [PSCustomObject]@{ DeviceName = $displayLabel DeviceId = $deviceId Status = 'Error' Detail = $_.Exception.Message } } } Write-Progress -Activity "Syncing device locations" -Completed # Summary $updated = @($results | Where-Object { $_.Status -eq 'Updated' }).Count $notFound = @($results | Where-Object { $_.Status -eq 'NotFound' }).Count $skipped = @($results | Where-Object { $_.Status -eq 'Skipped' }).Count $errors = @($results | Where-Object { $_.Status -eq 'Error' }).Count Write-Verbose "Sync complete: $updated updated, $notFound not found, $skipped skipped, $errors errors." return $results } # --- Read geolocation config from the DPAPI vault --- function Import-GeolocationConfig { <# .SYNOPSIS Reads the geolocation configuration and refresh token from the DPAPI vault. .DESCRIPTION Retrieves the Geolocation.Config bundle and Geolocation.RefreshToken from the discovery vault, converts string fields to their proper types, and returns a typed configuration hashtable. Requires DiscoveryHelpers.ps1 to be loaded first (for vault functions). #> [CmdletBinding()] param() $raw = Get-DiscoveryCredential -Name 'Geolocation.Config' if (-not $raw) { throw "Geolocation config not found in vault. Run Setup-GeolocationConfig.ps1 first." } $refreshToken = Get-DiscoveryCredential -Name 'Geolocation.RefreshToken' if (-not $refreshToken) { throw "Geolocation refresh token not found in vault. Run Setup-GeolocationConfig.ps1 first." } # Extract tile API keys from TileApiKey.* fields $tileApiKeys = @{} foreach ($key in @($raw.Keys)) { if ($key -like 'TileApiKey.*') { $provider = $key.Substring('TileApiKey.'.Length) $tileApiKeys[$provider] = $raw[$key] } } @{ ServerUri = $raw.ServerUri Protocol = $raw.Protocol Port = [int]$raw.Port IgnoreSSL = [System.Convert]::ToBoolean($raw.IgnoreSSL) WugConsoleUrl = $raw.WugConsoleUrl RefreshToken = $refreshToken DefaultLat = [double]$raw.DefaultLat DefaultLng = [double]$raw.DefaultLng DefaultZoom = [int]$raw.DefaultZoom GroupName = $raw.GroupName UseBuiltinCoords = [System.Convert]::ToBoolean($raw.UseBuiltinCoords) IncludeDevices = [System.Convert]::ToBoolean($raw.IncludeDevices) IncludeGroups = [System.Convert]::ToBoolean($raw.IncludeGroups) TileApiKeys = $tileApiKeys } } # SIG # Begin signature block # MIIr+wYJKoZIhvcNAQcCoIIr7DCCK+gCAQExDzANBglghkgBZQMEAgEFADB5Bgor # BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG # KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCBbM12TTRsogXKS # UYlPAmQwTY8j8YlxcK2c5qhk/xR3kqCCJQ0wggVvMIIEV6ADAgECAhBI/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 # BCAIPkfv8uT8gbl9XUtMQAWKhgvMPSUoKkBuY/Pyu9sABzANBgkqhkiG9w0BAQEF # AASCAgDW57vQODfgUY69wjnmUiM7r3RdAPRbyaH/sHakuk30YIl1qjmrZIVNNFty # ObNAImA2j4iVeVVPXJTYVy5qMDx4WU7S7f6EXmZfKll9WBdC7z4QJNXRIUk6pk2D # z5T8v+Fvnpn991VHCwY84fkOeHtVfsce3eD4gnkDtYrP4syT3QonrB0RWa5i0q3l # 6zDthIckmw/x5Ylxj4cIgTyU4Ozzt95S1TpL67dl+RHUFsNip8IAzmw2Tyw29PuZ # idbbQeiu0XABJbnptdg/w+RPxsl1NqDPfeEpnAZXFPxi4PJKBFvuOyKs003EsPyk # HdZcpcEfFek+PmBkx/aBa13ZJfHkwUF3swpPoGmVpG+UQMVOSnLHCgACfbxErqZc # CF7JNfO+deKZYMk0XukvgBpwq0zb2iVF8EFhEaqXzUxNiR55u2mIvG5q3OZKxVl9 # aFgZ6dTufb58ZKRPQoUdv7EeQMXo9J5BopUDF51vZO/jpt2UYyyg7xSRHpPV4+XE # xZPQ4kFeKmRulNRgx/p5WwI1GnlR83HAQ8b7GkcTxutg1g+jN+FIz/SdIYBsgtWJ # TbKA5PNwV5VaqhnqQ0BBTYypE+6WE2K+twZfOme2mixzkYztdYVIJ4Hrw+5w0rlt # MOCMmyHjWHQVjgUiFlQeFxIN9OTnKCPjtIsBTnvwwVdw8NprNKGCAyYwggMiBgkq # hkiG9w0BCQYxggMTMIIDDwIBATB9MGkxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5E # aWdpQ2VydCwgSW5jLjFBMD8GA1UEAxM4RGlnaUNlcnQgVHJ1c3RlZCBHNCBUaW1l # U3RhbXBpbmcgUlNBNDA5NiBTSEEyNTYgMjAyNSBDQTECEAqA7xhLjfEFgtHEdqeV # dGgwDQYJYIZIAWUDBAIBBQCgaTAYBgkqhkiG9w0BCQMxCwYJKoZIhvcNAQcBMBwG # CSqGSIb3DQEJBTEPFw0yNjA0MjMwMTExMzdaMC8GCSqGSIb3DQEJBDEiBCDv/QU6 # qpFvP7WBd/N1H6jK+H9LbQbwaj4vKCABsSr8MDANBgkqhkiG9w0BAQEFAASCAgBk # QXXr/eLokbGlm+tziNk3oaTzsRA3syrPmATSoWu4s7laj6cUNAQG+TmjMgvkX66c # +ec3WFR91zQYo/gfZalekbXXFLQA+H9YocPXFdyVMUPTjEALCWkdNjjCIe1Yq4nC # vSBbiqF1Z9Vh13DStgm6U/6S97T7jitSuImjgFpFIcrQ33bAkvjdPDC5rQIGvRuC # iJyumEz4PIaJ48xDbAupfi2PlB/yjTe5uhm0aE9ox9oQjEKxDqZbwTyolEvtNsfb # LA2G1SvU8PCJG8MsUMgBaqTQxI9QOHjule9c6Kqt1dzlpGz3MeLeFwBI96HYDY2C # TsqGIEhq1HD0T5vpZ0B+c75gUbp1ITep8oR4BfrAFgiAk67yi9qSFdk3Rvap0co6 # eBLg4+mx4iB/kwUMLCePgsuUjl7mQJ+9EAwB1nYHxW1nEgNzZbdp5b9ohT8h6xUS # 9b5/BOc4INsuw+U3Xps9WtFjlJIKC30KhgXm3FnrhKC+m+NQjl7GAXAFNw7nv0aY # 7e1sHXaWM3VTDN00TQM+BLs1fcGEGh33Avbgap1k3Csh08L4Ggt8VabT3X/9Zmt4 # 2PZLjGKsvy0JLbhBl/vR0QTlbT/MFOUmAW6+mza6s65rf7hlNHzsfaQDM6VsulOP # xgvi3iWSqZeqyad+ZvRuurEFrvioN4t9OL/q128fdg== # SIG # End signature block |