Private/Endpoint.ps1
|
# Where the API and the console are. They may be on different origins (a console at # https://console.example.com and its API at https://api.example.com/api/), and the person signing in knows # one of them: Connect-VMetric accepts either and finds the other (Resolve-VMetricEndpoint). # # Every address the module uses is validated first, whether the person gave it or a server answered it: an # absolute URL, https (plain http only to this machine, where no token crosses a network), no user name or # password, query or fragment, and the console an origin. The console's address ends up in Start-Process, # which on Windows would open a file path or another protocol's handler just as readily as a web page. function ConvertTo-VMetricUrl { # A validated absolute URL. A bare host gets https:// unless -Strict (for an address a server answered, # which must say what it is). [CmdletBinding()] [OutputType([uri])] param( [Parameter(Mandatory)] [AllowEmptyString()] [string] $Uri, # What the address is, for messages: "the address", "the console address the API names". [string] $What = 'the address', # An origin only (scheme, host and port): no path. [switch] $Origin, # No https:// is assumed. [switch] $Strict ) $text = $Uri.Trim() $shown = ConvertTo-VMetricSafeText -Text $text if ($shown.Length -gt 200) { $shown = $shown.Substring(0, 200) + '...' } $subject = $What.Substring(0, 1).ToUpperInvariant() + $What.Substring(1) if (-not $Strict -and $text -notmatch '^[A-Za-z][A-Za-z0-9+.-]*://') { $text = "https://$text" } $parsed = $null if (-not $text -or -not [System.Uri]::TryCreate($text, [System.UriKind]::Absolute, [ref] $parsed) -or -not $parsed.Host) { throw (New-VMetricException -Message "'$shown' is not a valid URL for $What. Give an address such as https://console.example.com." ` -Code 'InvalidUri' -Category InvalidArgument) } if ($parsed.Scheme -ne 'https' -and $parsed.Scheme -ne 'http') { throw (New-VMetricException -Message "'$shown' is not a web address ($What must be https)." -Code 'InvalidUri' -Category InvalidArgument) } if ($parsed.UserInfo) { throw (New-VMetricException -Message "$subject must not carry a user name or password." -Code 'InvalidUri' -Category InvalidArgument) } if ($parsed.Query -or $parsed.Fragment -or $text.Contains('?') -or $text.Contains('#')) { throw (New-VMetricException -Message "'$shown' carries a query or a fragment; give $What without one." -Code 'InvalidUri' -Category InvalidArgument) } if ($parsed.Scheme -ne 'https' -and -not $parsed.IsLoopback) { throw (New-VMetricException -Message "Connect over https: '$shown' would send your tokens unencrypted." -Code 'InsecureUri' -Category SecurityError) } if ($Origin -and $parsed.AbsolutePath -ne '/') { throw (New-VMetricException -Message "'$shown' is not an origin: $What is a scheme and a host only, such as https://console.example.com." ` -Code 'InvalidUri' -Category InvalidArgument) } $parsed } function ConvertTo-VMetricApiBase { # The API's base URL, ending in a slash, to which v1/<path> is appended: an origin serves the API under # /api/ (https://vm.example.com -> https://vm.example.com/api/); a URL with a path is the base itself # (https://api.example.com/api -> https://api.example.com/api/). [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [string] $Url ) $parsed = [uri]$Url $path = $parsed.AbsolutePath if ($path -eq '/' -or $path -eq '') { $path = '/api/' } elseif (-not $path.EndsWith('/')) { $path += '/' } $parsed.GetLeftPart([System.UriPartial]::Authority) + $path } function Resolve-VMetricOrigin { # The origin (scheme, host and port) of an address, validated: https://console.example.com. [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [string] $Uri ) (ConvertTo-VMetricUrl -Uri $Uri).GetLeftPart([System.UriPartial]::Authority) } function Invoke-VMetricDiscoveryRequest { # A GET with no credentials, for finding the API: the answer whatever its status (a redirect included, never # followed), or a ConnectionFailed error when there is none. [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory)] [string] $Uri, [switch] $SkipCertificateCheck ) Write-Verbose "GET $Uri" try { Invoke-VMetricHttp -Method GET -Uri $Uri -Headers @{ Accept = 'application/json, text/javascript, */*' } -Body $null -SkipCertificateCheck:$SkipCertificateCheck -TimeoutSec 30 } catch { if (Test-VMetricFlowControl -ErrorRecord $_) { throw } $failure = $_.Exception $origin = ([uri]$Uri).GetLeftPart([System.UriPartial]::Authority) $category = [System.Management.Automation.ErrorCategory]::ConnectionError $message = "Could not reach ${origin}: $($failure.Message)" if ($failure -is [System.TimeoutException] -or $failure -is [System.Threading.Tasks.TaskCanceledException]) { $category = [System.Management.Automation.ErrorCategory]::OperationTimeout $message = "$origin did not answer within 30 seconds." } elseif ("$($failure.Message) $($failure.InnerException.Message)" -match 'SSL|certificate|TLS') { $category = [System.Management.Automation.ErrorCategory]::SecurityError } throw (New-VMetricException -Message $message -Code 'ConnectionFailed' -Category $category -InnerException $failure) } } function Get-VMetricApiInfo { # GET <API base>v1/cli/metadata, which the API answers without credentials: {apiUrl, consoleUrl?, version}. # $null when the address is not the API (any status but 200, or not that JSON); Notes gets why. [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory)] [string] $ApiBase, [switch] $SkipCertificateCheck, [System.Collections.Generic.List[string]] $Notes ) $uri = $ApiBase + 'v1/cli/metadata' $response = Invoke-VMetricDiscoveryRequest -Uri $uri -SkipCertificateCheck:$SkipCertificateCheck $status = [int]$response.StatusCode if ($status -ge 300 -and $status -lt 400) { if ($null -ne $Notes) { $Notes.Add((New-VMetricRedirectException -Uri $uri -StatusCode $status -Location (Get-VMetricHeader -Response $response -Name 'Location')).Message) } return $null } if ($status -ne 200) { if ($null -ne $Notes) { $Notes.Add("$uri answered $status.") } return $null } $answer = $null try { $answer = ConvertFrom-VMetricJson -Json $response.Content } catch { $answer = $null } $apiUrl = Get-VMetricMember -InputObject $answer -Name 'apiUrl' if (-not (Test-VMetricObjectLike -InputObject $answer) -or $apiUrl -isnot [string] -or -not $apiUrl) { if ($null -ne $Notes) { $Notes.Add("$uri did not answer the API's metadata.") } return $null } $consoleUrl = Get-VMetricMember -InputObject $answer -Name 'consoleUrl' [pscustomobject]@{ ApiUrl = $apiUrl ConsoleUrl = $(if ($consoleUrl -is [string] -and $consoleUrl) { $consoleUrl } else { $null }) Version = [string](Get-VMetricMember -InputObject $answer -Name 'version') } } function Get-VMetricConsoleApiUrl { # The apiUrl a console's runtime configuration names (<console>/config.js, a line such as # apiUrl: "https://api.example.com/api/", # ), or $null when the address serves no such file. Only that one strict pattern is read. [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [string] $ConsoleBase, [switch] $SkipCertificateCheck, [System.Collections.Generic.List[string]] $Notes ) $uri = $ConsoleBase + '/config.js' $response = Invoke-VMetricDiscoveryRequest -Uri $uri -SkipCertificateCheck:$SkipCertificateCheck $status = [int]$response.StatusCode if ($status -ge 300 -and $status -lt 400) { if ($null -ne $Notes) { $Notes.Add((New-VMetricRedirectException -Uri $uri -StatusCode $status -Location (Get-VMetricHeader -Response $response -Name 'Location')).Message) } return $null } if ($status -ne 200 -or -not $response.Content) { if ($null -ne $Notes) { $Notes.Add("$uri answered $status.") } return $null } $match = [regex]::Match($response.Content, '(?m)^[ \t]*apiUrl[ \t]*:[ \t]*"([^"\\\s]{1,2048})"[ \t]*,?[ \t]*\r?$') if (-not $match.Success) { if ($null -ne $Notes) { $Notes.Add("$uri names no apiUrl.") } return $null } $match.Groups[1].Value } function Resolve-VMetricEndpoint { # The API's URL and the console's origin, from the address the person gave, the console's or the API's: # 1. <address>/api/v1/cli/metadata (<address>v1/cli/metadata when the address ends in /api/): the API # answers its own URL and its console's; # 2. otherwise <address>/config.js, the console's runtime configuration, names the API, whose metadata # then names the console. Without it (an older API), the console is the address that served config.js. # Answers {ApiUrl (ending in /api/), ConsoleUrl (an origin)}. Everything the servers answer is validated # as the person's own address is. [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory)] [string] $Uri, [switch] $SkipCertificateCheck ) $given = ConvertTo-VMetricUrl -Uri $Uri $origin = $given.GetLeftPart([System.UriPartial]::Authority) $path = $given.AbsolutePath.TrimEnd('/') if ($path -match '(?i)/api/v1$') { $path = $path.Substring(0, $path.Length - 3) } $isApi = $path -match '(?i)(^|/)api$' $notes = [System.Collections.Generic.List[string]]::new() $consoleDefault = if ($isApi) { $null } else { $origin } # The address as given, then its origin: a page pasted from the address bar # (https://console.example.com/login) is still the console's. $bases = @($origin + $path) if ($path -and -not $isApi) { $bases += $origin } $metadata = $null foreach ($base in $bases) { $apiBase = if ($isApi) { "$base/" } else { "$base/api/" } $metadata = Get-VMetricApiInfo -ApiBase $apiBase -SkipCertificateCheck:$SkipCertificateCheck -Notes $notes if (-not $metadata -and -not $isApi) { $named = Get-VMetricConsoleApiUrl -ConsoleBase $base -SkipCertificateCheck:$SkipCertificateCheck -Notes $notes if ($named) { $apiUrl = ConvertTo-VMetricApiBase -Url (ConvertTo-VMetricUrl -Uri $named -Strict -What "the API address $base/config.js names").AbsoluteUri $metadata = Get-VMetricApiInfo -ApiBase $apiUrl -SkipCertificateCheck:$SkipCertificateCheck -Notes $notes if (-not $metadata) { Write-Verbose "The API at $apiUrl answers no metadata; the console is $origin." $metadata = [pscustomobject]@{ ApiUrl = $apiUrl; ConsoleUrl = $null; Version = '' } } } } if ($metadata) { break } } if (-not $metadata) { $detail = ($notes | Select-Object -Unique) -join ' ' throw (New-VMetricException -Message "$(ConvertTo-VMetricSafeText -Text $Uri) is neither a VirtualMetric console nor its API. $detail Give the console address you sign in to, such as https://console.example.com." ` -Code 'NotVirtualMetric' -Category ObjectNotFound) } $apiUrl = ConvertTo-VMetricApiBase -Url (ConvertTo-VMetricUrl -Uri $metadata.ApiUrl -Strict -What 'the API address the server names').AbsoluteUri if ($metadata.ConsoleUrl) { $consoleUrl = (ConvertTo-VMetricUrl -Uri $metadata.ConsoleUrl -Strict -Origin -What 'the console address the API names').GetLeftPart([System.UriPartial]::Authority) } elseif ($consoleDefault) { $consoleUrl = $consoleDefault } else { # The API names no console: it serves one on its own origin. $consoleUrl = ([uri]$apiUrl).GetLeftPart([System.UriPartial]::Authority) } Write-Verbose "API: $apiUrl; console: $consoleUrl." [pscustomobject]@{ ApiUrl = $apiUrl ConsoleUrl = $consoleUrl } } |