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
    }
}