Private/Http.ps1
|
# HTTP. Invoke-VMetricHttp is the transport (one exchange, never throws for an HTTP status); tests replace it. # Invoke-VMetricRequest is what everything else calls: it adds the Bearer token, refreshes it ahead of expiry # and once on a 401, waits out another deployment (409 DEPLOYMENT_IN_PROGRESS), retries a GET the proxy # dropped, refuses a redirect, and turns an error answer into an ErrorRecord with the API's code, message and # trace id. # # The transport is a module-owned System.Net.Http.HttpClient, not Invoke-WebRequest, for two reasons: # - a failed Invoke-WebRequest leaves its own ErrorRecord in $Error and in -ErrorVariable, whose target is the # request message, Authorization header included, so Get-Error printed the bearer token. An HttpClient # failure is an exception that carries no request. # - Invoke-WebRequest follows redirects, and .NET replays the body of a 307 or 308: a refresh token, a device # code, an authorization code with its verifier, or a secret, sent to wherever the Location points. The # client never follows one; Invoke-VMetricRequest reports it instead. function Get-VMetricUserAgent { # VirtualMetric-PowerShell/<module version> PowerShell/<version> <OS> [CmdletBinding()] [OutputType([string])] param() if (-not $script:VMetricUserAgent) { $os = [regex]::Replace([string][System.Runtime.InteropServices.RuntimeInformation]::OSDescription, '[^\x20-\x7E]', '') $os = [regex]::Replace($os, '\s+', ' ').Trim() if ($os.Length -gt 100) { $os = $os.Substring(0, 100).TrimEnd() } $script:VMetricUserAgent = 'VirtualMetric-PowerShell/{0} PowerShell/{1} {2}' -f $script:ModuleVersion, $PSVersionTable.PSVersion, $os } $script:VMetricUserAgent } function Wait-VMetricInterval { # Start-Sleep, behind a seam so tests do not wait. [CmdletBinding()] param( [Parameter(Mandatory)] [double] $Seconds ) if ($Seconds -gt 0) { Start-Sleep -Milliseconds ([int][Math]::Ceiling($Seconds * 1000)) } } function Get-VMetricHttpClient { # The module's HttpClient for a certificate policy, made on first use and disposed with the module: it # follows no redirect, keeps no cookie, decompresses answers, and uses the system's proxy as # Invoke-WebRequest does. The request's own timeout applies, not the client's. [CmdletBinding()] [OutputType([System.Net.Http.HttpClient])] param( [switch] $SkipCertificateCheck ) $key = if ($SkipCertificateCheck) { 'SkipCertificateCheck' } else { 'Default' } $client = $script:VMetricHttpClients[$key] if ($client) { return $client } $handler = [System.Net.Http.HttpClientHandler]::new() $handler.AllowAutoRedirect = $false $handler.UseCookies = $false $handler.AutomaticDecompression = [System.Net.DecompressionMethods]::All if ($SkipCertificateCheck) { # A compiled delegate: a script block would run on a thread with no runspace. $handler.ServerCertificateCustomValidationCallback = [System.Net.Http.HttpClientHandler]::DangerousAcceptAnyServerCertificateValidator } $client = [System.Net.Http.HttpClient]::new($handler, $true) $client.Timeout = [System.Threading.Timeout]::InfiniteTimeSpan $script:VMetricHttpClients[$key] = $client $client } function Invoke-VMetricHttp { # One HTTP exchange. The answer is {StatusCode, Content, Headers} for every status, a redirect included; # only a failure to get an answer at all (DNS, TLS, a refused connection, a timeout) throws, as an # exception that carries no part of the request. The body text may hold a secret: it is never logged. [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory)] [string] $Method, [Parameter(Mandatory)] [string] $Uri, [hashtable] $Headers = @{}, [AllowNull()] [string] $Body, [switch] $SkipCertificateCheck, [int] $TimeoutSec = 120 ) $client = Get-VMetricHttpClient -SkipCertificateCheck:$SkipCertificateCheck $cancel = [System.Threading.CancellationTokenSource]::new([TimeSpan]::FromSeconds([Math]::Max(1, $TimeoutSec))) $request = [System.Net.Http.HttpRequestMessage]::new([System.Net.Http.HttpMethod]::new($Method.ToUpperInvariant()), $Uri) $response = $null try { $null = $request.Headers.TryAddWithoutValidation('User-Agent', (Get-VMetricUserAgent)) foreach ($name in $Headers.Keys) { $null = $request.Headers.TryAddWithoutValidation([string]$name, [string]$Headers[$name]) } if ($PSBoundParameters.ContainsKey('Body') -and $null -ne $Body) { $content = [System.Net.Http.ByteArrayContent]::new([System.Text.Encoding]::UTF8.GetBytes($Body)) $content.Headers.ContentType = [System.Net.Http.Headers.MediaTypeHeaderValue]::Parse('application/json; charset=utf-8') $request.Content = $content } $task = $client.SendAsync($request, [System.Net.Http.HttpCompletionOption]::ResponseContentRead, $cancel.Token) # Waits in short slices so Ctrl+C stops the wait; the finally block then cancels the request. while (-not ([System.IAsyncResult]$task).AsyncWaitHandle.WaitOne(200)) { } if ($task.IsCanceled) { throw [System.TimeoutException]::new("No answer within $TimeoutSec seconds.") } if ($task.IsFaulted) { $failure = $task.Exception.GetBaseException() if ($failure -is [System.OperationCanceledException]) { throw [System.TimeoutException]::new("No answer within $TimeoutSec seconds.") } throw $failure } $response = $task.Result # ResponseContentRead: the body is already buffered. $bytes = $response.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult() $answerHeaders = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($header in $response.Headers) { $answerHeaders[$header.Key] = $header.Value -join ', ' } foreach ($header in $response.Content.Headers) { $answerHeaders[$header.Key] = $header.Value -join ', ' } [pscustomobject]@{ StatusCode = [int]$response.StatusCode Content = [System.Text.Encoding]::UTF8.GetString($bytes) Headers = $answerHeaders } } finally { $cancel.Cancel() if ($response) { $response.Dispose() } $request.Dispose() $cancel.Dispose() } } function Get-VMetricHeader { # A header of a transport answer by name, ignoring case; $null when it has none. [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [object] $Response, [Parameter(Mandatory)] [string] $Name ) $headers = $Response.Headers if ($headers -isnot [System.Collections.IDictionary]) { return $null } foreach ($key in $headers.Keys) { if ([string]::Equals([string]$key, $Name, [System.StringComparison]::OrdinalIgnoreCase)) { return [string]$headers[$key] } } return $null } function New-VMetricRedirectException { # A redirect, refused: following it would send the request, its body and its token included, to whatever # address the Location names. The message names that address's origin (the server's text, cleaned), never # more of it. [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Creates an exception object; changes no state.')] [CmdletBinding()] [OutputType([System.Exception])] param( [Parameter(Mandatory)] [string] $Uri, [Parameter(Mandatory)] [int] $StatusCode, [AllowNull()] [AllowEmptyString()] [string] $Location ) $from = ([uri]$Uri).GetLeftPart([System.UriPartial]::Authority) $to = $null $parsed = $null if ($Location -and [System.Uri]::TryCreate([uri]$Uri, $Location.Trim(), [ref] $parsed) -and $parsed.IsAbsoluteUri -and $parsed.Host) { $to = ConvertTo-VMetricSafeText -Text $parsed.GetLeftPart([System.UriPartial]::Authority) } if (-not $to) { $message = "$from answered $StatusCode, a redirect without a usable address. The module follows no redirect; connect to the address that serves the VirtualMetric API." } elseif ($to -eq $from) { $message = "$from answered $StatusCode, a redirect to another path on the same server. The module follows no redirect, which would carry the request and its token or secrets elsewhere; check the address given to Connect-VMetric." } else { $message = "$from answered $StatusCode, a redirect to $to. The module follows no redirect, which would carry the request and its token or secrets to another address; if $to serves your VirtualMetric API, connect to it instead." } New-VMetricException -Message $message -Code 'Redirected' -Category ConnectionError -StatusCode $StatusCode } function ConvertTo-VMetricQueryValue { # A query value as text: a switch or boolean true/false, a DateTime Unix seconds (the API's timestamps), # a list comma-separated. $null leaves the parameter out. [CmdletBinding()] [OutputType([string])] param( [AllowNull()] [object] $Value ) if ($null -eq $Value) { return $null } if ($Value -is [System.Security.SecureString]) { throw (New-VMetricException -Message 'A secret cannot be sent in a query string.' -Code 'SecretInQuery' -Category InvalidArgument) } if ($Value -is [System.Management.Automation.SwitchParameter]) { return $(if ($Value.IsPresent) { 'true' } else { 'false' }) } if ($Value -is [bool]) { return $(if ($Value) { 'true' } else { 'false' }) } if ($Value -is [datetime]) { return [string][System.DateTimeOffset]::new($Value.ToUniversalTime()).ToUnixTimeSeconds() } if ($Value -is [System.DateTimeOffset]) { return [string]$Value.ToUnixTimeSeconds() } if ($Value -is [string]) { return $Value.ToString() } if ($Value -is [System.Collections.IEnumerable] -and $Value -isnot [System.Collections.IDictionary]) { $parts = foreach ($item in $Value) { ConvertTo-VMetricQueryValue -Value $item } return (@($parts) -join ',') } return [string]$Value } function New-VMetricRequestUri { # <API base>v1<Path>?<query>: https://api.example.com/api/v1/devices?pageNumber=1. The base is the API's # URL (ending in /api/), or an origin, which serves the API under /api/. [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Builds a string; changes no state.')] [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [Alias('Origin')] [string] $ApiUrl, [Parameter(Mandatory)] [string] $Path, [System.Collections.IDictionary] $Query ) $builder = [System.Text.StringBuilder]::new() [void]$builder.Append((ConvertTo-VMetricApiBase -Url $ApiUrl)).Append('v1') if (-not $Path.StartsWith('/')) { [void]$builder.Append('/') } [void]$builder.Append($Path) $separator = if ($Path.Contains('?')) { '&' } else { '?' } if ($Query) { foreach ($key in $Query.Keys) { $text = ConvertTo-VMetricQueryValue -Value $Query[$key] if ($null -eq $text) { continue } [void]$builder.Append($separator).Append([System.Uri]::EscapeDataString([string]$key)).Append('=').Append([System.Uri]::EscapeDataString($text)) $separator = '&' } } $builder.ToString() } function Test-VMetricAccessTokenExpiring { # True when a session that can refresh has under a minute left on its access token. [CmdletBinding()] [OutputType([bool])] param( [Parameter(Mandatory)] [System.Collections.IDictionary] $Session, [int] $WithinSeconds = 60 ) if (-not $Session.RefreshToken -or -not $Session.AccessExpiresAt) { return $false } return ((([System.DateTimeOffset]$Session.AccessExpiresAt) - [System.DateTimeOffset]::UtcNow).TotalSeconds -lt $WithinSeconds) } $script:VMetricRefreshRetrySeconds = 25 function Test-VMetricAnswerLost { # Whether a failed request may have been served with its answer lost on the way back: no answer at all # (a reset connection, a timeout), or a 502, 503 or 504, a proxy's as a rule. Not a TLS failure (the # request never got through) and not any other answer. [CmdletBinding()] [OutputType([bool])] param( [AllowNull()] [System.Exception] $Exception ) if ($Exception -isnot [VMetricException]) { return $false } if ($Exception.Code -eq 'ConnectionFailed') { return ($Exception.Category -ne [System.Management.Automation.ErrorCategory]::SecurityError) } return ($Exception.StatusCode -in @(502, 503, 504)) } function Update-VMetricAccessToken { # Refreshes a session's access token with its refresh token (which rotates). Holds the session's Lock for # the whole exchange: a caller that waited finds the Generation moved on and uses the token another caller # just got, instead of sending the refresh token that token replaced (the server would revoke the session). [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Refreshes the in-memory session only.')] [CmdletBinding()] param( [Parameter(Mandatory)] [System.Collections.IDictionary] $Session, # Refresh even if the token is not about to expire (after a 401). [switch] $Force, # Refresh when the token has less than this left (re-checked under the Lock). [int] $WithinSeconds = 60, # The Generation the failed request used: no refresh when another caller has refreshed since. [int] $UsedGeneration = -1 ) if ($Session.IsJobSnapshot) { throw (New-VMetricException -Message 'A job never refreshes the session it was given.' -Code 'JobTokenExpired' -Category AuthenticationError -Terminating) } [System.Threading.Monitor]::Enter($Session.Lock) try { if ($UsedGeneration -ge 0 -and [int]$Session.Generation -ne $UsedGeneration) { return } if (-not $Force -and -not (Test-VMetricAccessTokenExpiring -Session $Session -WithinSeconds $WithinSeconds)) { return } if (-not $Session.RefreshToken) { throw (New-VMetricException -Message 'Your session has ended. Run Connect-VMetric to sign in again.' -Code 'SessionExpired' ` -Category AuthenticationError -RecommendedAction 'Run Connect-VMetric to sign in again.' -Terminating) } Write-Verbose 'Refreshing the access token.' # A refresh whose answer was lost (a timeout, a reset connection, a proxy's 502 to 504) may have been # served: the server rotated the token and the answer never came. The server takes the token it just # rotated once more for 30 seconds and answers the same successor, so the refresh is tried again at # once, with the same token, within 25 seconds in all. Waiting for the next command instead would # present the spent token after the window, and the server would revoke the session as a replay. $deadline = [datetime]::UtcNow.AddSeconds($script:VMetricRefreshRetrySeconds) $delays = @(1, 2, 4, 8) $attempt = 0 while ($true) { $remaining = ($deadline - [datetime]::UtcNow).TotalSeconds try { $token = Invoke-VMetricRequest -Method POST -Path '/cli/token' -Anonymous -ApiUrl $Session.ApiUrl ` -SkipCertificateCheck:([bool]$Session.SkipCertificateCheck) -NoConflictRetry ` -TimeoutSec ([int][Math]::Max(1, [Math]::Min(10, [Math]::Floor($remaining)))) ` -Body ([ordered]@{ grantType = 'refresh_token'; refreshToken = $Session.RefreshToken }) break } catch { if (Test-VMetricFlowControl -ErrorRecord $_) { throw } $inner = $_.Exception if ($attempt -lt $delays.Count -and (Test-VMetricAnswerLost -Exception $inner) -and [datetime]::UtcNow.AddSeconds($delays[$attempt]) -lt $deadline) { Write-Verbose "The refresh got no answer ($($inner.Message)); trying again in $($delays[$attempt]) s with the same token." Wait-VMetricInterval -Seconds $delays[$attempt] $attempt++ continue } throw (New-VMetricException -Message "Your session could not be refreshed: $($inner.Message) Run Connect-VMetric to sign in again." ` -Code 'SessionExpired' -Category AuthenticationError -InnerException $inner ` -RecommendedAction 'Run Connect-VMetric to sign in again.' -Terminating) } } Update-VMetricSessionToken -Session $Session -TokenResponse $token } finally { [System.Threading.Monitor]::Exit($Session.Lock) } } function Get-VMetricBearerToken { # A session's access token as text, with the Generation it belongs to, read under the session's Lock: a # refresh on another thread (a job reads its parent's session) disposes the token it replaces. A job's view # reads its parent's session (see New-VMetricJobSession). The text goes into the Authorization header and # nowhere else. [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory)] [System.Collections.IDictionary] $Session ) $source = $Session if ($Session.IsJobSnapshot -and $Session.TokenSource) { $source = $Session.TokenSource } [System.Threading.Monitor]::Enter($source.Lock) try { if (-not $source.AccessToken) { return $null } [pscustomobject]@{ Generation = [int]$source.Generation Text = ConvertFrom-VMetricSecureString -SecureString $source.AccessToken ExpiresAt = $source.AccessExpiresAt } } finally { [System.Threading.Monitor]::Exit($source.Lock) } } function Invoke-VMetricRequest { # A request to <API base>v1<Path>. Answers the parsed JSON body ($null for an empty one). A failure is a # terminating ErrorRecord whose exception is a VMetricException (StatusCode, Code, TraceId, Details). [CmdletBinding()] param( [ValidateSet('GET', 'POST', 'PUT', 'PATCH', 'DELETE')] [string] $Method = 'GET', # Below /api/v1: /devices, /deployments/resources/read. [Parameter(Mandatory)] [string] $Path, [System.Collections.IDictionary] $Query, # Serialized as JSON; a SecureString in it is sent as its text. [AllowNull()] [object] $Body, # The session to use; the current one by default. [System.Collections.IDictionary] $Session, # No Authorization header (the sign-in endpoints); requires -ApiUrl. [switch] $Anonymous, # The API's URL (https://api.example.com/api/) or an origin serving it under /api/. [Alias('Origin')] [string] $ApiUrl, [switch] $SkipCertificateCheck, # Do not wait out another deployment on 409 DEPLOYMENT_IN_PROGRESS. [switch] $NoConflictRetry, # Answer ordered dictionaries instead of PSCustomObjects. [switch] $AsHashtable, [int] $TimeoutSec = 120 ) $target = "$Method $Path" if (-not $Anonymous -and -not $Session) { try { $Session = Get-VMetricSession } catch { throw (New-VMetricErrorRecord -Exception $_.Exception -TargetObject $target) } } if ($Session) { $ApiUrl = $Session.ApiUrl } if (-not $ApiUrl) { throw (New-VMetricErrorRecord -TargetObject $target -Exception ( New-VMetricException -Message 'No address to send the request to.' -Code 'NoOrigin' -Category InvalidArgument)) } $skipCertificate = $SkipCertificateCheck.IsPresent -or ($Session -and [bool]$Session.SkipCertificateCheck) $uri = New-VMetricRequestUri -ApiUrl $ApiUrl -Path $Path -Query $Query $Origin = ([uri]$uri).GetLeftPart([System.UriPartial]::Authority) $json = $null $secrets = $null if ($PSBoundParameters.ContainsKey('Body')) { $json = ConvertTo-VMetricJson -InputObject $Body $secrets = Get-VMetricSecretText -InputObject $Body } $authRetried = $false $conflictWaited = 0.0 $conflictDelay = 1.0 $transientRetries = 0 while ($true) { $headers = @{ Accept = 'application/json' } $generation = 0 if (-not $Anonymous) { if (Test-VMetricAccessTokenExpiring -Session $Session) { Update-VMetricAccessToken -Session $Session } $bearer = Get-VMetricBearerToken -Session $Session if (-not $bearer) { if ($Session.IsJobSnapshot) { $ended = New-VMetricException -Message $script:VMetricJobSessionEnded -Code 'JobTokenExpired' -Category AuthenticationError -Terminating } else { $ended = New-VMetricException -Message 'Your session has ended. Run Connect-VMetric to sign in again.' -Code 'SessionExpired' ` -Category AuthenticationError -Terminating } throw (New-VMetricErrorRecord -TargetObject $target -Exception $ended) } $generation = $bearer.Generation $headers.Authorization = 'Bearer ' + $bearer.Text $bearer = $null } Write-Verbose "$Method $uri" $started = [System.Diagnostics.Stopwatch]::StartNew() try { $response = Invoke-VMetricHttp -Method $Method -Uri $uri -Headers $headers -Body $json -SkipCertificateCheck:$skipCertificate -TimeoutSec $TimeoutSec } catch { if (Test-VMetricFlowControl -ErrorRecord $_) { throw } $failure = $_.Exception if ($Method -eq 'GET' -and $transientRetries -lt 2) { $transientRetries++ Write-Verbose "No answer ($($failure.Message)); retrying in $transientRetries s." Wait-VMetricInterval -Seconds $transientRetries continue } $category = [System.Management.Automation.ErrorCategory]::ConnectionError $message = "Could not reach ${Origin}: $($failure.Message)" if ($failure -is [System.Threading.Tasks.TaskCanceledException] -or $failure -is [System.TimeoutException]) { $category = [System.Management.Automation.ErrorCategory]::OperationTimeout $message = "$Origin did not answer within $TimeoutSec seconds." } elseif ("$($failure.Message) $($failure.InnerException.Message)" -match 'SSL|certificate|TLS') { $category = [System.Management.Automation.ErrorCategory]::SecurityError } throw (New-VMetricErrorRecord -TargetObject $target -Exception ( New-VMetricException -Message $message -Code 'ConnectionFailed' -Category $category -InnerException $failure)) } $status = [int]$response.StatusCode Write-Verbose "$status in $($started.ElapsedMilliseconds) ms" if ($status -ge 300 -and $status -lt 400) { throw (New-VMetricErrorRecord -TargetObject $target -Exception ( New-VMetricRedirectException -Uri $uri -StatusCode $status -Location (Get-VMetricHeader -Response $response -Name 'Location'))) } if ($status -lt 300) { if ($status -eq 204 -or [string]::IsNullOrWhiteSpace($response.Content)) { return $null } try { $parsed = ConvertFrom-VMetricJson -Json $response.Content -AsHashtable:$AsHashtable } catch { throw (New-VMetricErrorRecord -TargetObject $target -Exception ( New-VMetricException -Message "$Origin did not answer as the VirtualMetric API ($status, not JSON). Check the address given to Connect-VMetric." ` -Code 'UnexpectedResponse' -Category InvalidResult -StatusCode $status)) } if ($parsed -is [array]) { return , $parsed } return $parsed } $apiError = ConvertFrom-VMetricErrorResponse -StatusCode $status -Content $response.Content if ($status -eq 401 -and -not $Anonymous -and -not $authRetried) { if ($Session.IsJobSnapshot) { # A job never refreshes; it tries once more when its parent's session has refreshed since. $current = Get-VMetricBearerToken -Session $Session if ($current -and $current.Generation -ne $generation) { $authRetried = $true Write-Verbose 'The access token was refused; the session has a newer one, trying again.' continue } } elseif ($Session.RefreshToken) { $authRetried = $true Write-Verbose 'The access token was refused; refreshing it and trying again.' Update-VMetricAccessToken -Session $Session -Force -UsedGeneration $generation continue } } if ($status -eq 409 -and $apiError.Code -eq 'DEPLOYMENT_IN_PROGRESS' -and -not $NoConflictRetry -and $conflictWaited -lt 30) { $delay = [Math]::Min($conflictDelay, 30 - $conflictWaited) Write-Verbose "Another deployment is running in this organization; trying again in $delay s." Wait-VMetricInterval -Seconds $delay $conflictWaited += $delay $conflictDelay = [Math]::Min($conflictDelay * 2, 8) continue } if ($Method -eq 'GET' -and $status -in @(502, 503, 504) -and $transientRetries -lt 2) { $transientRetries++ Write-Verbose "$status from the server; retrying in $transientRetries s." Wait-VMetricInterval -Seconds $transientRetries continue } $exception = New-VMetricApiException -StatusCode $status -ApiError $apiError -Secret $secrets if ($status -eq 401 -and -not $Anonymous -and $Session.IsJobSnapshot -and $Session.AuthMethod -ne 'ApiToken') { $exception = New-VMetricException -Message $script:VMetricJobSessionEnded -Code 'JobTokenExpired' -Category AuthenticationError ` -StatusCode 401 -TraceId $apiError.TraceId -Terminating } elseif ($status -eq 401 -and -not $Anonymous) { $exception.IsTerminating = $true if ($Session.AuthMethod -eq 'ApiToken') { $exception.RecommendedAction = 'The API token was refused: it may be expired or revoked. Run Connect-VMetric with a valid token.' } } throw (New-VMetricErrorRecord -Exception $exception -TargetObject $target) } } |