From 6ed50710d5be8cd7193ca4591293f38440fad33f Mon Sep 17 00:00:00 2001 From: Neelima Potharaj Date: Wed, 21 Feb 2024 15:54:48 -0800 Subject: [PATCH 01/12] update displayed error message --- cs/src/Management/TunnelManagementClient.cs | 3136 ++++++++++--------- 1 file changed, 1570 insertions(+), 1566 deletions(-) diff --git a/cs/src/Management/TunnelManagementClient.cs b/cs/src/Management/TunnelManagementClient.cs index 5ac87f7b..7d66d204 100644 --- a/cs/src/Management/TunnelManagementClient.cs +++ b/cs/src/Management/TunnelManagementClient.cs @@ -1,1566 +1,1570 @@ -// -// Copyright (c) Microsoft. All rights reserved. -// Licensed under the MIT license. -// - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Net.Http.Headers; -#if NET5_0_OR_GREATER -using System.Net.Http.Json; -#endif -using System.Threading; -using System.Threading.Tasks; -using System.Web; -using Microsoft.DevTunnels.Contracts; -using static Microsoft.DevTunnels.Contracts.TunnelContracts; - -namespace Microsoft.DevTunnels.Management -{ - /// - /// Implementation of a client that manages tunnels and tunnel ports via the tunnel service - /// management API. - /// - public class TunnelManagementClient : ITunnelManagementClient - { - private const string ApiV1Path = "/api/v1"; - private const string TunnelsV1ApiPath = ApiV1Path + "/tunnels"; - private const string SubjectsV1ApiPath = ApiV1Path + "/subjects"; - private const string UserLimitsV1ApiPath = ApiV1Path + "/userlimits"; - private const string TunnelsApiPath = "/tunnels"; - private const string SubjectsApiPath = "/subjects"; - private const string UserLimitsApiPath = "/userlimits"; - private const string EndpointsApiSubPath = "/endpoints"; - private const string PortsApiSubPath = "/ports"; - private const string ClustersApiPath = "/clusters"; - private const string ClustersV1ApiPath = ApiV1Path + "/clusters"; - private const string TunnelAuthenticationScheme = "Tunnel"; - private const string RequestIdHeaderName = "VsSaaS-Request-Id"; - private const string CheckAvailableSubPath = ":checkNameAvailability"; - private const int CreateNameRetries = 3; - - private static readonly string[] ManageAccessTokenScope = - new[] { TunnelAccessScopes.Manage }; - private static readonly string[] HostAccessTokenScope = - new[] { TunnelAccessScopes.Host }; - private static readonly string[] ManagePortsAccessTokenScopes = new[] - { - TunnelAccessScopes.Manage, - TunnelAccessScopes.ManagePorts, - TunnelAccessScopes.Host, - }; - private static readonly string[] ReadAccessTokenScopes = new[] - { - TunnelAccessScopes.Manage, - TunnelAccessScopes.ManagePorts, - TunnelAccessScopes.Host, - TunnelAccessScopes.Connect, - }; - - /// - /// Accepted management client api versions - /// - public string[] TunnelsApiVersions = - { - "2023-09-27-preview" - }; - - /// - /// Event raised to report tunnel management progress. - /// - public event EventHandler? ReportProgress; - - /// - /// ApiVersion that will be used if one is not specified - /// - public const ManagementApiVersions DefaultApiVersion = ManagementApiVersions.Version20230927Preview; - - private static readonly ProductInfoHeaderValue TunnelSdkUserAgent = - TunnelUserAgent.GetUserAgent(typeof(TunnelManagementClient).Assembly, "Dev-Tunnels-Service-CSharp-SDK")!; - - private readonly HttpClient httpClient; - private readonly Func> userTokenCallback; - - /// - /// Initializes a new instance of the class - /// with an optional client authentication callback. - /// - /// User agent. - /// Optional async callback for retrieving a client - /// authentication header, for AAD or GitHub user authentication. This may be null - /// for anonymous tunnel clients, or if tunnel access tokens will be specified via - /// . - /// Api version to use for tunnels requests, accepted - /// values are - public TunnelManagementClient( - ProductInfoHeaderValue userAgent, - Func>? userTokenCallback = null, - ManagementApiVersions apiVersion = DefaultApiVersion) - : this(new[] { userAgent }, userTokenCallback, tunnelServiceUri: null, httpHandler: null, apiVersion) - { - } - - /// - /// Initializes a new instance of the class - /// with an optional client authentication callback. - /// - /// User agent. Muiltiple user agents can be supplied in the - /// case that this SDK is used in a program, such as a CLI, that has users that want - /// to be differentiated. - /// Optional async callback for retrieving a client - /// authentication header, for AAD or GitHub user authentication. This may be null - /// for anonymous tunnel clients, or if tunnel access tokens will be specified via - /// . - /// Api version to use for tunnels requests, accepted - /// values are - public TunnelManagementClient( - ProductInfoHeaderValue[] userAgents, - Func>? userTokenCallback = null, - ManagementApiVersions apiVersion = DefaultApiVersion) - : this(userAgents, userTokenCallback, tunnelServiceUri: null, httpHandler: null, apiVersion) - { - } - - /// - /// Initializes a new instance of the class - /// with a client authentication callback, service URI, and HTTP handler. - /// - /// User agent. - /// Optional async callback for retrieving a client - /// authentication header value with access token, for AAD or GitHub user authentication. - /// This may be null for anonymous tunnel clients, or if tunnel access tokens will be - /// specified via . - /// Optional tunnel service URI (not including any path), - /// or null to use the default global service URI. - /// Optional HTTP handler or handler chain that will be invoked - /// for HTTPS requests to the tunnel service. The or - /// specified (or at the end of the chain) must have - /// automatic redirection disabled. The provided HTTP handler will not be disposed - /// by . - /// Api version to use for tunnels requests, accepted - /// values are - public TunnelManagementClient( - ProductInfoHeaderValue userAgent, - Func>? userTokenCallback = null, - Uri? tunnelServiceUri = null, - HttpMessageHandler? httpHandler = null, - ManagementApiVersions apiVersion = DefaultApiVersion) - : this(new[] { userAgent }, userTokenCallback, tunnelServiceUri, httpHandler, apiVersion) - { - } - - /// - /// Initializes a new instance of the class - /// with a client authentication callback, service URI, and HTTP handler. - /// - /// User agent. Muiltiple user agents can be supplied in the - /// case that this SDK is used in a program, such as a CLI, that has users that want - /// to be differentiated. - /// Optional async callback for retrieving a client - /// authentication header value with access token, for AAD or GitHub user authentication. - /// This may be null for anonymous tunnel clients, or if tunnel access tokens will be - /// specified via . - /// Optional tunnel service URI (not including any path), - /// or null to use the default global service URI. - /// Optional HTTP handler or handler chain that will be invoked - /// for HTTPS requests to the tunnel service. The or - /// specified (or at the end of the chain) must have - /// automatic redirection disabled. The provided HTTP handler will not be disposed - /// by . - /// Api version to use for tunnels requests, accepted - /// values are - public TunnelManagementClient( - ProductInfoHeaderValue[] userAgents, - Func>? userTokenCallback = null, - Uri? tunnelServiceUri = null, - HttpMessageHandler? httpHandler = null, - ManagementApiVersions apiVersionEnum = DefaultApiVersion) - { - Requires.NotNullEmptyOrNullElements(userAgents, nameof(userAgents)); - UserAgents = Requires.NotNull(userAgents, nameof(userAgents)); - var apiVersion = apiVersionEnum.ToVersionString(); - if (!string.IsNullOrEmpty(apiVersion) && !TunnelsApiVersions.Contains(apiVersion)) - { - throw new ArgumentException( - $"Invalid apiVersion, accpeted values are {string.Join(", ", TunnelsApiVersions)} "); - } - ApiVersion = apiVersion; - - this.userTokenCallback = userTokenCallback ?? - (() => Task.FromResult(null)); - - httpHandler ??= new SocketsHttpHandler - { - AllowAutoRedirect = false, - }; - ValidateHttpHandler(httpHandler); - - tunnelServiceUri ??= new Uri(TunnelServiceProperties.Production.ServiceUri); - if (!tunnelServiceUri.IsAbsoluteUri || tunnelServiceUri.PathAndQuery != "/") - { - throw new ArgumentException( - $"Invalid tunnel service URI: {tunnelServiceUri}", nameof(tunnelServiceUri)); - } - - // The `SocketsHttpHandler` or `HttpClientHandler` automatic redirection is disabled - // because they do not keep the Authorization header when redirecting. This handler - // will keep all headers when redirecting, and also supports switching the behavior - // per-request. - httpHandler = new FollowRedirectsHttpHandler(httpHandler); - - this.httpClient = new HttpClient(httpHandler, disposeHandler: false) - { - BaseAddress = tunnelServiceUri, - }; - } - - private static void ValidateHttpHandler(HttpMessageHandler httpHandler) - { - while (httpHandler is DelegatingHandler delegatingHandler) - { - httpHandler = delegatingHandler.InnerHandler!; - } - - if (httpHandler is SocketsHttpHandler socketsHandler) - { - if (socketsHandler.AllowAutoRedirect) - { - throw new ArgumentException( - "Tunnel client HTTP handler must have automatic redirection disabled.", - nameof(httpHandler)); - } - } - else if (httpHandler is HttpClientHandler httpClientHandler) - { - if (httpClientHandler.AllowAutoRedirect) - { - throw new ArgumentException( - "Tunnel client HTTP handler must have automatic redirection disabled.", - nameof(httpHandler)); - } - else if (httpClientHandler.UseDefaultCredentials) - { - throw new ArgumentException( - "Tunnel client HTTP handler must not use default credentials.", - nameof(httpHandler)); - } - } - else - { - throw new NotSupportedException( - $"Unsupported HTTP handler type: {httpHandler?.GetType().Name}. " + - "HTTP handler chain must consist of 0 or more DelegatingHandlers " + - "ending with a HttpClientHandler."); - } - } - - /// - /// Gets or sets additional headers that are added to every request. - /// - public IEnumerable>? AdditionalRequestHeaders { get; set; } - - private ProductInfoHeaderValue[] UserAgents { get; } - - private string? ApiVersion { get; } - - private string TunnelsPath - { - get { return string.IsNullOrEmpty(ApiVersion) ? TunnelsV1ApiPath : TunnelsApiPath; } - } - - private string ClustersPath - { - get { return string.IsNullOrEmpty(ApiVersion) ? ClustersV1ApiPath : ClustersApiPath; } - } - - private string SubjectsPath - { - get { return string.IsNullOrEmpty(ApiVersion) ? SubjectsV1ApiPath : SubjectsApiPath; } - } - - private string UserLimitsPath - { - get { return string.IsNullOrEmpty(ApiVersion) ? UserLimitsV1ApiPath : UserLimitsApiPath; } - } - - /// - /// Sends an HTTP request to the tunnel management API, targeting a specific tunnel. - /// - /// HTTP request method. - /// Tunnel that the request is targeting. - /// Required list of access scopes for tokens in - /// that could be used to - /// authorize the request. - /// Optional request sub-path relative to the tunnel. - /// Optional query string to append to the request. - /// Request options. - /// Cancellation token. - /// The expected result type. - /// Result of the request. - /// The request parameters were invalid. - /// The request was unauthorized or forbidden. - /// The WWW-Authenticate response header may be captured in the exception data. - /// The request would have caused a conflict - /// or exceeded a limit. - /// The request failed for some other - /// reason. - /// - /// This protected method enables subclasses to support additional tunnel management APIs. - /// Authentication will use one of the following, if available, in order of preference: - /// - on - /// - token provided by the user token callback - /// - token in that matches - /// one of the scopes in - /// - protected Task SendTunnelRequestAsync( - HttpMethod method, - Tunnel tunnel, - string[] accessTokenScopes, - string? path, - string? query, - TunnelRequestOptions? options, - CancellationToken cancellation) - { - return SendTunnelRequestAsync( - method, - tunnel, - accessTokenScopes, - path, - query, - options, - body: null, - cancellation); - } - - /// - /// Sends an HTTP request with body content to the tunnel management API, targeting a - /// specific tunnel. - /// - /// HTTP request method. - /// Tunnel that the request is targeting. - /// Required list of access scopes for tokens in - /// that could be used to - /// authorize the request. - /// Optional request sub-path relative to the tunnel. - /// Optional query string to append to the request. - /// Request options. - /// Request body object. - /// Cancellation token. - /// Whether the request is a create operation. - /// The request body type. - /// The expected result type. - /// Result of the request. - /// The request parameters were invalid. - /// The request was unauthorized or forbidden. - /// The WWW-Authenticate response header may be captured in the exception data. - /// The request would have caused a conflict - /// or exceeded a limit. - /// The request failed for some other - /// reason. - /// - /// This protected method enables subclasses to support additional tunnel management APIs. - /// Authentication will use one of the following, if available, in order of preference: - /// - on - /// - token provided by the user token callback - /// - token in that matches - /// one of the scopes in - /// - protected async Task SendTunnelRequestAsync( - HttpMethod method, - Tunnel tunnel, - string[] accessTokenScopes, - string? path, - string? query, - TunnelRequestOptions? options, - TRequest? body, - CancellationToken cancellation, - bool isCreate = false) - where TRequest : class - { - this.OnReportProgress(TunnelProgress.StartingRequestUri); - var uri = BuildTunnelUri(tunnel, path, query, options, isCreate); - this.OnReportProgress(TunnelProgress.StartingRequestConfig); - var authHeader = await GetAuthenticationHeaderAsync(tunnel, accessTokenScopes, options); - this.OnReportProgress(TunnelProgress.StartingSendTunnelRequest); - var result = await SendRequestAsync( - method, uri, options, authHeader, body, cancellation); - this.OnReportProgress(TunnelProgress.CompletedSendTunnelRequest); - return result; - } - - /// - /// Sends an HTTP request to the tunnel management API. - /// - /// HTTP request method. - /// Optional tunnel service cluster ID to direct the request to. - /// If unspecified, the request will use the global traffic-manager to find the nearest - /// cluster. - /// Required request path. - /// Optional query string to append to the request. - /// Request options. - /// Cancellation token. - /// The expected result type. - /// Result of the request. - /// The request parameters were invalid. - /// The request was unauthorized or forbidden. - /// The WWW-Authenticate response header may be captured in the exception data. - /// The request would have caused a conflict - /// or exceeded a limit. - /// The request failed for some other - /// reason. - /// - /// This protected method enables subclasses to support additional tunnel management APIs. - /// Authentication will use one of the following, if available, in order of preference: - /// - on - /// - token provided by the user token callback - /// - protected Task SendRequestAsync( - HttpMethod method, - string? clusterId, - string path, - string? query, - TunnelRequestOptions? options, - CancellationToken cancellation) - { - return SendRequestAsync( - method, - clusterId, - path, query, - options, - body: null, - cancellation); - } - - /// - /// Sends an HTTP request with body content to the tunnel management API. - /// - /// HTTP request method. - /// Optional tunnel service cluster ID to direct the request to. - /// If unspecified, the request will use the global traffic-manager to find the nearest - /// cluster. - /// Required request path. - /// Optional query string to append to the request. - /// Request options. - /// Request body object. - /// Cancellation token. - /// The request body type. - /// The expected result type. - /// Result of the request. - /// The request parameters were invalid. - /// The request was unauthorized or forbidden. - /// The WWW-Authenticate response header may be captured in the exception data. - /// The request would have caused a conflict - /// or exceeded a limit. - /// The request failed for some other - /// reason. - /// - /// This protected method enables subclasses to support additional tunnel management APIs. - /// Authentication will use one of the following, if available, in order of preference: - /// - on - /// - token provided by the user token callback - /// - protected async Task SendRequestAsync( - HttpMethod method, - string? clusterId, - string path, - string? query, - TunnelRequestOptions? options, - TRequest? body, - CancellationToken cancellation) - where TRequest : class - { - var uri = BuildUri(clusterId, path, query, options); - Tunnel? tunnel = null; - var authHeader = await GetAuthenticationHeaderAsync( - tunnel: tunnel, accessTokenScopes: null, options); - return await SendRequestAsync( - method, uri, options, authHeader, body, cancellation); - } - - /// - /// Sends an HTTP request with body content to the tunnel management API, with an - /// explicit authentication header value. - /// - private async Task SendRequestAsync( - HttpMethod method, - Uri uri, - TunnelRequestOptions? options, - AuthenticationHeaderValue? authHeader, - TRequest? body, - CancellationToken cancellation) - where TRequest : class - { - if (authHeader?.Scheme == TunnelAuthenticationSchemes.TunnelPlan) - { - var token = TunnelPlanTokenProperties.TryParse(authHeader.Parameter ?? string.Empty); - if (!string.IsNullOrEmpty(token?.ClusterId)) - { - var uriStr = uri.ToString().Replace("global.", $"{token.ClusterId}."); - uri = new Uri(uriStr); - } - } - - var request = new HttpRequestMessage(method, uri); - request.Headers.Authorization = authHeader; - - var emptyHeadersList = Enumerable.Empty>(); - var additionalHeaders = (AdditionalRequestHeaders ?? emptyHeadersList).Concat( - options?.AdditionalHeaders ?? emptyHeadersList); - - foreach (var headerNameAndValue in additionalHeaders) - { - request.Headers.Add(headerNameAndValue.Key, headerNameAndValue.Value); - } - - foreach (ProductInfoHeaderValue userAgent in UserAgents) - { - request.Headers.UserAgent.Add(userAgent); - } - - var localMachineHeaders = TunnelUserAgent.GetMachineHeaders(); - if(localMachineHeaders != null) - { - request.Headers.UserAgent.Add(localMachineHeaders); - } - - request.Headers.UserAgent.Add(TunnelSdkUserAgent); - - // Add Group Policies - const string policyRegKeyPath = @"Software\Policies\Microsoft\DevTunnels"; - var policyProvider = new PolicyProvider(policyRegKeyPath); - var policyHeaderValue = policyProvider.GetHeaderValue(); - if (!string.IsNullOrEmpty(policyHeaderValue)) - { - request.Headers.Add("User-Agent-Policies", policyHeaderValue); - } - - if (body != null) - { - request.Content = JsonContent.Create(body, null, JsonOptions); - } - - if (options?.FollowRedirects == false) - { - FollowRedirectsHttpHandler.SetFollowRedirectsEnabledForRequest(request, false); - } - - options?.SetRequestOptions(request); - - var response = await this.httpClient.SendAsync(request, cancellation); - var result = await ConvertResponseAsync( - method, - response, - cancellation); - return result; - } - - /// - /// Converts a tunnel service HTTP response to a result object (or exception). - /// - /// Type of result expected, or bool to just check for either success or - /// not-found. - /// Request method. - /// Response from a tunnel service request. - /// Cancellation token. - /// Result object of the requested type, or false if the response was 404 and - /// the result type is boolean, or null if a GET request for a non-array result object type - /// returned 404 Not Found. - /// The service returned a - /// 400 Bad Request response. - /// The service returned a 401 Unauthorized - /// or 403 Forbidden response. - private static async Task ConvertResponseAsync( - HttpMethod method, - HttpResponseMessage response, - CancellationToken cancellation) - { - Requires.NotNull(response, nameof(response)); - - // Requests that expect a boolean result just check for success or not-found result. - // GET requests that expect a single object result return null for not found result. - // GET requests that expect an array result should throw an error for not-found result - // because empty array was expected instead. - // PUT/POST/PATCH requests should also throw an error for not-found. - bool allowNotFound = typeof(T) == typeof(bool) || - ((method == HttpMethod.Get || method == HttpMethod.Head) && !typeof(T).IsArray && typeof(T) != typeof(TunnelPortListResponse) && typeof(T) != typeof(TunnelListByRegionResponse)); - - string? errorMessage = null; - Exception? innerException = null; - if (response.IsSuccessStatusCode) - { - if (response.StatusCode == HttpStatusCode.NoContent || response.Content == null) - { - return typeof(T) == typeof(bool) ? (T?)(object)(bool?)true : default; - } - - try - { - T? result = await response.Content.ReadFromJsonAsync( - JsonOptions, cancellation); - return result; - } - catch (Exception ex) - { - innerException = ex; - errorMessage = "Tunnel service response deserialization error: " + ex.Message; - } - } - - if (errorMessage == null && response.Content != null) - { - try - { - if ((int)response.StatusCode >= 400 && (int)response.StatusCode < 500) - { - // 4xx status responses may include standard ProblemDetails. - var problemDetails = await response.Content - .ReadFromJsonAsync(JsonOptions, cancellation); - if (!string.IsNullOrEmpty(problemDetails?.Title) || - !string.IsNullOrEmpty(problemDetails?.Detail)) - { - if (allowNotFound && response.StatusCode == HttpStatusCode.NotFound && - problemDetails.Detail == null) - { - return default; - } - - errorMessage = "Tunnel service error: " + - problemDetails!.Title + " " + problemDetails.Detail; - if (problemDetails.Errors != null) - { - foreach (var error in problemDetails.Errors) - { - var messages = string.Join(" ", error.Value); - errorMessage += $"\n{error.Key}: {messages}"; - } - } - } - } - else if ((int)response.StatusCode >= 500) - { - // 5xx status responses may include VS SaaS error details. - var errorDetails = await response.Content.ReadFromJsonAsync( - JsonOptions, cancellation); - if (!string.IsNullOrEmpty(errorDetails?.Message)) - { - errorMessage = "Tunnel service error: " + errorDetails!.Message; - if (!string.IsNullOrEmpty(errorDetails.StackTrace)) - { - errorMessage += "\n" + errorDetails.StackTrace; - } - } - } - } - catch (Exception ex) - { - // A default error message will be filled in below. - innerException = ex; - } - } - - errorMessage ??= "Tunnel service response status code: " + response.StatusCode; - - if (response.Headers.TryGetValues(RequestIdHeaderName, out var requestId)) - { - errorMessage += $"\nRequest ID: {requestId.First()}"; - } - - try - { - response.EnsureSuccessStatusCode(); - } - catch (HttpRequestException hrex) - { - switch (response.StatusCode) - { - case HttpStatusCode.BadRequest: - throw new ArgumentException(errorMessage, hrex); - - case HttpStatusCode.Unauthorized: - case HttpStatusCode.Forbidden: - // Enterprise Policies - if (response.Headers.Contains("X-Enterprise-Policy-Failure")) - { - var message = response.Content != null ? await response.Content.ReadAsStringAsync() : string.Empty; - errorMessage = message; - } - - var ex = new UnauthorizedAccessException(errorMessage, hrex); - - // The HttpResponseHeaders.WwwAuthenticate property does not correctly - // handle multiple values! Get the values by name instead. - if (response.Headers.TryGetValues( - "WWW-Authenticate", out var authHeaderValues)) - { - ex.SetAuthenticationSchemes(authHeaderValues); - } - - throw ex; - - case HttpStatusCode.NotFound: - case HttpStatusCode.Conflict: - case HttpStatusCode.PreconditionFailed: - case HttpStatusCode.TooManyRequests: - throw new InvalidOperationException(errorMessage, hrex); - - case HttpStatusCode.Redirect: - case HttpStatusCode.RedirectKeepVerb: - // Add the redirect location to the exception data. - // Normally the HTTP client should automatically follow redirects, - // but this allows tests to validate the service's redirection behavior - // when client auto redirection is disabled. - hrex.Data["Location"] = response.Headers.Location; - throw; - - default: throw; - } - } - - throw new Exception(errorMessage, innerException); - } - - /// - /// Error details that may be returned from the service with 500 status responses - /// (when in development mode). - /// - /// - /// Copied from Microsoft.VsSaaS.Common to avoid taking a dependency on that assembly. - /// - private class ErrorDetails - { - public string? Message { get; set; } - public string? StackTrace { get; set; } - } - - /// - public void Dispose() - { - this.httpClient.Dispose(); - } - - private Uri BuildUri( - string? clusterId, - string path, - string? query, - TunnelRequestOptions? options) - { - Requires.NotNullOrEmpty(path, nameof(path)); - - var baseAddress = this.httpClient.BaseAddress!; - var builder = new UriBuilder(baseAddress); - - if (!string.IsNullOrEmpty(clusterId) && - baseAddress.HostNameType == UriHostNameType.Dns) - { - if (baseAddress.Host != "localhost" && - !baseAddress.Host.StartsWith($"{clusterId}.")) - { - // A specific cluster ID was specified (while not running on localhost). - // Prepend the cluster ID to the hostname, and optionally strip a global prefix. - builder.Host = $"{clusterId}.{builder.Host}".Replace("global.", string.Empty); - } - else if (baseAddress.Scheme == "https" && - clusterId.StartsWith("localhost") && builder.Port % 10 > 0 && - ushort.TryParse(clusterId.Substring("localhost".Length), out var clusterNumber)) - { - // Local testing simulates clusters by running the service on multiple ports. - // Change the port number to match the cluster ID suffix. - if (clusterNumber > 0 && clusterNumber < 10) - { - builder.Port = builder.Port - (builder.Port % 10) + clusterNumber; - } - } - } - - if (options != null) - { - var optionsQuery = options.ToQueryString(); - if (!string.IsNullOrEmpty(optionsQuery)) - { - query = optionsQuery + - (!string.IsNullOrEmpty(query) ? '&' + query : string.Empty); - } - } - - builder.Path = path; - builder.Query = query; - return builder.Uri; - } - - private Uri BuildTunnelUri( - Tunnel tunnel, - string? path, - string? query, - TunnelRequestOptions? options, - bool isCreate = false) - { - Requires.NotNull(tunnel, nameof(tunnel)); - - string tunnelPath; - var pathBase = TunnelsPath; - if (!string.IsNullOrEmpty(tunnel.TunnelId) && (!string.IsNullOrEmpty(tunnel.ClusterId) || isCreate)) - { - tunnelPath = $"{pathBase}/{tunnel.TunnelId}"; - } - else - { - Requires.Argument( - !string.IsNullOrEmpty(tunnel.Name), - nameof(tunnel), - "Tunnel object must include either a name or tunnel ID and cluster ID."); - - if (string.IsNullOrEmpty(tunnel.Domain)) - { - - tunnelPath = $"{pathBase}/{tunnel.Name}"; - } - else - { - // Append the domain to the tunnel name. - tunnelPath = $"{pathBase}/{tunnel.Name}.{tunnel.Domain}"; - } - } - - return BuildUri( - tunnel.ClusterId, - tunnelPath + (!string.IsNullOrEmpty(path) ? path : string.Empty), - query, - options); - } - - private async Task GetAuthenticationHeaderAsync( - Tunnel? tunnel, - string[]? accessTokenScopes, - TunnelRequestOptions? options) - { - AuthenticationHeaderValue? authHeader = null; - - if (!string.IsNullOrEmpty(options?.AccessToken)) - { - authHeader = new AuthenticationHeaderValue( - TunnelAuthenticationScheme, options.AccessToken); - } - - if (authHeader == null) - { - authHeader = await this.userTokenCallback(); - } - - if (authHeader == null && tunnel?.AccessTokens != null && accessTokenScopes != null) - { - foreach (var scope in accessTokenScopes) - { - if (tunnel.TryGetAccessToken(scope, out string? accessToken)) - { - authHeader = new AuthenticationHeaderValue( - TunnelAuthenticationScheme, accessToken); - break; - } - } - } - - return authHeader; - } - - /// - public async Task ListTunnelsAsync( - string? clusterId, - string? domain, - TunnelRequestOptions? options, - bool? ownedTunnelsOnly, - CancellationToken cancellation) - { - var queryParams = new string?[] - { - string.IsNullOrEmpty(clusterId) ? "global=true" : null, - !string.IsNullOrEmpty(domain) ? $"domain={HttpUtility.UrlEncode(domain)}" : null, - !string.IsNullOrEmpty(ApiVersion) ? GetApiQuery() : null, - ownedTunnelsOnly == true ? "ownedTunnelsOnly=true" : null, - }; - var query = string.Join("&", queryParams.Where((p) => p != null)); - var result = await this.SendRequestAsync( - HttpMethod.Get, - clusterId, - TunnelsPath, - query, - options, - cancellation); - if (result?.Value != null) - { - return result.Value.Where(t => t.Value != null).SelectMany(t => t.Value!).ToArray(); - } - - return Array.Empty(); - } - - /// - [Obsolete("Use ListTunnelsAsync() method with TunnelRequestOptions.Labels instead.")] - public async Task SearchTunnelsAsync( - string[] labels, - bool requireAllLabels, - string? clusterId, - string? domain, - TunnelRequestOptions? options, - CancellationToken cancellation) - { - var queryParams = new string?[] - { - string.IsNullOrEmpty(clusterId) ? "global=true" : null, - !string.IsNullOrEmpty(domain) ? $"domain={HttpUtility.UrlEncode(domain)}" : null, - $"labels={string.Join(",", labels.Select(HttpUtility.UrlEncode))}", - $"allLabels={requireAllLabels}", - !string.IsNullOrEmpty(ApiVersion) ? GetApiQuery() : null, - }; - var query = string.Join("&", queryParams.Where((p) => p != null)); - var result = await this.SendRequestAsync( - HttpMethod.Get, - clusterId, - TunnelsPath, - query, - options, - cancellation); - return result!; - } - - /// - public async Task GetTunnelAsync( - Tunnel tunnel, - TunnelRequestOptions? options, - CancellationToken cancellation) - { - var result = await this.SendTunnelRequestAsync( - HttpMethod.Get, - tunnel, - ReadAccessTokenScopes, - path: null, - query: GetApiQuery(), - options, - cancellation); - PreserveAccessTokens(tunnel, result); - return result; - } - - /// - public async Task CreateTunnelAsync( - Tunnel tunnel, - TunnelRequestOptions? options, - CancellationToken cancellation) - { - Requires.NotNull(tunnel, nameof(tunnel)); - options ??= new TunnelRequestOptions(); - options.AdditionalHeaders ??= new List>(); - options.AdditionalHeaders = options.AdditionalHeaders.Append(new KeyValuePair("If-None-Match", "*")); - var tunnelId = tunnel.TunnelId; - var idGenerated = string.IsNullOrEmpty(tunnelId); - if (idGenerated) - { - tunnel.TunnelId = IdGeneration.GenerateTunnelId(); - } - for (int retries = 0; retries <= CreateNameRetries; retries++) - { - try - { - var result = await this.SendTunnelRequestAsync( - HttpMethod.Put, - tunnel, - ManageAccessTokenScope, - path: null, - query: GetApiQuery(), - options, - ConvertTunnelForRequest(tunnel), - cancellation, - true); - PreserveAccessTokens(tunnel, result); - return result!; - } - catch (UnauthorizedAccessException) when (idGenerated && retries < CreateNameRetries) // The tunnel ID was already taken. - { - tunnel.TunnelId = IdGeneration.GenerateTunnelId(); - } - } - - // This code is unreachable, but the compiler still requires it. - var result2 = await this.SendTunnelRequestAsync( - HttpMethod.Put, - tunnel, - ManageAccessTokenScope, - path: null, - query: GetApiQuery(), - options, - ConvertTunnelForRequest(tunnel), - cancellation, - true); - PreserveAccessTokens(tunnel, result2); - return result2!; - } - - /// - public async Task CreateOrUpdateTunnelAsync( - Tunnel tunnel, - TunnelRequestOptions? options, - CancellationToken cancellation) - { - Requires.NotNull(tunnel, nameof(tunnel)); - - var tunnelId = tunnel.TunnelId; - var idGenerated = string.IsNullOrEmpty(tunnelId); - if (idGenerated) - { - tunnel.TunnelId = IdGeneration.GenerateTunnelId(); - } - for (int retries = 0; retries <= CreateNameRetries; retries++) - { - try - { - var result = await this.SendTunnelRequestAsync( - HttpMethod.Put, - tunnel, - ManageAccessTokenScope, - path: null, - query: GetApiQuery(), - options, - ConvertTunnelForRequest(tunnel), - cancellation, - true); - PreserveAccessTokens(tunnel, result); - return result!; - } - catch (UnauthorizedAccessException) when (idGenerated && retries < 3) // The tunnel ID was already taken. - { - tunnel.TunnelId = IdGeneration.GenerateTunnelId(); - } - } - - // This code is unreachable, but the compiler still requires it. - var result2 = await this.SendTunnelRequestAsync( - HttpMethod.Put, - tunnel, - ManageAccessTokenScope, - path: null, - query: GetApiQuery(), - options, - ConvertTunnelForRequest(tunnel), - cancellation, - true); - PreserveAccessTokens(tunnel, result2); - return result2!; - } - - /// - public async Task UpdateTunnelAsync( - Tunnel tunnel, - TunnelRequestOptions? options, - CancellationToken cancellation) - { - options ??= new TunnelRequestOptions(); - options.AdditionalHeaders ??= new List>(); - options.AdditionalHeaders = options.AdditionalHeaders.Append(new KeyValuePair("If-Match", "*")); - var result = await this.SendTunnelRequestAsync( - HttpMethod.Put, - tunnel, - ManageAccessTokenScope, - path: null, - query: GetApiQuery(), - options, - ConvertTunnelForRequest(tunnel), - cancellation); - PreserveAccessTokens(tunnel, result); - return result!; - } - - /// - public async Task DeleteTunnelAsync( - Tunnel tunnel, - TunnelRequestOptions? options, - CancellationToken cancellation) - { - var result = await this.SendTunnelRequestAsync( - HttpMethod.Delete, - tunnel, - ManageAccessTokenScope, - path: null, - query: GetApiQuery(), - options, - cancellation); - return result; - } - - /// - public async Task UpdateTunnelEndpointAsync( - Tunnel tunnel, - TunnelEndpoint endpoint, - TunnelRequestOptions? options = null, - CancellationToken cancellation = default) - { - Requires.NotNull(endpoint, nameof(endpoint)); - Requires.NotNullOrEmpty(endpoint.HostId!, nameof(TunnelEndpoint.HostId)); - Requires.NotNullOrEmpty(endpoint.Id!, nameof(TunnelEndpoint.Id)); - - var path = $"{EndpointsApiSubPath}/{endpoint.Id}"; - var query = GetApiQuery(); - query += "&connectionMode=" + endpoint.ConnectionMode; - var result = (await this.SendTunnelRequestAsync( - HttpMethod.Put, - tunnel, - HostAccessTokenScope, - path, - query: query, - options, - endpoint, - cancellation))!; - - - if (tunnel.Endpoints != null) - { - // Also update the endpoint in the local tunnel object. - tunnel.Endpoints = tunnel.Endpoints - .Where((e) => e.HostId != endpoint.HostId || - e.ConnectionMode != endpoint.ConnectionMode) - .Append(result) - .ToArray(); - } - - return result; - } - - /// - public async Task DeleteTunnelEndpointsAsync( - Tunnel tunnel, - string id, - TunnelRequestOptions? options = null, - CancellationToken cancellation = default) - { - Requires.NotNullOrEmpty(id, nameof(id)); - - var path = $"{EndpointsApiSubPath}/{id}"; - var result = await this.SendTunnelRequestAsync( - HttpMethod.Delete, - tunnel, - HostAccessTokenScope, - path, - query: GetApiQuery(), - options, - cancellation); - - if (result && tunnel.Endpoints != null) - { - // Also delete the endpoint in the local tunnel object. - tunnel.Endpoints = tunnel.Endpoints - .Where((e) => e.Id != id) - .ToArray(); - } - - return result; - } - - /// - public async Task ListTunnelPortsAsync( - Tunnel tunnel, - TunnelRequestOptions? options, - CancellationToken cancellation) - { - var result = await this.SendTunnelRequestAsync( - HttpMethod.Get, - tunnel, - ReadAccessTokenScopes, - PortsApiSubPath, - query: GetApiQuery(), - options, - cancellation); - return result!.Value!; - } - - /// - public async Task GetTunnelPortAsync( - Tunnel tunnel, - ushort portNumber, - TunnelRequestOptions? options, - CancellationToken cancellation) - { - this.OnReportProgress(TunnelProgress.StartingGetTunnelPort); - var path = $"{PortsApiSubPath}/{portNumber}"; - var result = await this.SendTunnelRequestAsync( - HttpMethod.Get, - tunnel, - ReadAccessTokenScopes, - path, - query: GetApiQuery(), - options, - cancellation); - this.OnReportProgress(TunnelProgress.CompletedGetTunnelPort); - return result; - } - - /// - public async Task CreateTunnelPortAsync( - Tunnel tunnel, - TunnelPort tunnelPort, - TunnelRequestOptions? options, - CancellationToken cancellation) - { - Requires.NotNull(tunnelPort, nameof(tunnelPort)); - this.OnReportProgress(TunnelProgress.StartingCreateTunnelPort); - var path = $"{PortsApiSubPath}/{tunnelPort.PortNumber}"; - options ??= new TunnelRequestOptions(); - options.AdditionalHeaders ??= new List>(); - options.AdditionalHeaders = options.AdditionalHeaders.Append(new KeyValuePair("If-None-Match", "*")); - - var result = (await this.SendTunnelRequestAsync( - HttpMethod.Put, - tunnel, - ManagePortsAccessTokenScopes, - path, - query: GetApiQuery(), - options, - ConvertTunnelPortForRequest(tunnel, tunnelPort), - cancellation))!; - PreserveAccessTokens(tunnelPort, result); - - tunnel.Ports ??= new TunnelPort[0]; - - // Also add the port to the local tunnel object. - tunnel.Ports = tunnel.Ports - .Where((p) => p.PortNumber != tunnelPort.PortNumber) - .Append(result) - .OrderBy((p) => p.PortNumber) - .ToArray(); - this.OnReportProgress(TunnelProgress.CompletedCreateTunnelPort); - return result; - } - - /// - public async Task UpdateTunnelPortAsync( - Tunnel tunnel, - TunnelPort tunnelPort, - TunnelRequestOptions? options, - CancellationToken cancellation) - { - Requires.NotNull(tunnelPort, nameof(tunnelPort)); - options ??= new TunnelRequestOptions(); - options.AdditionalHeaders ??= new List>(); - options.AdditionalHeaders = options.AdditionalHeaders.Append(new KeyValuePair("If-Match", "*")); - - if (tunnelPort.ClusterId != null && tunnel.ClusterId != null && - tunnelPort.ClusterId != tunnel.ClusterId) - { - throw new ArgumentException( - "Tunnel port cluster ID is not consistent.", nameof(tunnelPort)); - } - - var portNumber = tunnelPort.PortNumber; - var path = $"{PortsApiSubPath}/{portNumber}"; - var result = (await this.SendTunnelRequestAsync( - HttpMethod.Put, - tunnel, - ManagePortsAccessTokenScopes, - path, - query: GetApiQuery(), - options, - ConvertTunnelPortForRequest(tunnel, tunnelPort), - cancellation))!; - PreserveAccessTokens(tunnelPort, result); - - tunnel.Ports ??= new TunnelPort[0]; - - // Also add the port to the local tunnel object. - tunnel.Ports = tunnel.Ports - .Where((p) => p.PortNumber != tunnelPort.PortNumber) - .Append(result) - .OrderBy((p) => p.PortNumber) - .ToArray(); - - - return result; - } - - /// - public async Task CreateOrUpdateTunnelPortAsync( - Tunnel tunnel, - TunnelPort tunnelPort, - TunnelRequestOptions? options, - CancellationToken cancellation) - { - Requires.NotNull(tunnelPort, nameof(tunnelPort)); - - if (tunnelPort.ClusterId != null && tunnel.ClusterId != null && - tunnelPort.ClusterId != tunnel.ClusterId) - { - throw new ArgumentException( - "Tunnel port cluster ID is not consistent.", nameof(tunnelPort)); - } - - var portNumber = tunnelPort.PortNumber; - var path = $"{PortsApiSubPath}/{portNumber}"; - var result = (await this.SendTunnelRequestAsync( - HttpMethod.Put, - tunnel, - ManagePortsAccessTokenScopes, - path, - query: GetApiQuery(), - options, - ConvertTunnelPortForRequest(tunnel, tunnelPort), - cancellation))!; - PreserveAccessTokens(tunnelPort, result); - - tunnel.Ports ??= new TunnelPort[0]; - - // Also add the port to the local tunnel object. - tunnel.Ports = tunnel.Ports - .Where((p) => p.PortNumber != tunnelPort.PortNumber) - .Append(result) - .OrderBy((p) => p.PortNumber) - .ToArray(); - - - return result; - } - - /// - public async Task DeleteTunnelPortAsync( - Tunnel tunnel, - ushort portNumber, - TunnelRequestOptions? options, - CancellationToken cancellation) - { - var path = $"{PortsApiSubPath}/{portNumber}"; - var result = await this.SendTunnelRequestAsync( - HttpMethod.Delete, - tunnel, - ManagePortsAccessTokenScopes, - path, - query: GetApiQuery(), - options, - cancellation); - - if (result && tunnel.Ports != null) - { - // Also delete the port in the local tunnel object. - tunnel.Ports = tunnel.Ports - .Where((p) => p.PortNumber != portNumber) - .OrderBy((p) => p.PortNumber) - .ToArray(); - } - - return result; - } - - /// - /// Event fired when a tunnel progress event has been reported. - /// - protected virtual void OnReportProgress(TunnelProgress progress) - { - if (ReportProgress is EventHandler handler) - { - var args = new TunnelReportProgressEventArgs(progress.ToString()); - handler.Invoke(this, args); - } - } - - /// - /// Removes read-only properties like tokens and status from create/update requests. - /// - private Tunnel ConvertTunnelForRequest(Tunnel tunnel) - { - return new Tunnel - { - TunnelId = tunnel.TunnelId, - Name = tunnel.Name, - Domain = tunnel.Domain, - Description = tunnel.Description, - Labels = tunnel.Labels, - CustomExpiration = tunnel.CustomExpiration, - Options = tunnel.Options, - AccessControl = tunnel.AccessControl == null ? null : new TunnelAccessControl( - tunnel.AccessControl.Where((ace) => !ace.IsInherited)), - Endpoints = tunnel.Endpoints, - Ports = tunnel.Ports? - .Select((p) => ConvertTunnelPortForRequest(tunnel, p)) - .ToArray(), - }; - } - - /// - /// Removes read-only properties like tokens and status from create/update requests. - /// - private TunnelPort ConvertTunnelPortForRequest(Tunnel tunnel, TunnelPort tunnelPort) - { - if (tunnelPort.ClusterId != null && tunnel.ClusterId != null && - tunnelPort.ClusterId != tunnel.ClusterId) - { - throw new ArgumentException( - "Tunnel port cluster ID does not match tunnel.", nameof(tunnelPort)); - } - - if (tunnelPort.TunnelId != null && tunnel.TunnelId != null && - tunnelPort.TunnelId != tunnel.TunnelId) - { - throw new ArgumentException( - "Tunnel port tunnel ID does not match tunnel.", nameof(tunnelPort)); - } - - return new TunnelPort - { - PortNumber = tunnelPort.PortNumber, - Protocol = tunnelPort.Protocol, - IsDefault = tunnelPort.IsDefault, - Description = tunnelPort.Description, - Labels = tunnelPort.Labels, - Options = tunnelPort.Options, - AccessControl = tunnelPort.AccessControl == null ? null : new TunnelAccessControl( - tunnelPort.AccessControl.Where((ace) => !ace.IsInherited)), - SshUser = tunnelPort.SshUser, - }; - } - - /// - public async Task FormatSubjectsAsync( - TunnelAccessSubject[] subjects, - TunnelRequestOptions? options = null, - CancellationToken cancellation = default) - { - Requires.NotNull(subjects, nameof(subjects)); - - if (subjects.Length == 0) - { - return subjects; - } - - var formattedSubjects = await SendRequestAsync - ( - HttpMethod.Post, - clusterId: null, - SubjectsPath + "/format", - query: GetApiQuery(), - options, - subjects, - cancellation); - return formattedSubjects!; - } - - /// - public async Task ResolveSubjectsAsync( - TunnelAccessSubject[] subjects, - TunnelRequestOptions? options = null, - CancellationToken cancellation = default) - { - Requires.NotNull(subjects, nameof(subjects)); - - if (subjects.Length == 0) - { - return subjects; - } - - var resolvedSubjects = await SendRequestAsync - ( - HttpMethod.Post, - clusterId: null, - SubjectsPath + "/resolve", - query: GetApiQuery(), - options, - subjects, - cancellation); - return resolvedSubjects!; - } - - /// - public async Task ListUserLimitsAsync(CancellationToken cancellation = default) - { - var userLimits = await SendRequestAsync( - HttpMethod.Get, - clusterId: null, - UserLimitsPath, - query: GetApiQuery(), - options: null, - cancellation); - return userLimits!; - } - - /// - public async Task ListClustersAsync(CancellationToken cancellation) { - var baseAddress = this.httpClient.BaseAddress!; - var builder = new UriBuilder(baseAddress); - builder.Path = ClustersPath; - builder.Query = GetApiQuery(); - var clusterDetails = await SendRequestAsync( - HttpMethod.Get, - builder.Uri, - options: null, - authHeader: null, - body: null, - cancellation); - return clusterDetails!; - } - - /// - public async Task CheckNameAvailabilityAsync( - string name, - CancellationToken cancellation = default) - { - name = Uri.EscapeDataString(name); - Requires.NotNull(name, nameof(name)); - return await this.SendRequestAsync( - HttpMethod.Get, - clusterId: null, - TunnelsPath + "/" + name + CheckAvailableSubPath, - query: GetApiQuery(), - options: null, - cancellation - ); - } - - /// - /// Gets required query string parmeters - /// - /// Query string - protected virtual string? GetApiQuery() - { - return string.IsNullOrEmpty(ApiVersion) ? null : $"api-version={ApiVersion}"; - } - - /// - /// Copy access tokens from the request object to the result object, except for any - /// tokens that were refreshed by the request. - /// - /// - /// This intentionally does not check whether any existing tokens are expired. So - /// expired tokens may be preserved also, if not refreshed. This allows for better - /// diagnostics in that case. - /// - private static void PreserveAccessTokens(Tunnel requestTunnel, Tunnel? resultTunnel) - { - if (requestTunnel.AccessTokens != null && resultTunnel != null) - { - resultTunnel.AccessTokens ??= new Dictionary(); - foreach (var scopeAndToken in requestTunnel.AccessTokens) - { - if (!resultTunnel.AccessTokens.ContainsKey(scopeAndToken.Key)) - { - resultTunnel.AccessTokens[scopeAndToken.Key] = scopeAndToken.Value; - } - } - } - } - - /// - /// Copy access tokens from the request object to the result object, except for any - /// tokens that were refreshed by the request. - /// - private static void PreserveAccessTokens(TunnelPort requestPort, TunnelPort? resultPort) - { - if (requestPort.AccessTokens != null && resultPort != null) - { - resultPort.AccessTokens ??= new Dictionary(); - foreach (var scopeAndToken in requestPort.AccessTokens) - { - if (!resultPort.AccessTokens.ContainsKey(scopeAndToken.Key)) - { - resultPort.AccessTokens[scopeAndToken.Key] = scopeAndToken.Value; - } - } - } - } - } -} +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. +// + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text.Json; + +#if NET5_0_OR_GREATER +using System.Net.Http.Json; +#endif +using System.Threading; +using System.Threading.Tasks; +using System.Web; +using Microsoft.DevTunnels.Contracts; +using static Microsoft.DevTunnels.Contracts.TunnelContracts; + +namespace Microsoft.DevTunnels.Management +{ + /// + /// Implementation of a client that manages tunnels and tunnel ports via the tunnel service + /// management API. + /// + public class TunnelManagementClient : ITunnelManagementClient + { + private const string ApiV1Path = "/api/v1"; + private const string TunnelsV1ApiPath = ApiV1Path + "/tunnels"; + private const string SubjectsV1ApiPath = ApiV1Path + "/subjects"; + private const string UserLimitsV1ApiPath = ApiV1Path + "/userlimits"; + private const string TunnelsApiPath = "/tunnels"; + private const string SubjectsApiPath = "/subjects"; + private const string UserLimitsApiPath = "/userlimits"; + private const string EndpointsApiSubPath = "/endpoints"; + private const string PortsApiSubPath = "/ports"; + private const string ClustersApiPath = "/clusters"; + private const string ClustersV1ApiPath = ApiV1Path + "/clusters"; + private const string TunnelAuthenticationScheme = "Tunnel"; + private const string RequestIdHeaderName = "VsSaaS-Request-Id"; + private const string CheckAvailableSubPath = ":checkNameAvailability"; + private const int CreateNameRetries = 3; + + private static readonly string[] ManageAccessTokenScope = + new[] { TunnelAccessScopes.Manage }; + private static readonly string[] HostAccessTokenScope = + new[] { TunnelAccessScopes.Host }; + private static readonly string[] ManagePortsAccessTokenScopes = new[] + { + TunnelAccessScopes.Manage, + TunnelAccessScopes.ManagePorts, + TunnelAccessScopes.Host, + }; + private static readonly string[] ReadAccessTokenScopes = new[] + { + TunnelAccessScopes.Manage, + TunnelAccessScopes.ManagePorts, + TunnelAccessScopes.Host, + TunnelAccessScopes.Connect, + }; + + /// + /// Accepted management client api versions + /// + public string[] TunnelsApiVersions = + { + "2023-09-27-preview" + }; + + /// + /// Event raised to report tunnel management progress. + /// + public event EventHandler? ReportProgress; + + /// + /// ApiVersion that will be used if one is not specified + /// + public const ManagementApiVersions DefaultApiVersion = ManagementApiVersions.Version20230927Preview; + + private static readonly ProductInfoHeaderValue TunnelSdkUserAgent = + TunnelUserAgent.GetUserAgent(typeof(TunnelManagementClient).Assembly, "Dev-Tunnels-Service-CSharp-SDK")!; + + private readonly HttpClient httpClient; + private readonly Func> userTokenCallback; + + /// + /// Initializes a new instance of the class + /// with an optional client authentication callback. + /// + /// User agent. + /// Optional async callback for retrieving a client + /// authentication header, for AAD or GitHub user authentication. This may be null + /// for anonymous tunnel clients, or if tunnel access tokens will be specified via + /// . + /// Api version to use for tunnels requests, accepted + /// values are + public TunnelManagementClient( + ProductInfoHeaderValue userAgent, + Func>? userTokenCallback = null, + ManagementApiVersions apiVersion = DefaultApiVersion) + : this(new[] { userAgent }, userTokenCallback, tunnelServiceUri: null, httpHandler: null, apiVersion) + { + } + + /// + /// Initializes a new instance of the class + /// with an optional client authentication callback. + /// + /// User agent. Muiltiple user agents can be supplied in the + /// case that this SDK is used in a program, such as a CLI, that has users that want + /// to be differentiated. + /// Optional async callback for retrieving a client + /// authentication header, for AAD or GitHub user authentication. This may be null + /// for anonymous tunnel clients, or if tunnel access tokens will be specified via + /// . + /// Api version to use for tunnels requests, accepted + /// values are + public TunnelManagementClient( + ProductInfoHeaderValue[] userAgents, + Func>? userTokenCallback = null, + ManagementApiVersions apiVersion = DefaultApiVersion) + : this(userAgents, userTokenCallback, tunnelServiceUri: null, httpHandler: null, apiVersion) + { + } + + /// + /// Initializes a new instance of the class + /// with a client authentication callback, service URI, and HTTP handler. + /// + /// User agent. + /// Optional async callback for retrieving a client + /// authentication header value with access token, for AAD or GitHub user authentication. + /// This may be null for anonymous tunnel clients, or if tunnel access tokens will be + /// specified via . + /// Optional tunnel service URI (not including any path), + /// or null to use the default global service URI. + /// Optional HTTP handler or handler chain that will be invoked + /// for HTTPS requests to the tunnel service. The or + /// specified (or at the end of the chain) must have + /// automatic redirection disabled. The provided HTTP handler will not be disposed + /// by . + /// Api version to use for tunnels requests, accepted + /// values are + public TunnelManagementClient( + ProductInfoHeaderValue userAgent, + Func>? userTokenCallback = null, + Uri? tunnelServiceUri = null, + HttpMessageHandler? httpHandler = null, + ManagementApiVersions apiVersion = DefaultApiVersion) + : this(new[] { userAgent }, userTokenCallback, tunnelServiceUri, httpHandler, apiVersion) + { + } + + /// + /// Initializes a new instance of the class + /// with a client authentication callback, service URI, and HTTP handler. + /// + /// User agent. Muiltiple user agents can be supplied in the + /// case that this SDK is used in a program, such as a CLI, that has users that want + /// to be differentiated. + /// Optional async callback for retrieving a client + /// authentication header value with access token, for AAD or GitHub user authentication. + /// This may be null for anonymous tunnel clients, or if tunnel access tokens will be + /// specified via . + /// Optional tunnel service URI (not including any path), + /// or null to use the default global service URI. + /// Optional HTTP handler or handler chain that will be invoked + /// for HTTPS requests to the tunnel service. The or + /// specified (or at the end of the chain) must have + /// automatic redirection disabled. The provided HTTP handler will not be disposed + /// by . + /// Api version to use for tunnels requests, accepted + /// values are + public TunnelManagementClient( + ProductInfoHeaderValue[] userAgents, + Func>? userTokenCallback = null, + Uri? tunnelServiceUri = null, + HttpMessageHandler? httpHandler = null, + ManagementApiVersions apiVersionEnum = DefaultApiVersion) + { + Requires.NotNullEmptyOrNullElements(userAgents, nameof(userAgents)); + UserAgents = Requires.NotNull(userAgents, nameof(userAgents)); + var apiVersion = apiVersionEnum.ToVersionString(); + if (!string.IsNullOrEmpty(apiVersion) && !TunnelsApiVersions.Contains(apiVersion)) + { + throw new ArgumentException( + $"Invalid apiVersion, accpeted values are {string.Join(", ", TunnelsApiVersions)} "); + } + ApiVersion = apiVersion; + + this.userTokenCallback = userTokenCallback ?? + (() => Task.FromResult(null)); + + httpHandler ??= new SocketsHttpHandler + { + AllowAutoRedirect = false, + }; + ValidateHttpHandler(httpHandler); + + tunnelServiceUri ??= new Uri(TunnelServiceProperties.Production.ServiceUri); + if (!tunnelServiceUri.IsAbsoluteUri || tunnelServiceUri.PathAndQuery != "/") + { + throw new ArgumentException( + $"Invalid tunnel service URI: {tunnelServiceUri}", nameof(tunnelServiceUri)); + } + + // The `SocketsHttpHandler` or `HttpClientHandler` automatic redirection is disabled + // because they do not keep the Authorization header when redirecting. This handler + // will keep all headers when redirecting, and also supports switching the behavior + // per-request. + httpHandler = new FollowRedirectsHttpHandler(httpHandler); + + this.httpClient = new HttpClient(httpHandler, disposeHandler: false) + { + BaseAddress = tunnelServiceUri, + }; + } + + private static void ValidateHttpHandler(HttpMessageHandler httpHandler) + { + while (httpHandler is DelegatingHandler delegatingHandler) + { + httpHandler = delegatingHandler.InnerHandler!; + } + + if (httpHandler is SocketsHttpHandler socketsHandler) + { + if (socketsHandler.AllowAutoRedirect) + { + throw new ArgumentException( + "Tunnel client HTTP handler must have automatic redirection disabled.", + nameof(httpHandler)); + } + } + else if (httpHandler is HttpClientHandler httpClientHandler) + { + if (httpClientHandler.AllowAutoRedirect) + { + throw new ArgumentException( + "Tunnel client HTTP handler must have automatic redirection disabled.", + nameof(httpHandler)); + } + else if (httpClientHandler.UseDefaultCredentials) + { + throw new ArgumentException( + "Tunnel client HTTP handler must not use default credentials.", + nameof(httpHandler)); + } + } + else + { + throw new NotSupportedException( + $"Unsupported HTTP handler type: {httpHandler?.GetType().Name}. " + + "HTTP handler chain must consist of 0 or more DelegatingHandlers " + + "ending with a HttpClientHandler."); + } + } + + /// + /// Gets or sets additional headers that are added to every request. + /// + public IEnumerable>? AdditionalRequestHeaders { get; set; } + + private ProductInfoHeaderValue[] UserAgents { get; } + + private string? ApiVersion { get; } + + private string TunnelsPath + { + get { return string.IsNullOrEmpty(ApiVersion) ? TunnelsV1ApiPath : TunnelsApiPath; } + } + + private string ClustersPath + { + get { return string.IsNullOrEmpty(ApiVersion) ? ClustersV1ApiPath : ClustersApiPath; } + } + + private string SubjectsPath + { + get { return string.IsNullOrEmpty(ApiVersion) ? SubjectsV1ApiPath : SubjectsApiPath; } + } + + private string UserLimitsPath + { + get { return string.IsNullOrEmpty(ApiVersion) ? UserLimitsV1ApiPath : UserLimitsApiPath; } + } + + /// + /// Sends an HTTP request to the tunnel management API, targeting a specific tunnel. + /// + /// HTTP request method. + /// Tunnel that the request is targeting. + /// Required list of access scopes for tokens in + /// that could be used to + /// authorize the request. + /// Optional request sub-path relative to the tunnel. + /// Optional query string to append to the request. + /// Request options. + /// Cancellation token. + /// The expected result type. + /// Result of the request. + /// The request parameters were invalid. + /// The request was unauthorized or forbidden. + /// The WWW-Authenticate response header may be captured in the exception data. + /// The request would have caused a conflict + /// or exceeded a limit. + /// The request failed for some other + /// reason. + /// + /// This protected method enables subclasses to support additional tunnel management APIs. + /// Authentication will use one of the following, if available, in order of preference: + /// - on + /// - token provided by the user token callback + /// - token in that matches + /// one of the scopes in + /// + protected Task SendTunnelRequestAsync( + HttpMethod method, + Tunnel tunnel, + string[] accessTokenScopes, + string? path, + string? query, + TunnelRequestOptions? options, + CancellationToken cancellation) + { + return SendTunnelRequestAsync( + method, + tunnel, + accessTokenScopes, + path, + query, + options, + body: null, + cancellation); + } + + /// + /// Sends an HTTP request with body content to the tunnel management API, targeting a + /// specific tunnel. + /// + /// HTTP request method. + /// Tunnel that the request is targeting. + /// Required list of access scopes for tokens in + /// that could be used to + /// authorize the request. + /// Optional request sub-path relative to the tunnel. + /// Optional query string to append to the request. + /// Request options. + /// Request body object. + /// Cancellation token. + /// Whether the request is a create operation. + /// The request body type. + /// The expected result type. + /// Result of the request. + /// The request parameters were invalid. + /// The request was unauthorized or forbidden. + /// The WWW-Authenticate response header may be captured in the exception data. + /// The request would have caused a conflict + /// or exceeded a limit. + /// The request failed for some other + /// reason. + /// + /// This protected method enables subclasses to support additional tunnel management APIs. + /// Authentication will use one of the following, if available, in order of preference: + /// - on + /// - token provided by the user token callback + /// - token in that matches + /// one of the scopes in + /// + protected async Task SendTunnelRequestAsync( + HttpMethod method, + Tunnel tunnel, + string[] accessTokenScopes, + string? path, + string? query, + TunnelRequestOptions? options, + TRequest? body, + CancellationToken cancellation, + bool isCreate = false) + where TRequest : class + { + this.OnReportProgress(TunnelProgress.StartingRequestUri); + var uri = BuildTunnelUri(tunnel, path, query, options, isCreate); + this.OnReportProgress(TunnelProgress.StartingRequestConfig); + var authHeader = await GetAuthenticationHeaderAsync(tunnel, accessTokenScopes, options); + this.OnReportProgress(TunnelProgress.StartingSendTunnelRequest); + var result = await SendRequestAsync( + method, uri, options, authHeader, body, cancellation); + this.OnReportProgress(TunnelProgress.CompletedSendTunnelRequest); + return result; + } + + /// + /// Sends an HTTP request to the tunnel management API. + /// + /// HTTP request method. + /// Optional tunnel service cluster ID to direct the request to. + /// If unspecified, the request will use the global traffic-manager to find the nearest + /// cluster. + /// Required request path. + /// Optional query string to append to the request. + /// Request options. + /// Cancellation token. + /// The expected result type. + /// Result of the request. + /// The request parameters were invalid. + /// The request was unauthorized or forbidden. + /// The WWW-Authenticate response header may be captured in the exception data. + /// The request would have caused a conflict + /// or exceeded a limit. + /// The request failed for some other + /// reason. + /// + /// This protected method enables subclasses to support additional tunnel management APIs. + /// Authentication will use one of the following, if available, in order of preference: + /// - on + /// - token provided by the user token callback + /// + protected Task SendRequestAsync( + HttpMethod method, + string? clusterId, + string path, + string? query, + TunnelRequestOptions? options, + CancellationToken cancellation) + { + return SendRequestAsync( + method, + clusterId, + path, query, + options, + body: null, + cancellation); + } + + /// + /// Sends an HTTP request with body content to the tunnel management API. + /// + /// HTTP request method. + /// Optional tunnel service cluster ID to direct the request to. + /// If unspecified, the request will use the global traffic-manager to find the nearest + /// cluster. + /// Required request path. + /// Optional query string to append to the request. + /// Request options. + /// Request body object. + /// Cancellation token. + /// The request body type. + /// The expected result type. + /// Result of the request. + /// The request parameters were invalid. + /// The request was unauthorized or forbidden. + /// The WWW-Authenticate response header may be captured in the exception data. + /// The request would have caused a conflict + /// or exceeded a limit. + /// The request failed for some other + /// reason. + /// + /// This protected method enables subclasses to support additional tunnel management APIs. + /// Authentication will use one of the following, if available, in order of preference: + /// - on + /// - token provided by the user token callback + /// + protected async Task SendRequestAsync( + HttpMethod method, + string? clusterId, + string path, + string? query, + TunnelRequestOptions? options, + TRequest? body, + CancellationToken cancellation) + where TRequest : class + { + var uri = BuildUri(clusterId, path, query, options); + Tunnel? tunnel = null; + var authHeader = await GetAuthenticationHeaderAsync( + tunnel: tunnel, accessTokenScopes: null, options); + return await SendRequestAsync( + method, uri, options, authHeader, body, cancellation); + } + + /// + /// Sends an HTTP request with body content to the tunnel management API, with an + /// explicit authentication header value. + /// + private async Task SendRequestAsync( + HttpMethod method, + Uri uri, + TunnelRequestOptions? options, + AuthenticationHeaderValue? authHeader, + TRequest? body, + CancellationToken cancellation) + where TRequest : class + { + if (authHeader?.Scheme == TunnelAuthenticationSchemes.TunnelPlan) + { + var token = TunnelPlanTokenProperties.TryParse(authHeader.Parameter ?? string.Empty); + if (!string.IsNullOrEmpty(token?.ClusterId)) + { + var uriStr = uri.ToString().Replace("global.", $"{token.ClusterId}."); + uri = new Uri(uriStr); + } + } + + var request = new HttpRequestMessage(method, uri); + request.Headers.Authorization = authHeader; + + var emptyHeadersList = Enumerable.Empty>(); + var additionalHeaders = (AdditionalRequestHeaders ?? emptyHeadersList).Concat( + options?.AdditionalHeaders ?? emptyHeadersList); + + foreach (var headerNameAndValue in additionalHeaders) + { + request.Headers.Add(headerNameAndValue.Key, headerNameAndValue.Value); + } + + foreach (ProductInfoHeaderValue userAgent in UserAgents) + { + request.Headers.UserAgent.Add(userAgent); + } + + var localMachineHeaders = TunnelUserAgent.GetMachineHeaders(); + if(localMachineHeaders != null) + { + request.Headers.UserAgent.Add(localMachineHeaders); + } + + request.Headers.UserAgent.Add(TunnelSdkUserAgent); + + // Add Group Policies + const string policyRegKeyPath = @"Software\Policies\Microsoft\DevTunnels"; + var policyProvider = new PolicyProvider(policyRegKeyPath); + var policyHeaderValue = policyProvider.GetHeaderValue(); + if (!string.IsNullOrEmpty(policyHeaderValue)) + { + request.Headers.Add("User-Agent-Policies", policyHeaderValue); + } + + if (body != null) + { + request.Content = JsonContent.Create(body, null, JsonOptions); + } + + if (options?.FollowRedirects == false) + { + FollowRedirectsHttpHandler.SetFollowRedirectsEnabledForRequest(request, false); + } + + options?.SetRequestOptions(request); + + var response = await this.httpClient.SendAsync(request, cancellation); + var result = await ConvertResponseAsync( + method, + response, + cancellation); + return result; + } + + /// + /// Converts a tunnel service HTTP response to a result object (or exception). + /// + /// Type of result expected, or bool to just check for either success or + /// not-found. + /// Request method. + /// Response from a tunnel service request. + /// Cancellation token. + /// Result object of the requested type, or false if the response was 404 and + /// the result type is boolean, or null if a GET request for a non-array result object type + /// returned 404 Not Found. + /// The service returned a + /// 400 Bad Request response. + /// The service returned a 401 Unauthorized + /// or 403 Forbidden response. + private static async Task ConvertResponseAsync( + HttpMethod method, + HttpResponseMessage response, + CancellationToken cancellation) + { + Requires.NotNull(response, nameof(response)); + + // Requests that expect a boolean result just check for success or not-found result. + // GET requests that expect a single object result return null for not found result. + // GET requests that expect an array result should throw an error for not-found result + // because empty array was expected instead. + // PUT/POST/PATCH requests should also throw an error for not-found. + bool allowNotFound = typeof(T) == typeof(bool) || + ((method == HttpMethod.Get || method == HttpMethod.Head) && !typeof(T).IsArray && typeof(T) != typeof(TunnelPortListResponse) && typeof(T) != typeof(TunnelListByRegionResponse)); + + string? errorMessage = null; + Exception? innerException = null; + if (response.IsSuccessStatusCode) + { + if (response.StatusCode == HttpStatusCode.NoContent || response.Content == null) + { + return typeof(T) == typeof(bool) ? (T?)(object)(bool?)true : default; + } + + try + { + T? result = await response.Content.ReadFromJsonAsync( + JsonOptions, cancellation); + return result; + } + catch (Exception ex) + { + innerException = ex; + errorMessage = "Tunnel service response deserialization error: " + ex.Message; + } + } + + if (errorMessage == null && response.Content != null) + { + try + { + if ((int)response.StatusCode >= 400 && (int)response.StatusCode < 500) + { + // 4xx status responses may include standard ProblemDetails. + var problemDetails = await response.Content + .ReadFromJsonAsync(JsonOptions, cancellation); + if (!string.IsNullOrEmpty(problemDetails?.Title) || + !string.IsNullOrEmpty(problemDetails?.Detail)) + { + if (allowNotFound && response.StatusCode == HttpStatusCode.NotFound && + problemDetails.Detail == null) + { + return default; + } + + errorMessage = "Tunnel service error: " + + problemDetails!.Title + " " + problemDetails.Detail; + if (problemDetails.Errors != null) + { + foreach (var error in problemDetails.Errors) + { + var messages = string.Join(" ", error.Value); + errorMessage += $"\n{error.Key}: {messages}"; + } + } + } + } + else if ((int)response.StatusCode >= 500) + { + // 5xx status responses may include VS SaaS error details. + var errorDetails = await response.Content.ReadFromJsonAsync( + JsonOptions, cancellation); + if (!string.IsNullOrEmpty(errorDetails?.Message)) + { + errorMessage = "Tunnel service error: " + errorDetails!.Message; + if (!string.IsNullOrEmpty(errorDetails.StackTrace)) + { + errorMessage += "\n" + errorDetails.StackTrace; + } + } + } + } + catch (Exception ex) + { + // A default error message will be filled in below. + innerException = ex; + } + } + + errorMessage ??= "Tunnel service response status code: " + response.StatusCode; + + if (response.Headers.TryGetValues(RequestIdHeaderName, out var requestId)) + { + errorMessage += $"\nRequest ID: {requestId.First()}"; + } + + try + { + response.EnsureSuccessStatusCode(); + } + catch (HttpRequestException hrex) + { + switch (response.StatusCode) + { + case HttpStatusCode.BadRequest: + throw new ArgumentException(errorMessage, hrex); + + case HttpStatusCode.Unauthorized: + case HttpStatusCode.Forbidden: + // Enterprise Policies + if (response.Headers.Contains("X-Enterprise-Policy-Failure")) + { + var message = response.Content != null ? await response.Content.ReadAsStringAsync() : string.Empty; + var errorDetails = JsonSerializer.Deserialize(message); + errorMessage = errorDetails?.detail; + } + + var ex = new UnauthorizedAccessException(errorMessage, hrex); + + // The HttpResponseHeaders.WwwAuthenticate property does not correctly + // handle multiple values! Get the values by name instead. + if (response.Headers.TryGetValues( + "WWW-Authenticate", out var authHeaderValues)) + { + ex.SetAuthenticationSchemes(authHeaderValues); + } + + throw ex; + + case HttpStatusCode.NotFound: + case HttpStatusCode.Conflict: + case HttpStatusCode.PreconditionFailed: + case HttpStatusCode.TooManyRequests: + throw new InvalidOperationException(errorMessage, hrex); + + case HttpStatusCode.Redirect: + case HttpStatusCode.RedirectKeepVerb: + // Add the redirect location to the exception data. + // Normally the HTTP client should automatically follow redirects, + // but this allows tests to validate the service's redirection behavior + // when client auto redirection is disabled. + hrex.Data["Location"] = response.Headers.Location; + throw; + + default: throw; + } + } + + throw new Exception(errorMessage, innerException); + } + + /// + /// Error details that may be returned from the service with 500 status responses + /// (when in development mode). + /// + /// + /// Copied from Microsoft.VsSaaS.Common to avoid taking a dependency on that assembly. + /// + private class ErrorDetails + { + public string? Message { get; set; } + public string? StackTrace { get; set; } + public string? detail { get; set; } + } + + /// + public void Dispose() + { + this.httpClient.Dispose(); + } + + private Uri BuildUri( + string? clusterId, + string path, + string? query, + TunnelRequestOptions? options) + { + Requires.NotNullOrEmpty(path, nameof(path)); + + var baseAddress = this.httpClient.BaseAddress!; + var builder = new UriBuilder(baseAddress); + + if (!string.IsNullOrEmpty(clusterId) && + baseAddress.HostNameType == UriHostNameType.Dns) + { + if (baseAddress.Host != "localhost" && + !baseAddress.Host.StartsWith($"{clusterId}.")) + { + // A specific cluster ID was specified (while not running on localhost). + // Prepend the cluster ID to the hostname, and optionally strip a global prefix. + builder.Host = $"{clusterId}.{builder.Host}".Replace("global.", string.Empty); + } + else if (baseAddress.Scheme == "https" && + clusterId.StartsWith("localhost") && builder.Port % 10 > 0 && + ushort.TryParse(clusterId.Substring("localhost".Length), out var clusterNumber)) + { + // Local testing simulates clusters by running the service on multiple ports. + // Change the port number to match the cluster ID suffix. + if (clusterNumber > 0 && clusterNumber < 10) + { + builder.Port = builder.Port - (builder.Port % 10) + clusterNumber; + } + } + } + + if (options != null) + { + var optionsQuery = options.ToQueryString(); + if (!string.IsNullOrEmpty(optionsQuery)) + { + query = optionsQuery + + (!string.IsNullOrEmpty(query) ? '&' + query : string.Empty); + } + } + + builder.Path = path; + builder.Query = query; + return builder.Uri; + } + + private Uri BuildTunnelUri( + Tunnel tunnel, + string? path, + string? query, + TunnelRequestOptions? options, + bool isCreate = false) + { + Requires.NotNull(tunnel, nameof(tunnel)); + + string tunnelPath; + var pathBase = TunnelsPath; + if (!string.IsNullOrEmpty(tunnel.TunnelId) && (!string.IsNullOrEmpty(tunnel.ClusterId) || isCreate)) + { + tunnelPath = $"{pathBase}/{tunnel.TunnelId}"; + } + else + { + Requires.Argument( + !string.IsNullOrEmpty(tunnel.Name), + nameof(tunnel), + "Tunnel object must include either a name or tunnel ID and cluster ID."); + + if (string.IsNullOrEmpty(tunnel.Domain)) + { + + tunnelPath = $"{pathBase}/{tunnel.Name}"; + } + else + { + // Append the domain to the tunnel name. + tunnelPath = $"{pathBase}/{tunnel.Name}.{tunnel.Domain}"; + } + } + + return BuildUri( + tunnel.ClusterId, + tunnelPath + (!string.IsNullOrEmpty(path) ? path : string.Empty), + query, + options); + } + + private async Task GetAuthenticationHeaderAsync( + Tunnel? tunnel, + string[]? accessTokenScopes, + TunnelRequestOptions? options) + { + AuthenticationHeaderValue? authHeader = null; + + if (!string.IsNullOrEmpty(options?.AccessToken)) + { + authHeader = new AuthenticationHeaderValue( + TunnelAuthenticationScheme, options.AccessToken); + } + + if (authHeader == null) + { + authHeader = await this.userTokenCallback(); + } + + if (authHeader == null && tunnel?.AccessTokens != null && accessTokenScopes != null) + { + foreach (var scope in accessTokenScopes) + { + if (tunnel.TryGetAccessToken(scope, out string? accessToken)) + { + authHeader = new AuthenticationHeaderValue( + TunnelAuthenticationScheme, accessToken); + break; + } + } + } + + return authHeader; + } + + /// + public async Task ListTunnelsAsync( + string? clusterId, + string? domain, + TunnelRequestOptions? options, + bool? ownedTunnelsOnly, + CancellationToken cancellation) + { + var queryParams = new string?[] + { + string.IsNullOrEmpty(clusterId) ? "global=true" : null, + !string.IsNullOrEmpty(domain) ? $"domain={HttpUtility.UrlEncode(domain)}" : null, + !string.IsNullOrEmpty(ApiVersion) ? GetApiQuery() : null, + ownedTunnelsOnly == true ? "ownedTunnelsOnly=true" : null, + }; + var query = string.Join("&", queryParams.Where((p) => p != null)); + var result = await this.SendRequestAsync( + HttpMethod.Get, + clusterId, + TunnelsPath, + query, + options, + cancellation); + if (result?.Value != null) + { + return result.Value.Where(t => t.Value != null).SelectMany(t => t.Value!).ToArray(); + } + + return Array.Empty(); + } + + /// + [Obsolete("Use ListTunnelsAsync() method with TunnelRequestOptions.Labels instead.")] + public async Task SearchTunnelsAsync( + string[] labels, + bool requireAllLabels, + string? clusterId, + string? domain, + TunnelRequestOptions? options, + CancellationToken cancellation) + { + var queryParams = new string?[] + { + string.IsNullOrEmpty(clusterId) ? "global=true" : null, + !string.IsNullOrEmpty(domain) ? $"domain={HttpUtility.UrlEncode(domain)}" : null, + $"labels={string.Join(",", labels.Select(HttpUtility.UrlEncode))}", + $"allLabels={requireAllLabels}", + !string.IsNullOrEmpty(ApiVersion) ? GetApiQuery() : null, + }; + var query = string.Join("&", queryParams.Where((p) => p != null)); + var result = await this.SendRequestAsync( + HttpMethod.Get, + clusterId, + TunnelsPath, + query, + options, + cancellation); + return result!; + } + + /// + public async Task GetTunnelAsync( + Tunnel tunnel, + TunnelRequestOptions? options, + CancellationToken cancellation) + { + var result = await this.SendTunnelRequestAsync( + HttpMethod.Get, + tunnel, + ReadAccessTokenScopes, + path: null, + query: GetApiQuery(), + options, + cancellation); + PreserveAccessTokens(tunnel, result); + return result; + } + + /// + public async Task CreateTunnelAsync( + Tunnel tunnel, + TunnelRequestOptions? options, + CancellationToken cancellation) + { + Requires.NotNull(tunnel, nameof(tunnel)); + options ??= new TunnelRequestOptions(); + options.AdditionalHeaders ??= new List>(); + options.AdditionalHeaders = options.AdditionalHeaders.Append(new KeyValuePair("If-None-Match", "*")); + var tunnelId = tunnel.TunnelId; + var idGenerated = string.IsNullOrEmpty(tunnelId); + if (idGenerated) + { + tunnel.TunnelId = IdGeneration.GenerateTunnelId(); + } + for (int retries = 0; retries <= CreateNameRetries; retries++) + { + try + { + var result = await this.SendTunnelRequestAsync( + HttpMethod.Put, + tunnel, + ManageAccessTokenScope, + path: null, + query: GetApiQuery(), + options, + ConvertTunnelForRequest(tunnel), + cancellation, + true); + PreserveAccessTokens(tunnel, result); + return result!; + } + catch (UnauthorizedAccessException) when (idGenerated && retries < CreateNameRetries) // The tunnel ID was already taken. + { + tunnel.TunnelId = IdGeneration.GenerateTunnelId(); + } + } + + // This code is unreachable, but the compiler still requires it. + var result2 = await this.SendTunnelRequestAsync( + HttpMethod.Put, + tunnel, + ManageAccessTokenScope, + path: null, + query: GetApiQuery(), + options, + ConvertTunnelForRequest(tunnel), + cancellation, + true); + PreserveAccessTokens(tunnel, result2); + return result2!; + } + + /// + public async Task CreateOrUpdateTunnelAsync( + Tunnel tunnel, + TunnelRequestOptions? options, + CancellationToken cancellation) + { + Requires.NotNull(tunnel, nameof(tunnel)); + + var tunnelId = tunnel.TunnelId; + var idGenerated = string.IsNullOrEmpty(tunnelId); + if (idGenerated) + { + tunnel.TunnelId = IdGeneration.GenerateTunnelId(); + } + for (int retries = 0; retries <= CreateNameRetries; retries++) + { + try + { + var result = await this.SendTunnelRequestAsync( + HttpMethod.Put, + tunnel, + ManageAccessTokenScope, + path: null, + query: GetApiQuery(), + options, + ConvertTunnelForRequest(tunnel), + cancellation, + true); + PreserveAccessTokens(tunnel, result); + return result!; + } + catch (UnauthorizedAccessException) when (idGenerated && retries < 3) // The tunnel ID was already taken. + { + tunnel.TunnelId = IdGeneration.GenerateTunnelId(); + } + } + + // This code is unreachable, but the compiler still requires it. + var result2 = await this.SendTunnelRequestAsync( + HttpMethod.Put, + tunnel, + ManageAccessTokenScope, + path: null, + query: GetApiQuery(), + options, + ConvertTunnelForRequest(tunnel), + cancellation, + true); + PreserveAccessTokens(tunnel, result2); + return result2!; + } + + /// + public async Task UpdateTunnelAsync( + Tunnel tunnel, + TunnelRequestOptions? options, + CancellationToken cancellation) + { + options ??= new TunnelRequestOptions(); + options.AdditionalHeaders ??= new List>(); + options.AdditionalHeaders = options.AdditionalHeaders.Append(new KeyValuePair("If-Match", "*")); + var result = await this.SendTunnelRequestAsync( + HttpMethod.Put, + tunnel, + ManageAccessTokenScope, + path: null, + query: GetApiQuery(), + options, + ConvertTunnelForRequest(tunnel), + cancellation); + PreserveAccessTokens(tunnel, result); + return result!; + } + + /// + public async Task DeleteTunnelAsync( + Tunnel tunnel, + TunnelRequestOptions? options, + CancellationToken cancellation) + { + var result = await this.SendTunnelRequestAsync( + HttpMethod.Delete, + tunnel, + ManageAccessTokenScope, + path: null, + query: GetApiQuery(), + options, + cancellation); + return result; + } + + /// + public async Task UpdateTunnelEndpointAsync( + Tunnel tunnel, + TunnelEndpoint endpoint, + TunnelRequestOptions? options = null, + CancellationToken cancellation = default) + { + Requires.NotNull(endpoint, nameof(endpoint)); + Requires.NotNullOrEmpty(endpoint.HostId!, nameof(TunnelEndpoint.HostId)); + Requires.NotNullOrEmpty(endpoint.Id!, nameof(TunnelEndpoint.Id)); + + var path = $"{EndpointsApiSubPath}/{endpoint.Id}"; + var query = GetApiQuery(); + query += "&connectionMode=" + endpoint.ConnectionMode; + var result = (await this.SendTunnelRequestAsync( + HttpMethod.Put, + tunnel, + HostAccessTokenScope, + path, + query: query, + options, + endpoint, + cancellation))!; + + + if (tunnel.Endpoints != null) + { + // Also update the endpoint in the local tunnel object. + tunnel.Endpoints = tunnel.Endpoints + .Where((e) => e.HostId != endpoint.HostId || + e.ConnectionMode != endpoint.ConnectionMode) + .Append(result) + .ToArray(); + } + + return result; + } + + /// + public async Task DeleteTunnelEndpointsAsync( + Tunnel tunnel, + string id, + TunnelRequestOptions? options = null, + CancellationToken cancellation = default) + { + Requires.NotNullOrEmpty(id, nameof(id)); + + var path = $"{EndpointsApiSubPath}/{id}"; + var result = await this.SendTunnelRequestAsync( + HttpMethod.Delete, + tunnel, + HostAccessTokenScope, + path, + query: GetApiQuery(), + options, + cancellation); + + if (result && tunnel.Endpoints != null) + { + // Also delete the endpoint in the local tunnel object. + tunnel.Endpoints = tunnel.Endpoints + .Where((e) => e.Id != id) + .ToArray(); + } + + return result; + } + + /// + public async Task ListTunnelPortsAsync( + Tunnel tunnel, + TunnelRequestOptions? options, + CancellationToken cancellation) + { + var result = await this.SendTunnelRequestAsync( + HttpMethod.Get, + tunnel, + ReadAccessTokenScopes, + PortsApiSubPath, + query: GetApiQuery(), + options, + cancellation); + return result!.Value!; + } + + /// + public async Task GetTunnelPortAsync( + Tunnel tunnel, + ushort portNumber, + TunnelRequestOptions? options, + CancellationToken cancellation) + { + this.OnReportProgress(TunnelProgress.StartingGetTunnelPort); + var path = $"{PortsApiSubPath}/{portNumber}"; + var result = await this.SendTunnelRequestAsync( + HttpMethod.Get, + tunnel, + ReadAccessTokenScopes, + path, + query: GetApiQuery(), + options, + cancellation); + this.OnReportProgress(TunnelProgress.CompletedGetTunnelPort); + return result; + } + + /// + public async Task CreateTunnelPortAsync( + Tunnel tunnel, + TunnelPort tunnelPort, + TunnelRequestOptions? options, + CancellationToken cancellation) + { + Requires.NotNull(tunnelPort, nameof(tunnelPort)); + this.OnReportProgress(TunnelProgress.StartingCreateTunnelPort); + var path = $"{PortsApiSubPath}/{tunnelPort.PortNumber}"; + options ??= new TunnelRequestOptions(); + options.AdditionalHeaders ??= new List>(); + options.AdditionalHeaders = options.AdditionalHeaders.Append(new KeyValuePair("If-None-Match", "*")); + + var result = (await this.SendTunnelRequestAsync( + HttpMethod.Put, + tunnel, + ManagePortsAccessTokenScopes, + path, + query: GetApiQuery(), + options, + ConvertTunnelPortForRequest(tunnel, tunnelPort), + cancellation))!; + PreserveAccessTokens(tunnelPort, result); + + tunnel.Ports ??= new TunnelPort[0]; + + // Also add the port to the local tunnel object. + tunnel.Ports = tunnel.Ports + .Where((p) => p.PortNumber != tunnelPort.PortNumber) + .Append(result) + .OrderBy((p) => p.PortNumber) + .ToArray(); + this.OnReportProgress(TunnelProgress.CompletedCreateTunnelPort); + return result; + } + + /// + public async Task UpdateTunnelPortAsync( + Tunnel tunnel, + TunnelPort tunnelPort, + TunnelRequestOptions? options, + CancellationToken cancellation) + { + Requires.NotNull(tunnelPort, nameof(tunnelPort)); + options ??= new TunnelRequestOptions(); + options.AdditionalHeaders ??= new List>(); + options.AdditionalHeaders = options.AdditionalHeaders.Append(new KeyValuePair("If-Match", "*")); + + if (tunnelPort.ClusterId != null && tunnel.ClusterId != null && + tunnelPort.ClusterId != tunnel.ClusterId) + { + throw new ArgumentException( + "Tunnel port cluster ID is not consistent.", nameof(tunnelPort)); + } + + var portNumber = tunnelPort.PortNumber; + var path = $"{PortsApiSubPath}/{portNumber}"; + var result = (await this.SendTunnelRequestAsync( + HttpMethod.Put, + tunnel, + ManagePortsAccessTokenScopes, + path, + query: GetApiQuery(), + options, + ConvertTunnelPortForRequest(tunnel, tunnelPort), + cancellation))!; + PreserveAccessTokens(tunnelPort, result); + + tunnel.Ports ??= new TunnelPort[0]; + + // Also add the port to the local tunnel object. + tunnel.Ports = tunnel.Ports + .Where((p) => p.PortNumber != tunnelPort.PortNumber) + .Append(result) + .OrderBy((p) => p.PortNumber) + .ToArray(); + + + return result; + } + + /// + public async Task CreateOrUpdateTunnelPortAsync( + Tunnel tunnel, + TunnelPort tunnelPort, + TunnelRequestOptions? options, + CancellationToken cancellation) + { + Requires.NotNull(tunnelPort, nameof(tunnelPort)); + + if (tunnelPort.ClusterId != null && tunnel.ClusterId != null && + tunnelPort.ClusterId != tunnel.ClusterId) + { + throw new ArgumentException( + "Tunnel port cluster ID is not consistent.", nameof(tunnelPort)); + } + + var portNumber = tunnelPort.PortNumber; + var path = $"{PortsApiSubPath}/{portNumber}"; + var result = (await this.SendTunnelRequestAsync( + HttpMethod.Put, + tunnel, + ManagePortsAccessTokenScopes, + path, + query: GetApiQuery(), + options, + ConvertTunnelPortForRequest(tunnel, tunnelPort), + cancellation))!; + PreserveAccessTokens(tunnelPort, result); + + tunnel.Ports ??= new TunnelPort[0]; + + // Also add the port to the local tunnel object. + tunnel.Ports = tunnel.Ports + .Where((p) => p.PortNumber != tunnelPort.PortNumber) + .Append(result) + .OrderBy((p) => p.PortNumber) + .ToArray(); + + + return result; + } + + /// + public async Task DeleteTunnelPortAsync( + Tunnel tunnel, + ushort portNumber, + TunnelRequestOptions? options, + CancellationToken cancellation) + { + var path = $"{PortsApiSubPath}/{portNumber}"; + var result = await this.SendTunnelRequestAsync( + HttpMethod.Delete, + tunnel, + ManagePortsAccessTokenScopes, + path, + query: GetApiQuery(), + options, + cancellation); + + if (result && tunnel.Ports != null) + { + // Also delete the port in the local tunnel object. + tunnel.Ports = tunnel.Ports + .Where((p) => p.PortNumber != portNumber) + .OrderBy((p) => p.PortNumber) + .ToArray(); + } + + return result; + } + + /// + /// Event fired when a tunnel progress event has been reported. + /// + protected virtual void OnReportProgress(TunnelProgress progress) + { + if (ReportProgress is EventHandler handler) + { + var args = new TunnelReportProgressEventArgs(progress.ToString()); + handler.Invoke(this, args); + } + } + + /// + /// Removes read-only properties like tokens and status from create/update requests. + /// + private Tunnel ConvertTunnelForRequest(Tunnel tunnel) + { + return new Tunnel + { + TunnelId = tunnel.TunnelId, + Name = tunnel.Name, + Domain = tunnel.Domain, + Description = tunnel.Description, + Labels = tunnel.Labels, + CustomExpiration = tunnel.CustomExpiration, + Options = tunnel.Options, + AccessControl = tunnel.AccessControl == null ? null : new TunnelAccessControl( + tunnel.AccessControl.Where((ace) => !ace.IsInherited)), + Endpoints = tunnel.Endpoints, + Ports = tunnel.Ports? + .Select((p) => ConvertTunnelPortForRequest(tunnel, p)) + .ToArray(), + }; + } + + /// + /// Removes read-only properties like tokens and status from create/update requests. + /// + private TunnelPort ConvertTunnelPortForRequest(Tunnel tunnel, TunnelPort tunnelPort) + { + if (tunnelPort.ClusterId != null && tunnel.ClusterId != null && + tunnelPort.ClusterId != tunnel.ClusterId) + { + throw new ArgumentException( + "Tunnel port cluster ID does not match tunnel.", nameof(tunnelPort)); + } + + if (tunnelPort.TunnelId != null && tunnel.TunnelId != null && + tunnelPort.TunnelId != tunnel.TunnelId) + { + throw new ArgumentException( + "Tunnel port tunnel ID does not match tunnel.", nameof(tunnelPort)); + } + + return new TunnelPort + { + PortNumber = tunnelPort.PortNumber, + Protocol = tunnelPort.Protocol, + IsDefault = tunnelPort.IsDefault, + Description = tunnelPort.Description, + Labels = tunnelPort.Labels, + Options = tunnelPort.Options, + AccessControl = tunnelPort.AccessControl == null ? null : new TunnelAccessControl( + tunnelPort.AccessControl.Where((ace) => !ace.IsInherited)), + SshUser = tunnelPort.SshUser, + }; + } + + /// + public async Task FormatSubjectsAsync( + TunnelAccessSubject[] subjects, + TunnelRequestOptions? options = null, + CancellationToken cancellation = default) + { + Requires.NotNull(subjects, nameof(subjects)); + + if (subjects.Length == 0) + { + return subjects; + } + + var formattedSubjects = await SendRequestAsync + ( + HttpMethod.Post, + clusterId: null, + SubjectsPath + "/format", + query: GetApiQuery(), + options, + subjects, + cancellation); + return formattedSubjects!; + } + + /// + public async Task ResolveSubjectsAsync( + TunnelAccessSubject[] subjects, + TunnelRequestOptions? options = null, + CancellationToken cancellation = default) + { + Requires.NotNull(subjects, nameof(subjects)); + + if (subjects.Length == 0) + { + return subjects; + } + + var resolvedSubjects = await SendRequestAsync + ( + HttpMethod.Post, + clusterId: null, + SubjectsPath + "/resolve", + query: GetApiQuery(), + options, + subjects, + cancellation); + return resolvedSubjects!; + } + + /// + public async Task ListUserLimitsAsync(CancellationToken cancellation = default) + { + var userLimits = await SendRequestAsync( + HttpMethod.Get, + clusterId: null, + UserLimitsPath, + query: GetApiQuery(), + options: null, + cancellation); + return userLimits!; + } + + /// + public async Task ListClustersAsync(CancellationToken cancellation) { + var baseAddress = this.httpClient.BaseAddress!; + var builder = new UriBuilder(baseAddress); + builder.Path = ClustersPath; + builder.Query = GetApiQuery(); + var clusterDetails = await SendRequestAsync( + HttpMethod.Get, + builder.Uri, + options: null, + authHeader: null, + body: null, + cancellation); + return clusterDetails!; + } + + /// + public async Task CheckNameAvailabilityAsync( + string name, + CancellationToken cancellation = default) + { + name = Uri.EscapeDataString(name); + Requires.NotNull(name, nameof(name)); + return await this.SendRequestAsync( + HttpMethod.Get, + clusterId: null, + TunnelsPath + "/" + name + CheckAvailableSubPath, + query: GetApiQuery(), + options: null, + cancellation + ); + } + + /// + /// Gets required query string parmeters + /// + /// Query string + protected virtual string? GetApiQuery() + { + return string.IsNullOrEmpty(ApiVersion) ? null : $"api-version={ApiVersion}"; + } + + /// + /// Copy access tokens from the request object to the result object, except for any + /// tokens that were refreshed by the request. + /// + /// + /// This intentionally does not check whether any existing tokens are expired. So + /// expired tokens may be preserved also, if not refreshed. This allows for better + /// diagnostics in that case. + /// + private static void PreserveAccessTokens(Tunnel requestTunnel, Tunnel? resultTunnel) + { + if (requestTunnel.AccessTokens != null && resultTunnel != null) + { + resultTunnel.AccessTokens ??= new Dictionary(); + foreach (var scopeAndToken in requestTunnel.AccessTokens) + { + if (!resultTunnel.AccessTokens.ContainsKey(scopeAndToken.Key)) + { + resultTunnel.AccessTokens[scopeAndToken.Key] = scopeAndToken.Value; + } + } + } + } + + /// + /// Copy access tokens from the request object to the result object, except for any + /// tokens that were refreshed by the request. + /// + private static void PreserveAccessTokens(TunnelPort requestPort, TunnelPort? resultPort) + { + if (requestPort.AccessTokens != null && resultPort != null) + { + resultPort.AccessTokens ??= new Dictionary(); + foreach (var scopeAndToken in requestPort.AccessTokens) + { + if (!resultPort.AccessTokens.ContainsKey(scopeAndToken.Key)) + { + resultPort.AccessTokens[scopeAndToken.Key] = scopeAndToken.Value; + } + } + } + } + } +} From 41f44d9be5e6b992286533656416395b063224a1 Mon Sep 17 00:00:00 2001 From: Neelima Potharaj Date: Thu, 22 Feb 2024 10:26:50 -0800 Subject: [PATCH 02/12] fixed debugging related error --- cs/src/Management/TunnelManagementClient.cs | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/cs/src/Management/TunnelManagementClient.cs b/cs/src/Management/TunnelManagementClient.cs index 7d66d204..0495783e 100644 --- a/cs/src/Management/TunnelManagementClient.cs +++ b/cs/src/Management/TunnelManagementClient.cs @@ -523,7 +523,7 @@ private string UserLimitsPath } var localMachineHeaders = TunnelUserAgent.GetMachineHeaders(); - if(localMachineHeaders != null) + if (localMachineHeaders != null) { request.Headers.UserAgent.Add(localMachineHeaders); } @@ -691,6 +691,7 @@ private string UserLimitsPath errorMessage = errorDetails?.detail; } + var ex = new UnauthorizedAccessException(errorMessage, hrex); // The HttpResponseHeaders.WwwAuthenticate property does not correctly @@ -955,7 +956,7 @@ public async Task CreateTunnelAsync( { Requires.NotNull(tunnel, nameof(tunnel)); options ??= new TunnelRequestOptions(); - options.AdditionalHeaders ??= new List>(); + options.AdditionalHeaders ??= new List>(); options.AdditionalHeaders = options.AdditionalHeaders.Append(new KeyValuePair("If-None-Match", "*")); var tunnelId = tunnel.TunnelId; var idGenerated = string.IsNullOrEmpty(tunnelId); @@ -1001,7 +1002,7 @@ public async Task CreateTunnelAsync( return result2!; } - /// + /// public async Task CreateOrUpdateTunnelAsync( Tunnel tunnel, TunnelRequestOptions? options, @@ -1209,7 +1210,7 @@ public async Task CreateTunnelPortAsync( this.OnReportProgress(TunnelProgress.StartingCreateTunnelPort); var path = $"{PortsApiSubPath}/{tunnelPort.PortNumber}"; options ??= new TunnelRequestOptions(); - options.AdditionalHeaders ??= new List>(); + options.AdditionalHeaders ??= new List>(); options.AdditionalHeaders = options.AdditionalHeaders.Append(new KeyValuePair("If-None-Match", "*")); var result = (await this.SendTunnelRequestAsync( @@ -1244,7 +1245,7 @@ public async Task UpdateTunnelPortAsync( { Requires.NotNull(tunnelPort, nameof(tunnelPort)); options ??= new TunnelRequestOptions(); - options.AdditionalHeaders ??= new List>(); + options.AdditionalHeaders ??= new List>(); options.AdditionalHeaders = options.AdditionalHeaders.Append(new KeyValuePair("If-Match", "*")); if (tunnelPort.ClusterId != null && tunnel.ClusterId != null && @@ -1280,7 +1281,7 @@ public async Task UpdateTunnelPortAsync( return result; } - /// + /// public async Task CreateOrUpdateTunnelPortAsync( Tunnel tunnel, TunnelPort tunnelPort, @@ -1483,7 +1484,8 @@ public async Task ListUserLimitsAsync(CancellationToken cance } /// - public async Task ListClustersAsync(CancellationToken cancellation) { + public async Task ListClustersAsync(CancellationToken cancellation) + { var baseAddress = this.httpClient.BaseAddress!; var builder = new UriBuilder(baseAddress); builder.Path = ClustersPath; @@ -1567,4 +1569,4 @@ private static void PreserveAccessTokens(TunnelPort requestPort, TunnelPort? res } } } -} +} \ No newline at end of file From e94d35ac1df3994b262b653b5b83ecb564545be3 Mon Sep 17 00:00:00 2001 From: Neelima Potharaj Date: Thu, 22 Feb 2024 10:29:47 -0800 Subject: [PATCH 03/12] reset file --- cs/src/Management/TunnelManagementClient.cs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/cs/src/Management/TunnelManagementClient.cs b/cs/src/Management/TunnelManagementClient.cs index 0495783e..75436b9c 100644 --- a/cs/src/Management/TunnelManagementClient.cs +++ b/cs/src/Management/TunnelManagementClient.cs @@ -9,8 +9,6 @@ using System.Net; using System.Net.Http; using System.Net.Http.Headers; -using System.Text.Json; - #if NET5_0_OR_GREATER using System.Net.Http.Json; #endif @@ -687,11 +685,9 @@ private string UserLimitsPath if (response.Headers.Contains("X-Enterprise-Policy-Failure")) { var message = response.Content != null ? await response.Content.ReadAsStringAsync() : string.Empty; - var errorDetails = JsonSerializer.Deserialize(message); - errorMessage = errorDetails?.detail; + errorMessage = message; } - var ex = new UnauthorizedAccessException(errorMessage, hrex); // The HttpResponseHeaders.WwwAuthenticate property does not correctly @@ -737,7 +733,6 @@ private class ErrorDetails { public string? Message { get; set; } public string? StackTrace { get; set; } - public string? detail { get; set; } } /// From 93cdc8100d577d515472593494d892c719c8bf9f Mon Sep 17 00:00:00 2001 From: Neelima Potharaj Date: Thu, 22 Feb 2024 10:36:50 -0800 Subject: [PATCH 04/12] reset file to the version from the main branch --- cs/src/Management/TunnelManagementClient.cs | 3133 +++++++++---------- 1 file changed, 1566 insertions(+), 1567 deletions(-) diff --git a/cs/src/Management/TunnelManagementClient.cs b/cs/src/Management/TunnelManagementClient.cs index 75436b9c..5ac87f7b 100644 --- a/cs/src/Management/TunnelManagementClient.cs +++ b/cs/src/Management/TunnelManagementClient.cs @@ -1,1567 +1,1566 @@ -// -// Copyright (c) Microsoft. All rights reserved. -// Licensed under the MIT license. -// - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Net.Http.Headers; -#if NET5_0_OR_GREATER -using System.Net.Http.Json; -#endif -using System.Threading; -using System.Threading.Tasks; -using System.Web; -using Microsoft.DevTunnels.Contracts; -using static Microsoft.DevTunnels.Contracts.TunnelContracts; - -namespace Microsoft.DevTunnels.Management -{ - /// - /// Implementation of a client that manages tunnels and tunnel ports via the tunnel service - /// management API. - /// - public class TunnelManagementClient : ITunnelManagementClient - { - private const string ApiV1Path = "/api/v1"; - private const string TunnelsV1ApiPath = ApiV1Path + "/tunnels"; - private const string SubjectsV1ApiPath = ApiV1Path + "/subjects"; - private const string UserLimitsV1ApiPath = ApiV1Path + "/userlimits"; - private const string TunnelsApiPath = "/tunnels"; - private const string SubjectsApiPath = "/subjects"; - private const string UserLimitsApiPath = "/userlimits"; - private const string EndpointsApiSubPath = "/endpoints"; - private const string PortsApiSubPath = "/ports"; - private const string ClustersApiPath = "/clusters"; - private const string ClustersV1ApiPath = ApiV1Path + "/clusters"; - private const string TunnelAuthenticationScheme = "Tunnel"; - private const string RequestIdHeaderName = "VsSaaS-Request-Id"; - private const string CheckAvailableSubPath = ":checkNameAvailability"; - private const int CreateNameRetries = 3; - - private static readonly string[] ManageAccessTokenScope = - new[] { TunnelAccessScopes.Manage }; - private static readonly string[] HostAccessTokenScope = - new[] { TunnelAccessScopes.Host }; - private static readonly string[] ManagePortsAccessTokenScopes = new[] - { - TunnelAccessScopes.Manage, - TunnelAccessScopes.ManagePorts, - TunnelAccessScopes.Host, - }; - private static readonly string[] ReadAccessTokenScopes = new[] - { - TunnelAccessScopes.Manage, - TunnelAccessScopes.ManagePorts, - TunnelAccessScopes.Host, - TunnelAccessScopes.Connect, - }; - - /// - /// Accepted management client api versions - /// - public string[] TunnelsApiVersions = - { - "2023-09-27-preview" - }; - - /// - /// Event raised to report tunnel management progress. - /// - public event EventHandler? ReportProgress; - - /// - /// ApiVersion that will be used if one is not specified - /// - public const ManagementApiVersions DefaultApiVersion = ManagementApiVersions.Version20230927Preview; - - private static readonly ProductInfoHeaderValue TunnelSdkUserAgent = - TunnelUserAgent.GetUserAgent(typeof(TunnelManagementClient).Assembly, "Dev-Tunnels-Service-CSharp-SDK")!; - - private readonly HttpClient httpClient; - private readonly Func> userTokenCallback; - - /// - /// Initializes a new instance of the class - /// with an optional client authentication callback. - /// - /// User agent. - /// Optional async callback for retrieving a client - /// authentication header, for AAD or GitHub user authentication. This may be null - /// for anonymous tunnel clients, or if tunnel access tokens will be specified via - /// . - /// Api version to use for tunnels requests, accepted - /// values are - public TunnelManagementClient( - ProductInfoHeaderValue userAgent, - Func>? userTokenCallback = null, - ManagementApiVersions apiVersion = DefaultApiVersion) - : this(new[] { userAgent }, userTokenCallback, tunnelServiceUri: null, httpHandler: null, apiVersion) - { - } - - /// - /// Initializes a new instance of the class - /// with an optional client authentication callback. - /// - /// User agent. Muiltiple user agents can be supplied in the - /// case that this SDK is used in a program, such as a CLI, that has users that want - /// to be differentiated. - /// Optional async callback for retrieving a client - /// authentication header, for AAD or GitHub user authentication. This may be null - /// for anonymous tunnel clients, or if tunnel access tokens will be specified via - /// . - /// Api version to use for tunnels requests, accepted - /// values are - public TunnelManagementClient( - ProductInfoHeaderValue[] userAgents, - Func>? userTokenCallback = null, - ManagementApiVersions apiVersion = DefaultApiVersion) - : this(userAgents, userTokenCallback, tunnelServiceUri: null, httpHandler: null, apiVersion) - { - } - - /// - /// Initializes a new instance of the class - /// with a client authentication callback, service URI, and HTTP handler. - /// - /// User agent. - /// Optional async callback for retrieving a client - /// authentication header value with access token, for AAD or GitHub user authentication. - /// This may be null for anonymous tunnel clients, or if tunnel access tokens will be - /// specified via . - /// Optional tunnel service URI (not including any path), - /// or null to use the default global service URI. - /// Optional HTTP handler or handler chain that will be invoked - /// for HTTPS requests to the tunnel service. The or - /// specified (or at the end of the chain) must have - /// automatic redirection disabled. The provided HTTP handler will not be disposed - /// by . - /// Api version to use for tunnels requests, accepted - /// values are - public TunnelManagementClient( - ProductInfoHeaderValue userAgent, - Func>? userTokenCallback = null, - Uri? tunnelServiceUri = null, - HttpMessageHandler? httpHandler = null, - ManagementApiVersions apiVersion = DefaultApiVersion) - : this(new[] { userAgent }, userTokenCallback, tunnelServiceUri, httpHandler, apiVersion) - { - } - - /// - /// Initializes a new instance of the class - /// with a client authentication callback, service URI, and HTTP handler. - /// - /// User agent. Muiltiple user agents can be supplied in the - /// case that this SDK is used in a program, such as a CLI, that has users that want - /// to be differentiated. - /// Optional async callback for retrieving a client - /// authentication header value with access token, for AAD or GitHub user authentication. - /// This may be null for anonymous tunnel clients, or if tunnel access tokens will be - /// specified via . - /// Optional tunnel service URI (not including any path), - /// or null to use the default global service URI. - /// Optional HTTP handler or handler chain that will be invoked - /// for HTTPS requests to the tunnel service. The or - /// specified (or at the end of the chain) must have - /// automatic redirection disabled. The provided HTTP handler will not be disposed - /// by . - /// Api version to use for tunnels requests, accepted - /// values are - public TunnelManagementClient( - ProductInfoHeaderValue[] userAgents, - Func>? userTokenCallback = null, - Uri? tunnelServiceUri = null, - HttpMessageHandler? httpHandler = null, - ManagementApiVersions apiVersionEnum = DefaultApiVersion) - { - Requires.NotNullEmptyOrNullElements(userAgents, nameof(userAgents)); - UserAgents = Requires.NotNull(userAgents, nameof(userAgents)); - var apiVersion = apiVersionEnum.ToVersionString(); - if (!string.IsNullOrEmpty(apiVersion) && !TunnelsApiVersions.Contains(apiVersion)) - { - throw new ArgumentException( - $"Invalid apiVersion, accpeted values are {string.Join(", ", TunnelsApiVersions)} "); - } - ApiVersion = apiVersion; - - this.userTokenCallback = userTokenCallback ?? - (() => Task.FromResult(null)); - - httpHandler ??= new SocketsHttpHandler - { - AllowAutoRedirect = false, - }; - ValidateHttpHandler(httpHandler); - - tunnelServiceUri ??= new Uri(TunnelServiceProperties.Production.ServiceUri); - if (!tunnelServiceUri.IsAbsoluteUri || tunnelServiceUri.PathAndQuery != "/") - { - throw new ArgumentException( - $"Invalid tunnel service URI: {tunnelServiceUri}", nameof(tunnelServiceUri)); - } - - // The `SocketsHttpHandler` or `HttpClientHandler` automatic redirection is disabled - // because they do not keep the Authorization header when redirecting. This handler - // will keep all headers when redirecting, and also supports switching the behavior - // per-request. - httpHandler = new FollowRedirectsHttpHandler(httpHandler); - - this.httpClient = new HttpClient(httpHandler, disposeHandler: false) - { - BaseAddress = tunnelServiceUri, - }; - } - - private static void ValidateHttpHandler(HttpMessageHandler httpHandler) - { - while (httpHandler is DelegatingHandler delegatingHandler) - { - httpHandler = delegatingHandler.InnerHandler!; - } - - if (httpHandler is SocketsHttpHandler socketsHandler) - { - if (socketsHandler.AllowAutoRedirect) - { - throw new ArgumentException( - "Tunnel client HTTP handler must have automatic redirection disabled.", - nameof(httpHandler)); - } - } - else if (httpHandler is HttpClientHandler httpClientHandler) - { - if (httpClientHandler.AllowAutoRedirect) - { - throw new ArgumentException( - "Tunnel client HTTP handler must have automatic redirection disabled.", - nameof(httpHandler)); - } - else if (httpClientHandler.UseDefaultCredentials) - { - throw new ArgumentException( - "Tunnel client HTTP handler must not use default credentials.", - nameof(httpHandler)); - } - } - else - { - throw new NotSupportedException( - $"Unsupported HTTP handler type: {httpHandler?.GetType().Name}. " + - "HTTP handler chain must consist of 0 or more DelegatingHandlers " + - "ending with a HttpClientHandler."); - } - } - - /// - /// Gets or sets additional headers that are added to every request. - /// - public IEnumerable>? AdditionalRequestHeaders { get; set; } - - private ProductInfoHeaderValue[] UserAgents { get; } - - private string? ApiVersion { get; } - - private string TunnelsPath - { - get { return string.IsNullOrEmpty(ApiVersion) ? TunnelsV1ApiPath : TunnelsApiPath; } - } - - private string ClustersPath - { - get { return string.IsNullOrEmpty(ApiVersion) ? ClustersV1ApiPath : ClustersApiPath; } - } - - private string SubjectsPath - { - get { return string.IsNullOrEmpty(ApiVersion) ? SubjectsV1ApiPath : SubjectsApiPath; } - } - - private string UserLimitsPath - { - get { return string.IsNullOrEmpty(ApiVersion) ? UserLimitsV1ApiPath : UserLimitsApiPath; } - } - - /// - /// Sends an HTTP request to the tunnel management API, targeting a specific tunnel. - /// - /// HTTP request method. - /// Tunnel that the request is targeting. - /// Required list of access scopes for tokens in - /// that could be used to - /// authorize the request. - /// Optional request sub-path relative to the tunnel. - /// Optional query string to append to the request. - /// Request options. - /// Cancellation token. - /// The expected result type. - /// Result of the request. - /// The request parameters were invalid. - /// The request was unauthorized or forbidden. - /// The WWW-Authenticate response header may be captured in the exception data. - /// The request would have caused a conflict - /// or exceeded a limit. - /// The request failed for some other - /// reason. - /// - /// This protected method enables subclasses to support additional tunnel management APIs. - /// Authentication will use one of the following, if available, in order of preference: - /// - on - /// - token provided by the user token callback - /// - token in that matches - /// one of the scopes in - /// - protected Task SendTunnelRequestAsync( - HttpMethod method, - Tunnel tunnel, - string[] accessTokenScopes, - string? path, - string? query, - TunnelRequestOptions? options, - CancellationToken cancellation) - { - return SendTunnelRequestAsync( - method, - tunnel, - accessTokenScopes, - path, - query, - options, - body: null, - cancellation); - } - - /// - /// Sends an HTTP request with body content to the tunnel management API, targeting a - /// specific tunnel. - /// - /// HTTP request method. - /// Tunnel that the request is targeting. - /// Required list of access scopes for tokens in - /// that could be used to - /// authorize the request. - /// Optional request sub-path relative to the tunnel. - /// Optional query string to append to the request. - /// Request options. - /// Request body object. - /// Cancellation token. - /// Whether the request is a create operation. - /// The request body type. - /// The expected result type. - /// Result of the request. - /// The request parameters were invalid. - /// The request was unauthorized or forbidden. - /// The WWW-Authenticate response header may be captured in the exception data. - /// The request would have caused a conflict - /// or exceeded a limit. - /// The request failed for some other - /// reason. - /// - /// This protected method enables subclasses to support additional tunnel management APIs. - /// Authentication will use one of the following, if available, in order of preference: - /// - on - /// - token provided by the user token callback - /// - token in that matches - /// one of the scopes in - /// - protected async Task SendTunnelRequestAsync( - HttpMethod method, - Tunnel tunnel, - string[] accessTokenScopes, - string? path, - string? query, - TunnelRequestOptions? options, - TRequest? body, - CancellationToken cancellation, - bool isCreate = false) - where TRequest : class - { - this.OnReportProgress(TunnelProgress.StartingRequestUri); - var uri = BuildTunnelUri(tunnel, path, query, options, isCreate); - this.OnReportProgress(TunnelProgress.StartingRequestConfig); - var authHeader = await GetAuthenticationHeaderAsync(tunnel, accessTokenScopes, options); - this.OnReportProgress(TunnelProgress.StartingSendTunnelRequest); - var result = await SendRequestAsync( - method, uri, options, authHeader, body, cancellation); - this.OnReportProgress(TunnelProgress.CompletedSendTunnelRequest); - return result; - } - - /// - /// Sends an HTTP request to the tunnel management API. - /// - /// HTTP request method. - /// Optional tunnel service cluster ID to direct the request to. - /// If unspecified, the request will use the global traffic-manager to find the nearest - /// cluster. - /// Required request path. - /// Optional query string to append to the request. - /// Request options. - /// Cancellation token. - /// The expected result type. - /// Result of the request. - /// The request parameters were invalid. - /// The request was unauthorized or forbidden. - /// The WWW-Authenticate response header may be captured in the exception data. - /// The request would have caused a conflict - /// or exceeded a limit. - /// The request failed for some other - /// reason. - /// - /// This protected method enables subclasses to support additional tunnel management APIs. - /// Authentication will use one of the following, if available, in order of preference: - /// - on - /// - token provided by the user token callback - /// - protected Task SendRequestAsync( - HttpMethod method, - string? clusterId, - string path, - string? query, - TunnelRequestOptions? options, - CancellationToken cancellation) - { - return SendRequestAsync( - method, - clusterId, - path, query, - options, - body: null, - cancellation); - } - - /// - /// Sends an HTTP request with body content to the tunnel management API. - /// - /// HTTP request method. - /// Optional tunnel service cluster ID to direct the request to. - /// If unspecified, the request will use the global traffic-manager to find the nearest - /// cluster. - /// Required request path. - /// Optional query string to append to the request. - /// Request options. - /// Request body object. - /// Cancellation token. - /// The request body type. - /// The expected result type. - /// Result of the request. - /// The request parameters were invalid. - /// The request was unauthorized or forbidden. - /// The WWW-Authenticate response header may be captured in the exception data. - /// The request would have caused a conflict - /// or exceeded a limit. - /// The request failed for some other - /// reason. - /// - /// This protected method enables subclasses to support additional tunnel management APIs. - /// Authentication will use one of the following, if available, in order of preference: - /// - on - /// - token provided by the user token callback - /// - protected async Task SendRequestAsync( - HttpMethod method, - string? clusterId, - string path, - string? query, - TunnelRequestOptions? options, - TRequest? body, - CancellationToken cancellation) - where TRequest : class - { - var uri = BuildUri(clusterId, path, query, options); - Tunnel? tunnel = null; - var authHeader = await GetAuthenticationHeaderAsync( - tunnel: tunnel, accessTokenScopes: null, options); - return await SendRequestAsync( - method, uri, options, authHeader, body, cancellation); - } - - /// - /// Sends an HTTP request with body content to the tunnel management API, with an - /// explicit authentication header value. - /// - private async Task SendRequestAsync( - HttpMethod method, - Uri uri, - TunnelRequestOptions? options, - AuthenticationHeaderValue? authHeader, - TRequest? body, - CancellationToken cancellation) - where TRequest : class - { - if (authHeader?.Scheme == TunnelAuthenticationSchemes.TunnelPlan) - { - var token = TunnelPlanTokenProperties.TryParse(authHeader.Parameter ?? string.Empty); - if (!string.IsNullOrEmpty(token?.ClusterId)) - { - var uriStr = uri.ToString().Replace("global.", $"{token.ClusterId}."); - uri = new Uri(uriStr); - } - } - - var request = new HttpRequestMessage(method, uri); - request.Headers.Authorization = authHeader; - - var emptyHeadersList = Enumerable.Empty>(); - var additionalHeaders = (AdditionalRequestHeaders ?? emptyHeadersList).Concat( - options?.AdditionalHeaders ?? emptyHeadersList); - - foreach (var headerNameAndValue in additionalHeaders) - { - request.Headers.Add(headerNameAndValue.Key, headerNameAndValue.Value); - } - - foreach (ProductInfoHeaderValue userAgent in UserAgents) - { - request.Headers.UserAgent.Add(userAgent); - } - - var localMachineHeaders = TunnelUserAgent.GetMachineHeaders(); - if (localMachineHeaders != null) - { - request.Headers.UserAgent.Add(localMachineHeaders); - } - - request.Headers.UserAgent.Add(TunnelSdkUserAgent); - - // Add Group Policies - const string policyRegKeyPath = @"Software\Policies\Microsoft\DevTunnels"; - var policyProvider = new PolicyProvider(policyRegKeyPath); - var policyHeaderValue = policyProvider.GetHeaderValue(); - if (!string.IsNullOrEmpty(policyHeaderValue)) - { - request.Headers.Add("User-Agent-Policies", policyHeaderValue); - } - - if (body != null) - { - request.Content = JsonContent.Create(body, null, JsonOptions); - } - - if (options?.FollowRedirects == false) - { - FollowRedirectsHttpHandler.SetFollowRedirectsEnabledForRequest(request, false); - } - - options?.SetRequestOptions(request); - - var response = await this.httpClient.SendAsync(request, cancellation); - var result = await ConvertResponseAsync( - method, - response, - cancellation); - return result; - } - - /// - /// Converts a tunnel service HTTP response to a result object (or exception). - /// - /// Type of result expected, or bool to just check for either success or - /// not-found. - /// Request method. - /// Response from a tunnel service request. - /// Cancellation token. - /// Result object of the requested type, or false if the response was 404 and - /// the result type is boolean, or null if a GET request for a non-array result object type - /// returned 404 Not Found. - /// The service returned a - /// 400 Bad Request response. - /// The service returned a 401 Unauthorized - /// or 403 Forbidden response. - private static async Task ConvertResponseAsync( - HttpMethod method, - HttpResponseMessage response, - CancellationToken cancellation) - { - Requires.NotNull(response, nameof(response)); - - // Requests that expect a boolean result just check for success or not-found result. - // GET requests that expect a single object result return null for not found result. - // GET requests that expect an array result should throw an error for not-found result - // because empty array was expected instead. - // PUT/POST/PATCH requests should also throw an error for not-found. - bool allowNotFound = typeof(T) == typeof(bool) || - ((method == HttpMethod.Get || method == HttpMethod.Head) && !typeof(T).IsArray && typeof(T) != typeof(TunnelPortListResponse) && typeof(T) != typeof(TunnelListByRegionResponse)); - - string? errorMessage = null; - Exception? innerException = null; - if (response.IsSuccessStatusCode) - { - if (response.StatusCode == HttpStatusCode.NoContent || response.Content == null) - { - return typeof(T) == typeof(bool) ? (T?)(object)(bool?)true : default; - } - - try - { - T? result = await response.Content.ReadFromJsonAsync( - JsonOptions, cancellation); - return result; - } - catch (Exception ex) - { - innerException = ex; - errorMessage = "Tunnel service response deserialization error: " + ex.Message; - } - } - - if (errorMessage == null && response.Content != null) - { - try - { - if ((int)response.StatusCode >= 400 && (int)response.StatusCode < 500) - { - // 4xx status responses may include standard ProblemDetails. - var problemDetails = await response.Content - .ReadFromJsonAsync(JsonOptions, cancellation); - if (!string.IsNullOrEmpty(problemDetails?.Title) || - !string.IsNullOrEmpty(problemDetails?.Detail)) - { - if (allowNotFound && response.StatusCode == HttpStatusCode.NotFound && - problemDetails.Detail == null) - { - return default; - } - - errorMessage = "Tunnel service error: " + - problemDetails!.Title + " " + problemDetails.Detail; - if (problemDetails.Errors != null) - { - foreach (var error in problemDetails.Errors) - { - var messages = string.Join(" ", error.Value); - errorMessage += $"\n{error.Key}: {messages}"; - } - } - } - } - else if ((int)response.StatusCode >= 500) - { - // 5xx status responses may include VS SaaS error details. - var errorDetails = await response.Content.ReadFromJsonAsync( - JsonOptions, cancellation); - if (!string.IsNullOrEmpty(errorDetails?.Message)) - { - errorMessage = "Tunnel service error: " + errorDetails!.Message; - if (!string.IsNullOrEmpty(errorDetails.StackTrace)) - { - errorMessage += "\n" + errorDetails.StackTrace; - } - } - } - } - catch (Exception ex) - { - // A default error message will be filled in below. - innerException = ex; - } - } - - errorMessage ??= "Tunnel service response status code: " + response.StatusCode; - - if (response.Headers.TryGetValues(RequestIdHeaderName, out var requestId)) - { - errorMessage += $"\nRequest ID: {requestId.First()}"; - } - - try - { - response.EnsureSuccessStatusCode(); - } - catch (HttpRequestException hrex) - { - switch (response.StatusCode) - { - case HttpStatusCode.BadRequest: - throw new ArgumentException(errorMessage, hrex); - - case HttpStatusCode.Unauthorized: - case HttpStatusCode.Forbidden: - // Enterprise Policies - if (response.Headers.Contains("X-Enterprise-Policy-Failure")) - { - var message = response.Content != null ? await response.Content.ReadAsStringAsync() : string.Empty; - errorMessage = message; - } - - var ex = new UnauthorizedAccessException(errorMessage, hrex); - - // The HttpResponseHeaders.WwwAuthenticate property does not correctly - // handle multiple values! Get the values by name instead. - if (response.Headers.TryGetValues( - "WWW-Authenticate", out var authHeaderValues)) - { - ex.SetAuthenticationSchemes(authHeaderValues); - } - - throw ex; - - case HttpStatusCode.NotFound: - case HttpStatusCode.Conflict: - case HttpStatusCode.PreconditionFailed: - case HttpStatusCode.TooManyRequests: - throw new InvalidOperationException(errorMessage, hrex); - - case HttpStatusCode.Redirect: - case HttpStatusCode.RedirectKeepVerb: - // Add the redirect location to the exception data. - // Normally the HTTP client should automatically follow redirects, - // but this allows tests to validate the service's redirection behavior - // when client auto redirection is disabled. - hrex.Data["Location"] = response.Headers.Location; - throw; - - default: throw; - } - } - - throw new Exception(errorMessage, innerException); - } - - /// - /// Error details that may be returned from the service with 500 status responses - /// (when in development mode). - /// - /// - /// Copied from Microsoft.VsSaaS.Common to avoid taking a dependency on that assembly. - /// - private class ErrorDetails - { - public string? Message { get; set; } - public string? StackTrace { get; set; } - } - - /// - public void Dispose() - { - this.httpClient.Dispose(); - } - - private Uri BuildUri( - string? clusterId, - string path, - string? query, - TunnelRequestOptions? options) - { - Requires.NotNullOrEmpty(path, nameof(path)); - - var baseAddress = this.httpClient.BaseAddress!; - var builder = new UriBuilder(baseAddress); - - if (!string.IsNullOrEmpty(clusterId) && - baseAddress.HostNameType == UriHostNameType.Dns) - { - if (baseAddress.Host != "localhost" && - !baseAddress.Host.StartsWith($"{clusterId}.")) - { - // A specific cluster ID was specified (while not running on localhost). - // Prepend the cluster ID to the hostname, and optionally strip a global prefix. - builder.Host = $"{clusterId}.{builder.Host}".Replace("global.", string.Empty); - } - else if (baseAddress.Scheme == "https" && - clusterId.StartsWith("localhost") && builder.Port % 10 > 0 && - ushort.TryParse(clusterId.Substring("localhost".Length), out var clusterNumber)) - { - // Local testing simulates clusters by running the service on multiple ports. - // Change the port number to match the cluster ID suffix. - if (clusterNumber > 0 && clusterNumber < 10) - { - builder.Port = builder.Port - (builder.Port % 10) + clusterNumber; - } - } - } - - if (options != null) - { - var optionsQuery = options.ToQueryString(); - if (!string.IsNullOrEmpty(optionsQuery)) - { - query = optionsQuery + - (!string.IsNullOrEmpty(query) ? '&' + query : string.Empty); - } - } - - builder.Path = path; - builder.Query = query; - return builder.Uri; - } - - private Uri BuildTunnelUri( - Tunnel tunnel, - string? path, - string? query, - TunnelRequestOptions? options, - bool isCreate = false) - { - Requires.NotNull(tunnel, nameof(tunnel)); - - string tunnelPath; - var pathBase = TunnelsPath; - if (!string.IsNullOrEmpty(tunnel.TunnelId) && (!string.IsNullOrEmpty(tunnel.ClusterId) || isCreate)) - { - tunnelPath = $"{pathBase}/{tunnel.TunnelId}"; - } - else - { - Requires.Argument( - !string.IsNullOrEmpty(tunnel.Name), - nameof(tunnel), - "Tunnel object must include either a name or tunnel ID and cluster ID."); - - if (string.IsNullOrEmpty(tunnel.Domain)) - { - - tunnelPath = $"{pathBase}/{tunnel.Name}"; - } - else - { - // Append the domain to the tunnel name. - tunnelPath = $"{pathBase}/{tunnel.Name}.{tunnel.Domain}"; - } - } - - return BuildUri( - tunnel.ClusterId, - tunnelPath + (!string.IsNullOrEmpty(path) ? path : string.Empty), - query, - options); - } - - private async Task GetAuthenticationHeaderAsync( - Tunnel? tunnel, - string[]? accessTokenScopes, - TunnelRequestOptions? options) - { - AuthenticationHeaderValue? authHeader = null; - - if (!string.IsNullOrEmpty(options?.AccessToken)) - { - authHeader = new AuthenticationHeaderValue( - TunnelAuthenticationScheme, options.AccessToken); - } - - if (authHeader == null) - { - authHeader = await this.userTokenCallback(); - } - - if (authHeader == null && tunnel?.AccessTokens != null && accessTokenScopes != null) - { - foreach (var scope in accessTokenScopes) - { - if (tunnel.TryGetAccessToken(scope, out string? accessToken)) - { - authHeader = new AuthenticationHeaderValue( - TunnelAuthenticationScheme, accessToken); - break; - } - } - } - - return authHeader; - } - - /// - public async Task ListTunnelsAsync( - string? clusterId, - string? domain, - TunnelRequestOptions? options, - bool? ownedTunnelsOnly, - CancellationToken cancellation) - { - var queryParams = new string?[] - { - string.IsNullOrEmpty(clusterId) ? "global=true" : null, - !string.IsNullOrEmpty(domain) ? $"domain={HttpUtility.UrlEncode(domain)}" : null, - !string.IsNullOrEmpty(ApiVersion) ? GetApiQuery() : null, - ownedTunnelsOnly == true ? "ownedTunnelsOnly=true" : null, - }; - var query = string.Join("&", queryParams.Where((p) => p != null)); - var result = await this.SendRequestAsync( - HttpMethod.Get, - clusterId, - TunnelsPath, - query, - options, - cancellation); - if (result?.Value != null) - { - return result.Value.Where(t => t.Value != null).SelectMany(t => t.Value!).ToArray(); - } - - return Array.Empty(); - } - - /// - [Obsolete("Use ListTunnelsAsync() method with TunnelRequestOptions.Labels instead.")] - public async Task SearchTunnelsAsync( - string[] labels, - bool requireAllLabels, - string? clusterId, - string? domain, - TunnelRequestOptions? options, - CancellationToken cancellation) - { - var queryParams = new string?[] - { - string.IsNullOrEmpty(clusterId) ? "global=true" : null, - !string.IsNullOrEmpty(domain) ? $"domain={HttpUtility.UrlEncode(domain)}" : null, - $"labels={string.Join(",", labels.Select(HttpUtility.UrlEncode))}", - $"allLabels={requireAllLabels}", - !string.IsNullOrEmpty(ApiVersion) ? GetApiQuery() : null, - }; - var query = string.Join("&", queryParams.Where((p) => p != null)); - var result = await this.SendRequestAsync( - HttpMethod.Get, - clusterId, - TunnelsPath, - query, - options, - cancellation); - return result!; - } - - /// - public async Task GetTunnelAsync( - Tunnel tunnel, - TunnelRequestOptions? options, - CancellationToken cancellation) - { - var result = await this.SendTunnelRequestAsync( - HttpMethod.Get, - tunnel, - ReadAccessTokenScopes, - path: null, - query: GetApiQuery(), - options, - cancellation); - PreserveAccessTokens(tunnel, result); - return result; - } - - /// - public async Task CreateTunnelAsync( - Tunnel tunnel, - TunnelRequestOptions? options, - CancellationToken cancellation) - { - Requires.NotNull(tunnel, nameof(tunnel)); - options ??= new TunnelRequestOptions(); - options.AdditionalHeaders ??= new List>(); - options.AdditionalHeaders = options.AdditionalHeaders.Append(new KeyValuePair("If-None-Match", "*")); - var tunnelId = tunnel.TunnelId; - var idGenerated = string.IsNullOrEmpty(tunnelId); - if (idGenerated) - { - tunnel.TunnelId = IdGeneration.GenerateTunnelId(); - } - for (int retries = 0; retries <= CreateNameRetries; retries++) - { - try - { - var result = await this.SendTunnelRequestAsync( - HttpMethod.Put, - tunnel, - ManageAccessTokenScope, - path: null, - query: GetApiQuery(), - options, - ConvertTunnelForRequest(tunnel), - cancellation, - true); - PreserveAccessTokens(tunnel, result); - return result!; - } - catch (UnauthorizedAccessException) when (idGenerated && retries < CreateNameRetries) // The tunnel ID was already taken. - { - tunnel.TunnelId = IdGeneration.GenerateTunnelId(); - } - } - - // This code is unreachable, but the compiler still requires it. - var result2 = await this.SendTunnelRequestAsync( - HttpMethod.Put, - tunnel, - ManageAccessTokenScope, - path: null, - query: GetApiQuery(), - options, - ConvertTunnelForRequest(tunnel), - cancellation, - true); - PreserveAccessTokens(tunnel, result2); - return result2!; - } - - /// - public async Task CreateOrUpdateTunnelAsync( - Tunnel tunnel, - TunnelRequestOptions? options, - CancellationToken cancellation) - { - Requires.NotNull(tunnel, nameof(tunnel)); - - var tunnelId = tunnel.TunnelId; - var idGenerated = string.IsNullOrEmpty(tunnelId); - if (idGenerated) - { - tunnel.TunnelId = IdGeneration.GenerateTunnelId(); - } - for (int retries = 0; retries <= CreateNameRetries; retries++) - { - try - { - var result = await this.SendTunnelRequestAsync( - HttpMethod.Put, - tunnel, - ManageAccessTokenScope, - path: null, - query: GetApiQuery(), - options, - ConvertTunnelForRequest(tunnel), - cancellation, - true); - PreserveAccessTokens(tunnel, result); - return result!; - } - catch (UnauthorizedAccessException) when (idGenerated && retries < 3) // The tunnel ID was already taken. - { - tunnel.TunnelId = IdGeneration.GenerateTunnelId(); - } - } - - // This code is unreachable, but the compiler still requires it. - var result2 = await this.SendTunnelRequestAsync( - HttpMethod.Put, - tunnel, - ManageAccessTokenScope, - path: null, - query: GetApiQuery(), - options, - ConvertTunnelForRequest(tunnel), - cancellation, - true); - PreserveAccessTokens(tunnel, result2); - return result2!; - } - - /// - public async Task UpdateTunnelAsync( - Tunnel tunnel, - TunnelRequestOptions? options, - CancellationToken cancellation) - { - options ??= new TunnelRequestOptions(); - options.AdditionalHeaders ??= new List>(); - options.AdditionalHeaders = options.AdditionalHeaders.Append(new KeyValuePair("If-Match", "*")); - var result = await this.SendTunnelRequestAsync( - HttpMethod.Put, - tunnel, - ManageAccessTokenScope, - path: null, - query: GetApiQuery(), - options, - ConvertTunnelForRequest(tunnel), - cancellation); - PreserveAccessTokens(tunnel, result); - return result!; - } - - /// - public async Task DeleteTunnelAsync( - Tunnel tunnel, - TunnelRequestOptions? options, - CancellationToken cancellation) - { - var result = await this.SendTunnelRequestAsync( - HttpMethod.Delete, - tunnel, - ManageAccessTokenScope, - path: null, - query: GetApiQuery(), - options, - cancellation); - return result; - } - - /// - public async Task UpdateTunnelEndpointAsync( - Tunnel tunnel, - TunnelEndpoint endpoint, - TunnelRequestOptions? options = null, - CancellationToken cancellation = default) - { - Requires.NotNull(endpoint, nameof(endpoint)); - Requires.NotNullOrEmpty(endpoint.HostId!, nameof(TunnelEndpoint.HostId)); - Requires.NotNullOrEmpty(endpoint.Id!, nameof(TunnelEndpoint.Id)); - - var path = $"{EndpointsApiSubPath}/{endpoint.Id}"; - var query = GetApiQuery(); - query += "&connectionMode=" + endpoint.ConnectionMode; - var result = (await this.SendTunnelRequestAsync( - HttpMethod.Put, - tunnel, - HostAccessTokenScope, - path, - query: query, - options, - endpoint, - cancellation))!; - - - if (tunnel.Endpoints != null) - { - // Also update the endpoint in the local tunnel object. - tunnel.Endpoints = tunnel.Endpoints - .Where((e) => e.HostId != endpoint.HostId || - e.ConnectionMode != endpoint.ConnectionMode) - .Append(result) - .ToArray(); - } - - return result; - } - - /// - public async Task DeleteTunnelEndpointsAsync( - Tunnel tunnel, - string id, - TunnelRequestOptions? options = null, - CancellationToken cancellation = default) - { - Requires.NotNullOrEmpty(id, nameof(id)); - - var path = $"{EndpointsApiSubPath}/{id}"; - var result = await this.SendTunnelRequestAsync( - HttpMethod.Delete, - tunnel, - HostAccessTokenScope, - path, - query: GetApiQuery(), - options, - cancellation); - - if (result && tunnel.Endpoints != null) - { - // Also delete the endpoint in the local tunnel object. - tunnel.Endpoints = tunnel.Endpoints - .Where((e) => e.Id != id) - .ToArray(); - } - - return result; - } - - /// - public async Task ListTunnelPortsAsync( - Tunnel tunnel, - TunnelRequestOptions? options, - CancellationToken cancellation) - { - var result = await this.SendTunnelRequestAsync( - HttpMethod.Get, - tunnel, - ReadAccessTokenScopes, - PortsApiSubPath, - query: GetApiQuery(), - options, - cancellation); - return result!.Value!; - } - - /// - public async Task GetTunnelPortAsync( - Tunnel tunnel, - ushort portNumber, - TunnelRequestOptions? options, - CancellationToken cancellation) - { - this.OnReportProgress(TunnelProgress.StartingGetTunnelPort); - var path = $"{PortsApiSubPath}/{portNumber}"; - var result = await this.SendTunnelRequestAsync( - HttpMethod.Get, - tunnel, - ReadAccessTokenScopes, - path, - query: GetApiQuery(), - options, - cancellation); - this.OnReportProgress(TunnelProgress.CompletedGetTunnelPort); - return result; - } - - /// - public async Task CreateTunnelPortAsync( - Tunnel tunnel, - TunnelPort tunnelPort, - TunnelRequestOptions? options, - CancellationToken cancellation) - { - Requires.NotNull(tunnelPort, nameof(tunnelPort)); - this.OnReportProgress(TunnelProgress.StartingCreateTunnelPort); - var path = $"{PortsApiSubPath}/{tunnelPort.PortNumber}"; - options ??= new TunnelRequestOptions(); - options.AdditionalHeaders ??= new List>(); - options.AdditionalHeaders = options.AdditionalHeaders.Append(new KeyValuePair("If-None-Match", "*")); - - var result = (await this.SendTunnelRequestAsync( - HttpMethod.Put, - tunnel, - ManagePortsAccessTokenScopes, - path, - query: GetApiQuery(), - options, - ConvertTunnelPortForRequest(tunnel, tunnelPort), - cancellation))!; - PreserveAccessTokens(tunnelPort, result); - - tunnel.Ports ??= new TunnelPort[0]; - - // Also add the port to the local tunnel object. - tunnel.Ports = tunnel.Ports - .Where((p) => p.PortNumber != tunnelPort.PortNumber) - .Append(result) - .OrderBy((p) => p.PortNumber) - .ToArray(); - this.OnReportProgress(TunnelProgress.CompletedCreateTunnelPort); - return result; - } - - /// - public async Task UpdateTunnelPortAsync( - Tunnel tunnel, - TunnelPort tunnelPort, - TunnelRequestOptions? options, - CancellationToken cancellation) - { - Requires.NotNull(tunnelPort, nameof(tunnelPort)); - options ??= new TunnelRequestOptions(); - options.AdditionalHeaders ??= new List>(); - options.AdditionalHeaders = options.AdditionalHeaders.Append(new KeyValuePair("If-Match", "*")); - - if (tunnelPort.ClusterId != null && tunnel.ClusterId != null && - tunnelPort.ClusterId != tunnel.ClusterId) - { - throw new ArgumentException( - "Tunnel port cluster ID is not consistent.", nameof(tunnelPort)); - } - - var portNumber = tunnelPort.PortNumber; - var path = $"{PortsApiSubPath}/{portNumber}"; - var result = (await this.SendTunnelRequestAsync( - HttpMethod.Put, - tunnel, - ManagePortsAccessTokenScopes, - path, - query: GetApiQuery(), - options, - ConvertTunnelPortForRequest(tunnel, tunnelPort), - cancellation))!; - PreserveAccessTokens(tunnelPort, result); - - tunnel.Ports ??= new TunnelPort[0]; - - // Also add the port to the local tunnel object. - tunnel.Ports = tunnel.Ports - .Where((p) => p.PortNumber != tunnelPort.PortNumber) - .Append(result) - .OrderBy((p) => p.PortNumber) - .ToArray(); - - - return result; - } - - /// - public async Task CreateOrUpdateTunnelPortAsync( - Tunnel tunnel, - TunnelPort tunnelPort, - TunnelRequestOptions? options, - CancellationToken cancellation) - { - Requires.NotNull(tunnelPort, nameof(tunnelPort)); - - if (tunnelPort.ClusterId != null && tunnel.ClusterId != null && - tunnelPort.ClusterId != tunnel.ClusterId) - { - throw new ArgumentException( - "Tunnel port cluster ID is not consistent.", nameof(tunnelPort)); - } - - var portNumber = tunnelPort.PortNumber; - var path = $"{PortsApiSubPath}/{portNumber}"; - var result = (await this.SendTunnelRequestAsync( - HttpMethod.Put, - tunnel, - ManagePortsAccessTokenScopes, - path, - query: GetApiQuery(), - options, - ConvertTunnelPortForRequest(tunnel, tunnelPort), - cancellation))!; - PreserveAccessTokens(tunnelPort, result); - - tunnel.Ports ??= new TunnelPort[0]; - - // Also add the port to the local tunnel object. - tunnel.Ports = tunnel.Ports - .Where((p) => p.PortNumber != tunnelPort.PortNumber) - .Append(result) - .OrderBy((p) => p.PortNumber) - .ToArray(); - - - return result; - } - - /// - public async Task DeleteTunnelPortAsync( - Tunnel tunnel, - ushort portNumber, - TunnelRequestOptions? options, - CancellationToken cancellation) - { - var path = $"{PortsApiSubPath}/{portNumber}"; - var result = await this.SendTunnelRequestAsync( - HttpMethod.Delete, - tunnel, - ManagePortsAccessTokenScopes, - path, - query: GetApiQuery(), - options, - cancellation); - - if (result && tunnel.Ports != null) - { - // Also delete the port in the local tunnel object. - tunnel.Ports = tunnel.Ports - .Where((p) => p.PortNumber != portNumber) - .OrderBy((p) => p.PortNumber) - .ToArray(); - } - - return result; - } - - /// - /// Event fired when a tunnel progress event has been reported. - /// - protected virtual void OnReportProgress(TunnelProgress progress) - { - if (ReportProgress is EventHandler handler) - { - var args = new TunnelReportProgressEventArgs(progress.ToString()); - handler.Invoke(this, args); - } - } - - /// - /// Removes read-only properties like tokens and status from create/update requests. - /// - private Tunnel ConvertTunnelForRequest(Tunnel tunnel) - { - return new Tunnel - { - TunnelId = tunnel.TunnelId, - Name = tunnel.Name, - Domain = tunnel.Domain, - Description = tunnel.Description, - Labels = tunnel.Labels, - CustomExpiration = tunnel.CustomExpiration, - Options = tunnel.Options, - AccessControl = tunnel.AccessControl == null ? null : new TunnelAccessControl( - tunnel.AccessControl.Where((ace) => !ace.IsInherited)), - Endpoints = tunnel.Endpoints, - Ports = tunnel.Ports? - .Select((p) => ConvertTunnelPortForRequest(tunnel, p)) - .ToArray(), - }; - } - - /// - /// Removes read-only properties like tokens and status from create/update requests. - /// - private TunnelPort ConvertTunnelPortForRequest(Tunnel tunnel, TunnelPort tunnelPort) - { - if (tunnelPort.ClusterId != null && tunnel.ClusterId != null && - tunnelPort.ClusterId != tunnel.ClusterId) - { - throw new ArgumentException( - "Tunnel port cluster ID does not match tunnel.", nameof(tunnelPort)); - } - - if (tunnelPort.TunnelId != null && tunnel.TunnelId != null && - tunnelPort.TunnelId != tunnel.TunnelId) - { - throw new ArgumentException( - "Tunnel port tunnel ID does not match tunnel.", nameof(tunnelPort)); - } - - return new TunnelPort - { - PortNumber = tunnelPort.PortNumber, - Protocol = tunnelPort.Protocol, - IsDefault = tunnelPort.IsDefault, - Description = tunnelPort.Description, - Labels = tunnelPort.Labels, - Options = tunnelPort.Options, - AccessControl = tunnelPort.AccessControl == null ? null : new TunnelAccessControl( - tunnelPort.AccessControl.Where((ace) => !ace.IsInherited)), - SshUser = tunnelPort.SshUser, - }; - } - - /// - public async Task FormatSubjectsAsync( - TunnelAccessSubject[] subjects, - TunnelRequestOptions? options = null, - CancellationToken cancellation = default) - { - Requires.NotNull(subjects, nameof(subjects)); - - if (subjects.Length == 0) - { - return subjects; - } - - var formattedSubjects = await SendRequestAsync - ( - HttpMethod.Post, - clusterId: null, - SubjectsPath + "/format", - query: GetApiQuery(), - options, - subjects, - cancellation); - return formattedSubjects!; - } - - /// - public async Task ResolveSubjectsAsync( - TunnelAccessSubject[] subjects, - TunnelRequestOptions? options = null, - CancellationToken cancellation = default) - { - Requires.NotNull(subjects, nameof(subjects)); - - if (subjects.Length == 0) - { - return subjects; - } - - var resolvedSubjects = await SendRequestAsync - ( - HttpMethod.Post, - clusterId: null, - SubjectsPath + "/resolve", - query: GetApiQuery(), - options, - subjects, - cancellation); - return resolvedSubjects!; - } - - /// - public async Task ListUserLimitsAsync(CancellationToken cancellation = default) - { - var userLimits = await SendRequestAsync( - HttpMethod.Get, - clusterId: null, - UserLimitsPath, - query: GetApiQuery(), - options: null, - cancellation); - return userLimits!; - } - - /// - public async Task ListClustersAsync(CancellationToken cancellation) - { - var baseAddress = this.httpClient.BaseAddress!; - var builder = new UriBuilder(baseAddress); - builder.Path = ClustersPath; - builder.Query = GetApiQuery(); - var clusterDetails = await SendRequestAsync( - HttpMethod.Get, - builder.Uri, - options: null, - authHeader: null, - body: null, - cancellation); - return clusterDetails!; - } - - /// - public async Task CheckNameAvailabilityAsync( - string name, - CancellationToken cancellation = default) - { - name = Uri.EscapeDataString(name); - Requires.NotNull(name, nameof(name)); - return await this.SendRequestAsync( - HttpMethod.Get, - clusterId: null, - TunnelsPath + "/" + name + CheckAvailableSubPath, - query: GetApiQuery(), - options: null, - cancellation - ); - } - - /// - /// Gets required query string parmeters - /// - /// Query string - protected virtual string? GetApiQuery() - { - return string.IsNullOrEmpty(ApiVersion) ? null : $"api-version={ApiVersion}"; - } - - /// - /// Copy access tokens from the request object to the result object, except for any - /// tokens that were refreshed by the request. - /// - /// - /// This intentionally does not check whether any existing tokens are expired. So - /// expired tokens may be preserved also, if not refreshed. This allows for better - /// diagnostics in that case. - /// - private static void PreserveAccessTokens(Tunnel requestTunnel, Tunnel? resultTunnel) - { - if (requestTunnel.AccessTokens != null && resultTunnel != null) - { - resultTunnel.AccessTokens ??= new Dictionary(); - foreach (var scopeAndToken in requestTunnel.AccessTokens) - { - if (!resultTunnel.AccessTokens.ContainsKey(scopeAndToken.Key)) - { - resultTunnel.AccessTokens[scopeAndToken.Key] = scopeAndToken.Value; - } - } - } - } - - /// - /// Copy access tokens from the request object to the result object, except for any - /// tokens that were refreshed by the request. - /// - private static void PreserveAccessTokens(TunnelPort requestPort, TunnelPort? resultPort) - { - if (requestPort.AccessTokens != null && resultPort != null) - { - resultPort.AccessTokens ??= new Dictionary(); - foreach (var scopeAndToken in requestPort.AccessTokens) - { - if (!resultPort.AccessTokens.ContainsKey(scopeAndToken.Key)) - { - resultPort.AccessTokens[scopeAndToken.Key] = scopeAndToken.Value; - } - } - } - } - } -} \ No newline at end of file +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. +// + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +#if NET5_0_OR_GREATER +using System.Net.Http.Json; +#endif +using System.Threading; +using System.Threading.Tasks; +using System.Web; +using Microsoft.DevTunnels.Contracts; +using static Microsoft.DevTunnels.Contracts.TunnelContracts; + +namespace Microsoft.DevTunnels.Management +{ + /// + /// Implementation of a client that manages tunnels and tunnel ports via the tunnel service + /// management API. + /// + public class TunnelManagementClient : ITunnelManagementClient + { + private const string ApiV1Path = "/api/v1"; + private const string TunnelsV1ApiPath = ApiV1Path + "/tunnels"; + private const string SubjectsV1ApiPath = ApiV1Path + "/subjects"; + private const string UserLimitsV1ApiPath = ApiV1Path + "/userlimits"; + private const string TunnelsApiPath = "/tunnels"; + private const string SubjectsApiPath = "/subjects"; + private const string UserLimitsApiPath = "/userlimits"; + private const string EndpointsApiSubPath = "/endpoints"; + private const string PortsApiSubPath = "/ports"; + private const string ClustersApiPath = "/clusters"; + private const string ClustersV1ApiPath = ApiV1Path + "/clusters"; + private const string TunnelAuthenticationScheme = "Tunnel"; + private const string RequestIdHeaderName = "VsSaaS-Request-Id"; + private const string CheckAvailableSubPath = ":checkNameAvailability"; + private const int CreateNameRetries = 3; + + private static readonly string[] ManageAccessTokenScope = + new[] { TunnelAccessScopes.Manage }; + private static readonly string[] HostAccessTokenScope = + new[] { TunnelAccessScopes.Host }; + private static readonly string[] ManagePortsAccessTokenScopes = new[] + { + TunnelAccessScopes.Manage, + TunnelAccessScopes.ManagePorts, + TunnelAccessScopes.Host, + }; + private static readonly string[] ReadAccessTokenScopes = new[] + { + TunnelAccessScopes.Manage, + TunnelAccessScopes.ManagePorts, + TunnelAccessScopes.Host, + TunnelAccessScopes.Connect, + }; + + /// + /// Accepted management client api versions + /// + public string[] TunnelsApiVersions = + { + "2023-09-27-preview" + }; + + /// + /// Event raised to report tunnel management progress. + /// + public event EventHandler? ReportProgress; + + /// + /// ApiVersion that will be used if one is not specified + /// + public const ManagementApiVersions DefaultApiVersion = ManagementApiVersions.Version20230927Preview; + + private static readonly ProductInfoHeaderValue TunnelSdkUserAgent = + TunnelUserAgent.GetUserAgent(typeof(TunnelManagementClient).Assembly, "Dev-Tunnels-Service-CSharp-SDK")!; + + private readonly HttpClient httpClient; + private readonly Func> userTokenCallback; + + /// + /// Initializes a new instance of the class + /// with an optional client authentication callback. + /// + /// User agent. + /// Optional async callback for retrieving a client + /// authentication header, for AAD or GitHub user authentication. This may be null + /// for anonymous tunnel clients, or if tunnel access tokens will be specified via + /// . + /// Api version to use for tunnels requests, accepted + /// values are + public TunnelManagementClient( + ProductInfoHeaderValue userAgent, + Func>? userTokenCallback = null, + ManagementApiVersions apiVersion = DefaultApiVersion) + : this(new[] { userAgent }, userTokenCallback, tunnelServiceUri: null, httpHandler: null, apiVersion) + { + } + + /// + /// Initializes a new instance of the class + /// with an optional client authentication callback. + /// + /// User agent. Muiltiple user agents can be supplied in the + /// case that this SDK is used in a program, such as a CLI, that has users that want + /// to be differentiated. + /// Optional async callback for retrieving a client + /// authentication header, for AAD or GitHub user authentication. This may be null + /// for anonymous tunnel clients, or if tunnel access tokens will be specified via + /// . + /// Api version to use for tunnels requests, accepted + /// values are + public TunnelManagementClient( + ProductInfoHeaderValue[] userAgents, + Func>? userTokenCallback = null, + ManagementApiVersions apiVersion = DefaultApiVersion) + : this(userAgents, userTokenCallback, tunnelServiceUri: null, httpHandler: null, apiVersion) + { + } + + /// + /// Initializes a new instance of the class + /// with a client authentication callback, service URI, and HTTP handler. + /// + /// User agent. + /// Optional async callback for retrieving a client + /// authentication header value with access token, for AAD or GitHub user authentication. + /// This may be null for anonymous tunnel clients, or if tunnel access tokens will be + /// specified via . + /// Optional tunnel service URI (not including any path), + /// or null to use the default global service URI. + /// Optional HTTP handler or handler chain that will be invoked + /// for HTTPS requests to the tunnel service. The or + /// specified (or at the end of the chain) must have + /// automatic redirection disabled. The provided HTTP handler will not be disposed + /// by . + /// Api version to use for tunnels requests, accepted + /// values are + public TunnelManagementClient( + ProductInfoHeaderValue userAgent, + Func>? userTokenCallback = null, + Uri? tunnelServiceUri = null, + HttpMessageHandler? httpHandler = null, + ManagementApiVersions apiVersion = DefaultApiVersion) + : this(new[] { userAgent }, userTokenCallback, tunnelServiceUri, httpHandler, apiVersion) + { + } + + /// + /// Initializes a new instance of the class + /// with a client authentication callback, service URI, and HTTP handler. + /// + /// User agent. Muiltiple user agents can be supplied in the + /// case that this SDK is used in a program, such as a CLI, that has users that want + /// to be differentiated. + /// Optional async callback for retrieving a client + /// authentication header value with access token, for AAD or GitHub user authentication. + /// This may be null for anonymous tunnel clients, or if tunnel access tokens will be + /// specified via . + /// Optional tunnel service URI (not including any path), + /// or null to use the default global service URI. + /// Optional HTTP handler or handler chain that will be invoked + /// for HTTPS requests to the tunnel service. The or + /// specified (or at the end of the chain) must have + /// automatic redirection disabled. The provided HTTP handler will not be disposed + /// by . + /// Api version to use for tunnels requests, accepted + /// values are + public TunnelManagementClient( + ProductInfoHeaderValue[] userAgents, + Func>? userTokenCallback = null, + Uri? tunnelServiceUri = null, + HttpMessageHandler? httpHandler = null, + ManagementApiVersions apiVersionEnum = DefaultApiVersion) + { + Requires.NotNullEmptyOrNullElements(userAgents, nameof(userAgents)); + UserAgents = Requires.NotNull(userAgents, nameof(userAgents)); + var apiVersion = apiVersionEnum.ToVersionString(); + if (!string.IsNullOrEmpty(apiVersion) && !TunnelsApiVersions.Contains(apiVersion)) + { + throw new ArgumentException( + $"Invalid apiVersion, accpeted values are {string.Join(", ", TunnelsApiVersions)} "); + } + ApiVersion = apiVersion; + + this.userTokenCallback = userTokenCallback ?? + (() => Task.FromResult(null)); + + httpHandler ??= new SocketsHttpHandler + { + AllowAutoRedirect = false, + }; + ValidateHttpHandler(httpHandler); + + tunnelServiceUri ??= new Uri(TunnelServiceProperties.Production.ServiceUri); + if (!tunnelServiceUri.IsAbsoluteUri || tunnelServiceUri.PathAndQuery != "/") + { + throw new ArgumentException( + $"Invalid tunnel service URI: {tunnelServiceUri}", nameof(tunnelServiceUri)); + } + + // The `SocketsHttpHandler` or `HttpClientHandler` automatic redirection is disabled + // because they do not keep the Authorization header when redirecting. This handler + // will keep all headers when redirecting, and also supports switching the behavior + // per-request. + httpHandler = new FollowRedirectsHttpHandler(httpHandler); + + this.httpClient = new HttpClient(httpHandler, disposeHandler: false) + { + BaseAddress = tunnelServiceUri, + }; + } + + private static void ValidateHttpHandler(HttpMessageHandler httpHandler) + { + while (httpHandler is DelegatingHandler delegatingHandler) + { + httpHandler = delegatingHandler.InnerHandler!; + } + + if (httpHandler is SocketsHttpHandler socketsHandler) + { + if (socketsHandler.AllowAutoRedirect) + { + throw new ArgumentException( + "Tunnel client HTTP handler must have automatic redirection disabled.", + nameof(httpHandler)); + } + } + else if (httpHandler is HttpClientHandler httpClientHandler) + { + if (httpClientHandler.AllowAutoRedirect) + { + throw new ArgumentException( + "Tunnel client HTTP handler must have automatic redirection disabled.", + nameof(httpHandler)); + } + else if (httpClientHandler.UseDefaultCredentials) + { + throw new ArgumentException( + "Tunnel client HTTP handler must not use default credentials.", + nameof(httpHandler)); + } + } + else + { + throw new NotSupportedException( + $"Unsupported HTTP handler type: {httpHandler?.GetType().Name}. " + + "HTTP handler chain must consist of 0 or more DelegatingHandlers " + + "ending with a HttpClientHandler."); + } + } + + /// + /// Gets or sets additional headers that are added to every request. + /// + public IEnumerable>? AdditionalRequestHeaders { get; set; } + + private ProductInfoHeaderValue[] UserAgents { get; } + + private string? ApiVersion { get; } + + private string TunnelsPath + { + get { return string.IsNullOrEmpty(ApiVersion) ? TunnelsV1ApiPath : TunnelsApiPath; } + } + + private string ClustersPath + { + get { return string.IsNullOrEmpty(ApiVersion) ? ClustersV1ApiPath : ClustersApiPath; } + } + + private string SubjectsPath + { + get { return string.IsNullOrEmpty(ApiVersion) ? SubjectsV1ApiPath : SubjectsApiPath; } + } + + private string UserLimitsPath + { + get { return string.IsNullOrEmpty(ApiVersion) ? UserLimitsV1ApiPath : UserLimitsApiPath; } + } + + /// + /// Sends an HTTP request to the tunnel management API, targeting a specific tunnel. + /// + /// HTTP request method. + /// Tunnel that the request is targeting. + /// Required list of access scopes for tokens in + /// that could be used to + /// authorize the request. + /// Optional request sub-path relative to the tunnel. + /// Optional query string to append to the request. + /// Request options. + /// Cancellation token. + /// The expected result type. + /// Result of the request. + /// The request parameters were invalid. + /// The request was unauthorized or forbidden. + /// The WWW-Authenticate response header may be captured in the exception data. + /// The request would have caused a conflict + /// or exceeded a limit. + /// The request failed for some other + /// reason. + /// + /// This protected method enables subclasses to support additional tunnel management APIs. + /// Authentication will use one of the following, if available, in order of preference: + /// - on + /// - token provided by the user token callback + /// - token in that matches + /// one of the scopes in + /// + protected Task SendTunnelRequestAsync( + HttpMethod method, + Tunnel tunnel, + string[] accessTokenScopes, + string? path, + string? query, + TunnelRequestOptions? options, + CancellationToken cancellation) + { + return SendTunnelRequestAsync( + method, + tunnel, + accessTokenScopes, + path, + query, + options, + body: null, + cancellation); + } + + /// + /// Sends an HTTP request with body content to the tunnel management API, targeting a + /// specific tunnel. + /// + /// HTTP request method. + /// Tunnel that the request is targeting. + /// Required list of access scopes for tokens in + /// that could be used to + /// authorize the request. + /// Optional request sub-path relative to the tunnel. + /// Optional query string to append to the request. + /// Request options. + /// Request body object. + /// Cancellation token. + /// Whether the request is a create operation. + /// The request body type. + /// The expected result type. + /// Result of the request. + /// The request parameters were invalid. + /// The request was unauthorized or forbidden. + /// The WWW-Authenticate response header may be captured in the exception data. + /// The request would have caused a conflict + /// or exceeded a limit. + /// The request failed for some other + /// reason. + /// + /// This protected method enables subclasses to support additional tunnel management APIs. + /// Authentication will use one of the following, if available, in order of preference: + /// - on + /// - token provided by the user token callback + /// - token in that matches + /// one of the scopes in + /// + protected async Task SendTunnelRequestAsync( + HttpMethod method, + Tunnel tunnel, + string[] accessTokenScopes, + string? path, + string? query, + TunnelRequestOptions? options, + TRequest? body, + CancellationToken cancellation, + bool isCreate = false) + where TRequest : class + { + this.OnReportProgress(TunnelProgress.StartingRequestUri); + var uri = BuildTunnelUri(tunnel, path, query, options, isCreate); + this.OnReportProgress(TunnelProgress.StartingRequestConfig); + var authHeader = await GetAuthenticationHeaderAsync(tunnel, accessTokenScopes, options); + this.OnReportProgress(TunnelProgress.StartingSendTunnelRequest); + var result = await SendRequestAsync( + method, uri, options, authHeader, body, cancellation); + this.OnReportProgress(TunnelProgress.CompletedSendTunnelRequest); + return result; + } + + /// + /// Sends an HTTP request to the tunnel management API. + /// + /// HTTP request method. + /// Optional tunnel service cluster ID to direct the request to. + /// If unspecified, the request will use the global traffic-manager to find the nearest + /// cluster. + /// Required request path. + /// Optional query string to append to the request. + /// Request options. + /// Cancellation token. + /// The expected result type. + /// Result of the request. + /// The request parameters were invalid. + /// The request was unauthorized or forbidden. + /// The WWW-Authenticate response header may be captured in the exception data. + /// The request would have caused a conflict + /// or exceeded a limit. + /// The request failed for some other + /// reason. + /// + /// This protected method enables subclasses to support additional tunnel management APIs. + /// Authentication will use one of the following, if available, in order of preference: + /// - on + /// - token provided by the user token callback + /// + protected Task SendRequestAsync( + HttpMethod method, + string? clusterId, + string path, + string? query, + TunnelRequestOptions? options, + CancellationToken cancellation) + { + return SendRequestAsync( + method, + clusterId, + path, query, + options, + body: null, + cancellation); + } + + /// + /// Sends an HTTP request with body content to the tunnel management API. + /// + /// HTTP request method. + /// Optional tunnel service cluster ID to direct the request to. + /// If unspecified, the request will use the global traffic-manager to find the nearest + /// cluster. + /// Required request path. + /// Optional query string to append to the request. + /// Request options. + /// Request body object. + /// Cancellation token. + /// The request body type. + /// The expected result type. + /// Result of the request. + /// The request parameters were invalid. + /// The request was unauthorized or forbidden. + /// The WWW-Authenticate response header may be captured in the exception data. + /// The request would have caused a conflict + /// or exceeded a limit. + /// The request failed for some other + /// reason. + /// + /// This protected method enables subclasses to support additional tunnel management APIs. + /// Authentication will use one of the following, if available, in order of preference: + /// - on + /// - token provided by the user token callback + /// + protected async Task SendRequestAsync( + HttpMethod method, + string? clusterId, + string path, + string? query, + TunnelRequestOptions? options, + TRequest? body, + CancellationToken cancellation) + where TRequest : class + { + var uri = BuildUri(clusterId, path, query, options); + Tunnel? tunnel = null; + var authHeader = await GetAuthenticationHeaderAsync( + tunnel: tunnel, accessTokenScopes: null, options); + return await SendRequestAsync( + method, uri, options, authHeader, body, cancellation); + } + + /// + /// Sends an HTTP request with body content to the tunnel management API, with an + /// explicit authentication header value. + /// + private async Task SendRequestAsync( + HttpMethod method, + Uri uri, + TunnelRequestOptions? options, + AuthenticationHeaderValue? authHeader, + TRequest? body, + CancellationToken cancellation) + where TRequest : class + { + if (authHeader?.Scheme == TunnelAuthenticationSchemes.TunnelPlan) + { + var token = TunnelPlanTokenProperties.TryParse(authHeader.Parameter ?? string.Empty); + if (!string.IsNullOrEmpty(token?.ClusterId)) + { + var uriStr = uri.ToString().Replace("global.", $"{token.ClusterId}."); + uri = new Uri(uriStr); + } + } + + var request = new HttpRequestMessage(method, uri); + request.Headers.Authorization = authHeader; + + var emptyHeadersList = Enumerable.Empty>(); + var additionalHeaders = (AdditionalRequestHeaders ?? emptyHeadersList).Concat( + options?.AdditionalHeaders ?? emptyHeadersList); + + foreach (var headerNameAndValue in additionalHeaders) + { + request.Headers.Add(headerNameAndValue.Key, headerNameAndValue.Value); + } + + foreach (ProductInfoHeaderValue userAgent in UserAgents) + { + request.Headers.UserAgent.Add(userAgent); + } + + var localMachineHeaders = TunnelUserAgent.GetMachineHeaders(); + if(localMachineHeaders != null) + { + request.Headers.UserAgent.Add(localMachineHeaders); + } + + request.Headers.UserAgent.Add(TunnelSdkUserAgent); + + // Add Group Policies + const string policyRegKeyPath = @"Software\Policies\Microsoft\DevTunnels"; + var policyProvider = new PolicyProvider(policyRegKeyPath); + var policyHeaderValue = policyProvider.GetHeaderValue(); + if (!string.IsNullOrEmpty(policyHeaderValue)) + { + request.Headers.Add("User-Agent-Policies", policyHeaderValue); + } + + if (body != null) + { + request.Content = JsonContent.Create(body, null, JsonOptions); + } + + if (options?.FollowRedirects == false) + { + FollowRedirectsHttpHandler.SetFollowRedirectsEnabledForRequest(request, false); + } + + options?.SetRequestOptions(request); + + var response = await this.httpClient.SendAsync(request, cancellation); + var result = await ConvertResponseAsync( + method, + response, + cancellation); + return result; + } + + /// + /// Converts a tunnel service HTTP response to a result object (or exception). + /// + /// Type of result expected, or bool to just check for either success or + /// not-found. + /// Request method. + /// Response from a tunnel service request. + /// Cancellation token. + /// Result object of the requested type, or false if the response was 404 and + /// the result type is boolean, or null if a GET request for a non-array result object type + /// returned 404 Not Found. + /// The service returned a + /// 400 Bad Request response. + /// The service returned a 401 Unauthorized + /// or 403 Forbidden response. + private static async Task ConvertResponseAsync( + HttpMethod method, + HttpResponseMessage response, + CancellationToken cancellation) + { + Requires.NotNull(response, nameof(response)); + + // Requests that expect a boolean result just check for success or not-found result. + // GET requests that expect a single object result return null for not found result. + // GET requests that expect an array result should throw an error for not-found result + // because empty array was expected instead. + // PUT/POST/PATCH requests should also throw an error for not-found. + bool allowNotFound = typeof(T) == typeof(bool) || + ((method == HttpMethod.Get || method == HttpMethod.Head) && !typeof(T).IsArray && typeof(T) != typeof(TunnelPortListResponse) && typeof(T) != typeof(TunnelListByRegionResponse)); + + string? errorMessage = null; + Exception? innerException = null; + if (response.IsSuccessStatusCode) + { + if (response.StatusCode == HttpStatusCode.NoContent || response.Content == null) + { + return typeof(T) == typeof(bool) ? (T?)(object)(bool?)true : default; + } + + try + { + T? result = await response.Content.ReadFromJsonAsync( + JsonOptions, cancellation); + return result; + } + catch (Exception ex) + { + innerException = ex; + errorMessage = "Tunnel service response deserialization error: " + ex.Message; + } + } + + if (errorMessage == null && response.Content != null) + { + try + { + if ((int)response.StatusCode >= 400 && (int)response.StatusCode < 500) + { + // 4xx status responses may include standard ProblemDetails. + var problemDetails = await response.Content + .ReadFromJsonAsync(JsonOptions, cancellation); + if (!string.IsNullOrEmpty(problemDetails?.Title) || + !string.IsNullOrEmpty(problemDetails?.Detail)) + { + if (allowNotFound && response.StatusCode == HttpStatusCode.NotFound && + problemDetails.Detail == null) + { + return default; + } + + errorMessage = "Tunnel service error: " + + problemDetails!.Title + " " + problemDetails.Detail; + if (problemDetails.Errors != null) + { + foreach (var error in problemDetails.Errors) + { + var messages = string.Join(" ", error.Value); + errorMessage += $"\n{error.Key}: {messages}"; + } + } + } + } + else if ((int)response.StatusCode >= 500) + { + // 5xx status responses may include VS SaaS error details. + var errorDetails = await response.Content.ReadFromJsonAsync( + JsonOptions, cancellation); + if (!string.IsNullOrEmpty(errorDetails?.Message)) + { + errorMessage = "Tunnel service error: " + errorDetails!.Message; + if (!string.IsNullOrEmpty(errorDetails.StackTrace)) + { + errorMessage += "\n" + errorDetails.StackTrace; + } + } + } + } + catch (Exception ex) + { + // A default error message will be filled in below. + innerException = ex; + } + } + + errorMessage ??= "Tunnel service response status code: " + response.StatusCode; + + if (response.Headers.TryGetValues(RequestIdHeaderName, out var requestId)) + { + errorMessage += $"\nRequest ID: {requestId.First()}"; + } + + try + { + response.EnsureSuccessStatusCode(); + } + catch (HttpRequestException hrex) + { + switch (response.StatusCode) + { + case HttpStatusCode.BadRequest: + throw new ArgumentException(errorMessage, hrex); + + case HttpStatusCode.Unauthorized: + case HttpStatusCode.Forbidden: + // Enterprise Policies + if (response.Headers.Contains("X-Enterprise-Policy-Failure")) + { + var message = response.Content != null ? await response.Content.ReadAsStringAsync() : string.Empty; + errorMessage = message; + } + + var ex = new UnauthorizedAccessException(errorMessage, hrex); + + // The HttpResponseHeaders.WwwAuthenticate property does not correctly + // handle multiple values! Get the values by name instead. + if (response.Headers.TryGetValues( + "WWW-Authenticate", out var authHeaderValues)) + { + ex.SetAuthenticationSchemes(authHeaderValues); + } + + throw ex; + + case HttpStatusCode.NotFound: + case HttpStatusCode.Conflict: + case HttpStatusCode.PreconditionFailed: + case HttpStatusCode.TooManyRequests: + throw new InvalidOperationException(errorMessage, hrex); + + case HttpStatusCode.Redirect: + case HttpStatusCode.RedirectKeepVerb: + // Add the redirect location to the exception data. + // Normally the HTTP client should automatically follow redirects, + // but this allows tests to validate the service's redirection behavior + // when client auto redirection is disabled. + hrex.Data["Location"] = response.Headers.Location; + throw; + + default: throw; + } + } + + throw new Exception(errorMessage, innerException); + } + + /// + /// Error details that may be returned from the service with 500 status responses + /// (when in development mode). + /// + /// + /// Copied from Microsoft.VsSaaS.Common to avoid taking a dependency on that assembly. + /// + private class ErrorDetails + { + public string? Message { get; set; } + public string? StackTrace { get; set; } + } + + /// + public void Dispose() + { + this.httpClient.Dispose(); + } + + private Uri BuildUri( + string? clusterId, + string path, + string? query, + TunnelRequestOptions? options) + { + Requires.NotNullOrEmpty(path, nameof(path)); + + var baseAddress = this.httpClient.BaseAddress!; + var builder = new UriBuilder(baseAddress); + + if (!string.IsNullOrEmpty(clusterId) && + baseAddress.HostNameType == UriHostNameType.Dns) + { + if (baseAddress.Host != "localhost" && + !baseAddress.Host.StartsWith($"{clusterId}.")) + { + // A specific cluster ID was specified (while not running on localhost). + // Prepend the cluster ID to the hostname, and optionally strip a global prefix. + builder.Host = $"{clusterId}.{builder.Host}".Replace("global.", string.Empty); + } + else if (baseAddress.Scheme == "https" && + clusterId.StartsWith("localhost") && builder.Port % 10 > 0 && + ushort.TryParse(clusterId.Substring("localhost".Length), out var clusterNumber)) + { + // Local testing simulates clusters by running the service on multiple ports. + // Change the port number to match the cluster ID suffix. + if (clusterNumber > 0 && clusterNumber < 10) + { + builder.Port = builder.Port - (builder.Port % 10) + clusterNumber; + } + } + } + + if (options != null) + { + var optionsQuery = options.ToQueryString(); + if (!string.IsNullOrEmpty(optionsQuery)) + { + query = optionsQuery + + (!string.IsNullOrEmpty(query) ? '&' + query : string.Empty); + } + } + + builder.Path = path; + builder.Query = query; + return builder.Uri; + } + + private Uri BuildTunnelUri( + Tunnel tunnel, + string? path, + string? query, + TunnelRequestOptions? options, + bool isCreate = false) + { + Requires.NotNull(tunnel, nameof(tunnel)); + + string tunnelPath; + var pathBase = TunnelsPath; + if (!string.IsNullOrEmpty(tunnel.TunnelId) && (!string.IsNullOrEmpty(tunnel.ClusterId) || isCreate)) + { + tunnelPath = $"{pathBase}/{tunnel.TunnelId}"; + } + else + { + Requires.Argument( + !string.IsNullOrEmpty(tunnel.Name), + nameof(tunnel), + "Tunnel object must include either a name or tunnel ID and cluster ID."); + + if (string.IsNullOrEmpty(tunnel.Domain)) + { + + tunnelPath = $"{pathBase}/{tunnel.Name}"; + } + else + { + // Append the domain to the tunnel name. + tunnelPath = $"{pathBase}/{tunnel.Name}.{tunnel.Domain}"; + } + } + + return BuildUri( + tunnel.ClusterId, + tunnelPath + (!string.IsNullOrEmpty(path) ? path : string.Empty), + query, + options); + } + + private async Task GetAuthenticationHeaderAsync( + Tunnel? tunnel, + string[]? accessTokenScopes, + TunnelRequestOptions? options) + { + AuthenticationHeaderValue? authHeader = null; + + if (!string.IsNullOrEmpty(options?.AccessToken)) + { + authHeader = new AuthenticationHeaderValue( + TunnelAuthenticationScheme, options.AccessToken); + } + + if (authHeader == null) + { + authHeader = await this.userTokenCallback(); + } + + if (authHeader == null && tunnel?.AccessTokens != null && accessTokenScopes != null) + { + foreach (var scope in accessTokenScopes) + { + if (tunnel.TryGetAccessToken(scope, out string? accessToken)) + { + authHeader = new AuthenticationHeaderValue( + TunnelAuthenticationScheme, accessToken); + break; + } + } + } + + return authHeader; + } + + /// + public async Task ListTunnelsAsync( + string? clusterId, + string? domain, + TunnelRequestOptions? options, + bool? ownedTunnelsOnly, + CancellationToken cancellation) + { + var queryParams = new string?[] + { + string.IsNullOrEmpty(clusterId) ? "global=true" : null, + !string.IsNullOrEmpty(domain) ? $"domain={HttpUtility.UrlEncode(domain)}" : null, + !string.IsNullOrEmpty(ApiVersion) ? GetApiQuery() : null, + ownedTunnelsOnly == true ? "ownedTunnelsOnly=true" : null, + }; + var query = string.Join("&", queryParams.Where((p) => p != null)); + var result = await this.SendRequestAsync( + HttpMethod.Get, + clusterId, + TunnelsPath, + query, + options, + cancellation); + if (result?.Value != null) + { + return result.Value.Where(t => t.Value != null).SelectMany(t => t.Value!).ToArray(); + } + + return Array.Empty(); + } + + /// + [Obsolete("Use ListTunnelsAsync() method with TunnelRequestOptions.Labels instead.")] + public async Task SearchTunnelsAsync( + string[] labels, + bool requireAllLabels, + string? clusterId, + string? domain, + TunnelRequestOptions? options, + CancellationToken cancellation) + { + var queryParams = new string?[] + { + string.IsNullOrEmpty(clusterId) ? "global=true" : null, + !string.IsNullOrEmpty(domain) ? $"domain={HttpUtility.UrlEncode(domain)}" : null, + $"labels={string.Join(",", labels.Select(HttpUtility.UrlEncode))}", + $"allLabels={requireAllLabels}", + !string.IsNullOrEmpty(ApiVersion) ? GetApiQuery() : null, + }; + var query = string.Join("&", queryParams.Where((p) => p != null)); + var result = await this.SendRequestAsync( + HttpMethod.Get, + clusterId, + TunnelsPath, + query, + options, + cancellation); + return result!; + } + + /// + public async Task GetTunnelAsync( + Tunnel tunnel, + TunnelRequestOptions? options, + CancellationToken cancellation) + { + var result = await this.SendTunnelRequestAsync( + HttpMethod.Get, + tunnel, + ReadAccessTokenScopes, + path: null, + query: GetApiQuery(), + options, + cancellation); + PreserveAccessTokens(tunnel, result); + return result; + } + + /// + public async Task CreateTunnelAsync( + Tunnel tunnel, + TunnelRequestOptions? options, + CancellationToken cancellation) + { + Requires.NotNull(tunnel, nameof(tunnel)); + options ??= new TunnelRequestOptions(); + options.AdditionalHeaders ??= new List>(); + options.AdditionalHeaders = options.AdditionalHeaders.Append(new KeyValuePair("If-None-Match", "*")); + var tunnelId = tunnel.TunnelId; + var idGenerated = string.IsNullOrEmpty(tunnelId); + if (idGenerated) + { + tunnel.TunnelId = IdGeneration.GenerateTunnelId(); + } + for (int retries = 0; retries <= CreateNameRetries; retries++) + { + try + { + var result = await this.SendTunnelRequestAsync( + HttpMethod.Put, + tunnel, + ManageAccessTokenScope, + path: null, + query: GetApiQuery(), + options, + ConvertTunnelForRequest(tunnel), + cancellation, + true); + PreserveAccessTokens(tunnel, result); + return result!; + } + catch (UnauthorizedAccessException) when (idGenerated && retries < CreateNameRetries) // The tunnel ID was already taken. + { + tunnel.TunnelId = IdGeneration.GenerateTunnelId(); + } + } + + // This code is unreachable, but the compiler still requires it. + var result2 = await this.SendTunnelRequestAsync( + HttpMethod.Put, + tunnel, + ManageAccessTokenScope, + path: null, + query: GetApiQuery(), + options, + ConvertTunnelForRequest(tunnel), + cancellation, + true); + PreserveAccessTokens(tunnel, result2); + return result2!; + } + + /// + public async Task CreateOrUpdateTunnelAsync( + Tunnel tunnel, + TunnelRequestOptions? options, + CancellationToken cancellation) + { + Requires.NotNull(tunnel, nameof(tunnel)); + + var tunnelId = tunnel.TunnelId; + var idGenerated = string.IsNullOrEmpty(tunnelId); + if (idGenerated) + { + tunnel.TunnelId = IdGeneration.GenerateTunnelId(); + } + for (int retries = 0; retries <= CreateNameRetries; retries++) + { + try + { + var result = await this.SendTunnelRequestAsync( + HttpMethod.Put, + tunnel, + ManageAccessTokenScope, + path: null, + query: GetApiQuery(), + options, + ConvertTunnelForRequest(tunnel), + cancellation, + true); + PreserveAccessTokens(tunnel, result); + return result!; + } + catch (UnauthorizedAccessException) when (idGenerated && retries < 3) // The tunnel ID was already taken. + { + tunnel.TunnelId = IdGeneration.GenerateTunnelId(); + } + } + + // This code is unreachable, but the compiler still requires it. + var result2 = await this.SendTunnelRequestAsync( + HttpMethod.Put, + tunnel, + ManageAccessTokenScope, + path: null, + query: GetApiQuery(), + options, + ConvertTunnelForRequest(tunnel), + cancellation, + true); + PreserveAccessTokens(tunnel, result2); + return result2!; + } + + /// + public async Task UpdateTunnelAsync( + Tunnel tunnel, + TunnelRequestOptions? options, + CancellationToken cancellation) + { + options ??= new TunnelRequestOptions(); + options.AdditionalHeaders ??= new List>(); + options.AdditionalHeaders = options.AdditionalHeaders.Append(new KeyValuePair("If-Match", "*")); + var result = await this.SendTunnelRequestAsync( + HttpMethod.Put, + tunnel, + ManageAccessTokenScope, + path: null, + query: GetApiQuery(), + options, + ConvertTunnelForRequest(tunnel), + cancellation); + PreserveAccessTokens(tunnel, result); + return result!; + } + + /// + public async Task DeleteTunnelAsync( + Tunnel tunnel, + TunnelRequestOptions? options, + CancellationToken cancellation) + { + var result = await this.SendTunnelRequestAsync( + HttpMethod.Delete, + tunnel, + ManageAccessTokenScope, + path: null, + query: GetApiQuery(), + options, + cancellation); + return result; + } + + /// + public async Task UpdateTunnelEndpointAsync( + Tunnel tunnel, + TunnelEndpoint endpoint, + TunnelRequestOptions? options = null, + CancellationToken cancellation = default) + { + Requires.NotNull(endpoint, nameof(endpoint)); + Requires.NotNullOrEmpty(endpoint.HostId!, nameof(TunnelEndpoint.HostId)); + Requires.NotNullOrEmpty(endpoint.Id!, nameof(TunnelEndpoint.Id)); + + var path = $"{EndpointsApiSubPath}/{endpoint.Id}"; + var query = GetApiQuery(); + query += "&connectionMode=" + endpoint.ConnectionMode; + var result = (await this.SendTunnelRequestAsync( + HttpMethod.Put, + tunnel, + HostAccessTokenScope, + path, + query: query, + options, + endpoint, + cancellation))!; + + + if (tunnel.Endpoints != null) + { + // Also update the endpoint in the local tunnel object. + tunnel.Endpoints = tunnel.Endpoints + .Where((e) => e.HostId != endpoint.HostId || + e.ConnectionMode != endpoint.ConnectionMode) + .Append(result) + .ToArray(); + } + + return result; + } + + /// + public async Task DeleteTunnelEndpointsAsync( + Tunnel tunnel, + string id, + TunnelRequestOptions? options = null, + CancellationToken cancellation = default) + { + Requires.NotNullOrEmpty(id, nameof(id)); + + var path = $"{EndpointsApiSubPath}/{id}"; + var result = await this.SendTunnelRequestAsync( + HttpMethod.Delete, + tunnel, + HostAccessTokenScope, + path, + query: GetApiQuery(), + options, + cancellation); + + if (result && tunnel.Endpoints != null) + { + // Also delete the endpoint in the local tunnel object. + tunnel.Endpoints = tunnel.Endpoints + .Where((e) => e.Id != id) + .ToArray(); + } + + return result; + } + + /// + public async Task ListTunnelPortsAsync( + Tunnel tunnel, + TunnelRequestOptions? options, + CancellationToken cancellation) + { + var result = await this.SendTunnelRequestAsync( + HttpMethod.Get, + tunnel, + ReadAccessTokenScopes, + PortsApiSubPath, + query: GetApiQuery(), + options, + cancellation); + return result!.Value!; + } + + /// + public async Task GetTunnelPortAsync( + Tunnel tunnel, + ushort portNumber, + TunnelRequestOptions? options, + CancellationToken cancellation) + { + this.OnReportProgress(TunnelProgress.StartingGetTunnelPort); + var path = $"{PortsApiSubPath}/{portNumber}"; + var result = await this.SendTunnelRequestAsync( + HttpMethod.Get, + tunnel, + ReadAccessTokenScopes, + path, + query: GetApiQuery(), + options, + cancellation); + this.OnReportProgress(TunnelProgress.CompletedGetTunnelPort); + return result; + } + + /// + public async Task CreateTunnelPortAsync( + Tunnel tunnel, + TunnelPort tunnelPort, + TunnelRequestOptions? options, + CancellationToken cancellation) + { + Requires.NotNull(tunnelPort, nameof(tunnelPort)); + this.OnReportProgress(TunnelProgress.StartingCreateTunnelPort); + var path = $"{PortsApiSubPath}/{tunnelPort.PortNumber}"; + options ??= new TunnelRequestOptions(); + options.AdditionalHeaders ??= new List>(); + options.AdditionalHeaders = options.AdditionalHeaders.Append(new KeyValuePair("If-None-Match", "*")); + + var result = (await this.SendTunnelRequestAsync( + HttpMethod.Put, + tunnel, + ManagePortsAccessTokenScopes, + path, + query: GetApiQuery(), + options, + ConvertTunnelPortForRequest(tunnel, tunnelPort), + cancellation))!; + PreserveAccessTokens(tunnelPort, result); + + tunnel.Ports ??= new TunnelPort[0]; + + // Also add the port to the local tunnel object. + tunnel.Ports = tunnel.Ports + .Where((p) => p.PortNumber != tunnelPort.PortNumber) + .Append(result) + .OrderBy((p) => p.PortNumber) + .ToArray(); + this.OnReportProgress(TunnelProgress.CompletedCreateTunnelPort); + return result; + } + + /// + public async Task UpdateTunnelPortAsync( + Tunnel tunnel, + TunnelPort tunnelPort, + TunnelRequestOptions? options, + CancellationToken cancellation) + { + Requires.NotNull(tunnelPort, nameof(tunnelPort)); + options ??= new TunnelRequestOptions(); + options.AdditionalHeaders ??= new List>(); + options.AdditionalHeaders = options.AdditionalHeaders.Append(new KeyValuePair("If-Match", "*")); + + if (tunnelPort.ClusterId != null && tunnel.ClusterId != null && + tunnelPort.ClusterId != tunnel.ClusterId) + { + throw new ArgumentException( + "Tunnel port cluster ID is not consistent.", nameof(tunnelPort)); + } + + var portNumber = tunnelPort.PortNumber; + var path = $"{PortsApiSubPath}/{portNumber}"; + var result = (await this.SendTunnelRequestAsync( + HttpMethod.Put, + tunnel, + ManagePortsAccessTokenScopes, + path, + query: GetApiQuery(), + options, + ConvertTunnelPortForRequest(tunnel, tunnelPort), + cancellation))!; + PreserveAccessTokens(tunnelPort, result); + + tunnel.Ports ??= new TunnelPort[0]; + + // Also add the port to the local tunnel object. + tunnel.Ports = tunnel.Ports + .Where((p) => p.PortNumber != tunnelPort.PortNumber) + .Append(result) + .OrderBy((p) => p.PortNumber) + .ToArray(); + + + return result; + } + + /// + public async Task CreateOrUpdateTunnelPortAsync( + Tunnel tunnel, + TunnelPort tunnelPort, + TunnelRequestOptions? options, + CancellationToken cancellation) + { + Requires.NotNull(tunnelPort, nameof(tunnelPort)); + + if (tunnelPort.ClusterId != null && tunnel.ClusterId != null && + tunnelPort.ClusterId != tunnel.ClusterId) + { + throw new ArgumentException( + "Tunnel port cluster ID is not consistent.", nameof(tunnelPort)); + } + + var portNumber = tunnelPort.PortNumber; + var path = $"{PortsApiSubPath}/{portNumber}"; + var result = (await this.SendTunnelRequestAsync( + HttpMethod.Put, + tunnel, + ManagePortsAccessTokenScopes, + path, + query: GetApiQuery(), + options, + ConvertTunnelPortForRequest(tunnel, tunnelPort), + cancellation))!; + PreserveAccessTokens(tunnelPort, result); + + tunnel.Ports ??= new TunnelPort[0]; + + // Also add the port to the local tunnel object. + tunnel.Ports = tunnel.Ports + .Where((p) => p.PortNumber != tunnelPort.PortNumber) + .Append(result) + .OrderBy((p) => p.PortNumber) + .ToArray(); + + + return result; + } + + /// + public async Task DeleteTunnelPortAsync( + Tunnel tunnel, + ushort portNumber, + TunnelRequestOptions? options, + CancellationToken cancellation) + { + var path = $"{PortsApiSubPath}/{portNumber}"; + var result = await this.SendTunnelRequestAsync( + HttpMethod.Delete, + tunnel, + ManagePortsAccessTokenScopes, + path, + query: GetApiQuery(), + options, + cancellation); + + if (result && tunnel.Ports != null) + { + // Also delete the port in the local tunnel object. + tunnel.Ports = tunnel.Ports + .Where((p) => p.PortNumber != portNumber) + .OrderBy((p) => p.PortNumber) + .ToArray(); + } + + return result; + } + + /// + /// Event fired when a tunnel progress event has been reported. + /// + protected virtual void OnReportProgress(TunnelProgress progress) + { + if (ReportProgress is EventHandler handler) + { + var args = new TunnelReportProgressEventArgs(progress.ToString()); + handler.Invoke(this, args); + } + } + + /// + /// Removes read-only properties like tokens and status from create/update requests. + /// + private Tunnel ConvertTunnelForRequest(Tunnel tunnel) + { + return new Tunnel + { + TunnelId = tunnel.TunnelId, + Name = tunnel.Name, + Domain = tunnel.Domain, + Description = tunnel.Description, + Labels = tunnel.Labels, + CustomExpiration = tunnel.CustomExpiration, + Options = tunnel.Options, + AccessControl = tunnel.AccessControl == null ? null : new TunnelAccessControl( + tunnel.AccessControl.Where((ace) => !ace.IsInherited)), + Endpoints = tunnel.Endpoints, + Ports = tunnel.Ports? + .Select((p) => ConvertTunnelPortForRequest(tunnel, p)) + .ToArray(), + }; + } + + /// + /// Removes read-only properties like tokens and status from create/update requests. + /// + private TunnelPort ConvertTunnelPortForRequest(Tunnel tunnel, TunnelPort tunnelPort) + { + if (tunnelPort.ClusterId != null && tunnel.ClusterId != null && + tunnelPort.ClusterId != tunnel.ClusterId) + { + throw new ArgumentException( + "Tunnel port cluster ID does not match tunnel.", nameof(tunnelPort)); + } + + if (tunnelPort.TunnelId != null && tunnel.TunnelId != null && + tunnelPort.TunnelId != tunnel.TunnelId) + { + throw new ArgumentException( + "Tunnel port tunnel ID does not match tunnel.", nameof(tunnelPort)); + } + + return new TunnelPort + { + PortNumber = tunnelPort.PortNumber, + Protocol = tunnelPort.Protocol, + IsDefault = tunnelPort.IsDefault, + Description = tunnelPort.Description, + Labels = tunnelPort.Labels, + Options = tunnelPort.Options, + AccessControl = tunnelPort.AccessControl == null ? null : new TunnelAccessControl( + tunnelPort.AccessControl.Where((ace) => !ace.IsInherited)), + SshUser = tunnelPort.SshUser, + }; + } + + /// + public async Task FormatSubjectsAsync( + TunnelAccessSubject[] subjects, + TunnelRequestOptions? options = null, + CancellationToken cancellation = default) + { + Requires.NotNull(subjects, nameof(subjects)); + + if (subjects.Length == 0) + { + return subjects; + } + + var formattedSubjects = await SendRequestAsync + ( + HttpMethod.Post, + clusterId: null, + SubjectsPath + "/format", + query: GetApiQuery(), + options, + subjects, + cancellation); + return formattedSubjects!; + } + + /// + public async Task ResolveSubjectsAsync( + TunnelAccessSubject[] subjects, + TunnelRequestOptions? options = null, + CancellationToken cancellation = default) + { + Requires.NotNull(subjects, nameof(subjects)); + + if (subjects.Length == 0) + { + return subjects; + } + + var resolvedSubjects = await SendRequestAsync + ( + HttpMethod.Post, + clusterId: null, + SubjectsPath + "/resolve", + query: GetApiQuery(), + options, + subjects, + cancellation); + return resolvedSubjects!; + } + + /// + public async Task ListUserLimitsAsync(CancellationToken cancellation = default) + { + var userLimits = await SendRequestAsync( + HttpMethod.Get, + clusterId: null, + UserLimitsPath, + query: GetApiQuery(), + options: null, + cancellation); + return userLimits!; + } + + /// + public async Task ListClustersAsync(CancellationToken cancellation) { + var baseAddress = this.httpClient.BaseAddress!; + var builder = new UriBuilder(baseAddress); + builder.Path = ClustersPath; + builder.Query = GetApiQuery(); + var clusterDetails = await SendRequestAsync( + HttpMethod.Get, + builder.Uri, + options: null, + authHeader: null, + body: null, + cancellation); + return clusterDetails!; + } + + /// + public async Task CheckNameAvailabilityAsync( + string name, + CancellationToken cancellation = default) + { + name = Uri.EscapeDataString(name); + Requires.NotNull(name, nameof(name)); + return await this.SendRequestAsync( + HttpMethod.Get, + clusterId: null, + TunnelsPath + "/" + name + CheckAvailableSubPath, + query: GetApiQuery(), + options: null, + cancellation + ); + } + + /// + /// Gets required query string parmeters + /// + /// Query string + protected virtual string? GetApiQuery() + { + return string.IsNullOrEmpty(ApiVersion) ? null : $"api-version={ApiVersion}"; + } + + /// + /// Copy access tokens from the request object to the result object, except for any + /// tokens that were refreshed by the request. + /// + /// + /// This intentionally does not check whether any existing tokens are expired. So + /// expired tokens may be preserved also, if not refreshed. This allows for better + /// diagnostics in that case. + /// + private static void PreserveAccessTokens(Tunnel requestTunnel, Tunnel? resultTunnel) + { + if (requestTunnel.AccessTokens != null && resultTunnel != null) + { + resultTunnel.AccessTokens ??= new Dictionary(); + foreach (var scopeAndToken in requestTunnel.AccessTokens) + { + if (!resultTunnel.AccessTokens.ContainsKey(scopeAndToken.Key)) + { + resultTunnel.AccessTokens[scopeAndToken.Key] = scopeAndToken.Value; + } + } + } + } + + /// + /// Copy access tokens from the request object to the result object, except for any + /// tokens that were refreshed by the request. + /// + private static void PreserveAccessTokens(TunnelPort requestPort, TunnelPort? resultPort) + { + if (requestPort.AccessTokens != null && resultPort != null) + { + resultPort.AccessTokens ??= new Dictionary(); + foreach (var scopeAndToken in requestPort.AccessTokens) + { + if (!resultPort.AccessTokens.ContainsKey(scopeAndToken.Key)) + { + resultPort.AccessTokens[scopeAndToken.Key] = scopeAndToken.Value; + } + } + } + } + } +} From 48e09f87f1f110ae0af3c8af89156eae643b3bb8 Mon Sep 17 00:00:00 2001 From: Neelima Potharaj Date: Thu, 22 Feb 2024 10:38:41 -0800 Subject: [PATCH 05/12] added code changes --- cs/src/Management/TunnelManagementClient.cs | 24 ++++++++++++--------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/cs/src/Management/TunnelManagementClient.cs b/cs/src/Management/TunnelManagementClient.cs index 5ac87f7b..e67b3dc1 100644 --- a/cs/src/Management/TunnelManagementClient.cs +++ b/cs/src/Management/TunnelManagementClient.cs @@ -9,6 +9,8 @@ using System.Net; using System.Net.Http; using System.Net.Http.Headers; +using System.Text.Json; + #if NET5_0_OR_GREATER using System.Net.Http.Json; #endif @@ -680,12 +682,13 @@ private string UserLimitsPath throw new ArgumentException(errorMessage, hrex); case HttpStatusCode.Unauthorized: - case HttpStatusCode.Forbidden: - // Enterprise Policies - if (response.Headers.Contains("X-Enterprise-Policy-Failure")) - { - var message = response.Content != null ? await response.Content.ReadAsStringAsync() : string.Empty; - errorMessage = message; + case HttpStatusCode.Forbidden: + // Enterprise Policies + if (response.Headers.Contains("X-Enterprise-Policy-Failure")) + { + var message = response.Content != null ? await response.Content.ReadAsStringAsync() : string.Empty; + var errorDetails = JsonSerializer.Deserialize(message); + errorMessage = errorDetails?.detail; } var ex = new UnauthorizedAccessException(errorMessage, hrex); @@ -729,10 +732,11 @@ private string UserLimitsPath /// /// Copied from Microsoft.VsSaaS.Common to avoid taking a dependency on that assembly. /// - private class ErrorDetails - { - public string? Message { get; set; } - public string? StackTrace { get; set; } + private class ErrorDetails + { + public string? Message { get; set; } + public string? StackTrace { get; set; } + public string? detail { get; set; } } /// From 488b32d03459c253ce158f4d7b2cbfd877cc4e97 Mon Sep 17 00:00:00 2001 From: Neelima Potharaj Date: Thu, 22 Feb 2024 17:12:10 -0800 Subject: [PATCH 06/12] fixed json parsing to avoid exceptiosn; updated ErrorDetails --- cs/src/Management/TunnelManagementClient.cs | 28 +++++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/cs/src/Management/TunnelManagementClient.cs b/cs/src/Management/TunnelManagementClient.cs index e67b3dc1..c88adc86 100644 --- a/cs/src/Management/TunnelManagementClient.cs +++ b/cs/src/Management/TunnelManagementClient.cs @@ -10,6 +10,8 @@ using System.Net.Http; using System.Net.Http.Headers; using System.Text.Json; +using System.Text.Json.Serialization; + #if NET5_0_OR_GREATER using System.Net.Http.Json; @@ -686,11 +688,27 @@ private string UserLimitsPath // Enterprise Policies if (response.Headers.Contains("X-Enterprise-Policy-Failure")) { + var options = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }; var message = response.Content != null ? await response.Content.ReadAsStringAsync() : string.Empty; - var errorDetails = JsonSerializer.Deserialize(message); - errorMessage = errorDetails?.detail; - } - + + ErrorDetails? errorDetails = null; + try + { + errorDetails = JsonSerializer.Deserialize(message, options); + } + catch (JsonException) + { + // If deserialization fails, it means the message is not in JSON format. + // In this case, use the message directly as the error message. + } + + // Use the deserialized error detail if available, otherwise use the raw message. + errorMessage = errorDetails?.Detail ?? message; + } + var ex = new UnauthorizedAccessException(errorMessage, hrex); // The HttpResponseHeaders.WwwAuthenticate property does not correctly @@ -736,7 +754,7 @@ private class ErrorDetails { public string? Message { get; set; } public string? StackTrace { get; set; } - public string? detail { get; set; } + public string? Detail { get; set; } } /// From e8a785c5dcd66d751c00e8802319f9e62f219066 Mon Sep 17 00:00:00 2001 From: Neelima Potharaj Date: Thu, 22 Feb 2024 17:12:57 -0800 Subject: [PATCH 07/12] removed unused 'using' --- cs/src/Management/TunnelManagementClient.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/cs/src/Management/TunnelManagementClient.cs b/cs/src/Management/TunnelManagementClient.cs index c88adc86..5025bb1c 100644 --- a/cs/src/Management/TunnelManagementClient.cs +++ b/cs/src/Management/TunnelManagementClient.cs @@ -10,7 +10,6 @@ using System.Net.Http; using System.Net.Http.Headers; using System.Text.Json; -using System.Text.Json.Serialization; #if NET5_0_OR_GREATER From 804e0ea232a11333bee4dc88ec7a6fd82650248f Mon Sep 17 00:00:00 2001 From: Neelima Potharaj Date: Thu, 22 Feb 2024 17:15:02 -0800 Subject: [PATCH 08/12] removed extra lines --- cs/src/Management/TunnelManagementClient.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/cs/src/Management/TunnelManagementClient.cs b/cs/src/Management/TunnelManagementClient.cs index 5025bb1c..7f6eda91 100644 --- a/cs/src/Management/TunnelManagementClient.cs +++ b/cs/src/Management/TunnelManagementClient.cs @@ -10,8 +10,6 @@ using System.Net.Http; using System.Net.Http.Headers; using System.Text.Json; - - #if NET5_0_OR_GREATER using System.Net.Http.Json; #endif From 102860683eb5ff100332bee134ca4ac2ed1c6948 Mon Sep 17 00:00:00 2001 From: Neelima Potharaj Date: Mon, 26 Feb 2024 17:27:06 -0800 Subject: [PATCH 09/12] reset file --- cs/src/Management/TunnelManagementClient.cs | 23 ++------------------- 1 file changed, 2 insertions(+), 21 deletions(-) diff --git a/cs/src/Management/TunnelManagementClient.cs b/cs/src/Management/TunnelManagementClient.cs index 0a0811c7..c16a02de 100644 --- a/cs/src/Management/TunnelManagementClient.cs +++ b/cs/src/Management/TunnelManagementClient.cs @@ -9,7 +9,6 @@ using System.Net; using System.Net.Http; using System.Net.Http.Headers; -using System.Text.Json; using System.Security.Authentication; #if NET5_0_OR_GREATER @@ -695,25 +694,8 @@ private string UserLimitsPath // Enterprise Policies if (response.Headers.Contains("X-Enterprise-Policy-Failure")) { - var options = new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true - }; var message = response.Content != null ? await response.Content.ReadAsStringAsync() : string.Empty; - - ErrorDetails? errorDetails = null; - try - { - errorDetails = JsonSerializer.Deserialize(message, options); - } - catch (JsonException) - { - // If deserialization fails, it means the message is not in JSON format. - // In this case, use the message directly as the error message. - } - - // Use the deserialized error detail if available, otherwise use the raw message. - errorMessage = errorDetails?.Detail ?? message; + errorMessage = message; } var ex = new UnauthorizedAccessException(errorMessage, hrex); @@ -761,7 +743,6 @@ private class ErrorDetails { public string? Message { get; set; } public string? StackTrace { get; set; } - public string? Detail { get; set; } } /// @@ -1592,4 +1573,4 @@ private static void PreserveAccessTokens(TunnelPort requestPort, TunnelPort? res } } } -} +} \ No newline at end of file From 4431dd0460f490981431795216690c31faf4c388 Mon Sep 17 00:00:00 2001 From: Neelima Potharaj Date: Mon, 26 Feb 2024 17:34:38 -0800 Subject: [PATCH 10/12] merge conflicts - extra spaces --- cs/src/Management/TunnelManagementClient.cs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/cs/src/Management/TunnelManagementClient.cs b/cs/src/Management/TunnelManagementClient.cs index c16a02de..a48f490a 100644 --- a/cs/src/Management/TunnelManagementClient.cs +++ b/cs/src/Management/TunnelManagementClient.cs @@ -523,7 +523,7 @@ private string UserLimitsPath } var localMachineHeaders = TunnelUserAgent.GetMachineHeaders(); - if(localMachineHeaders != null) + if (localMachineHeaders != null) { request.Headers.UserAgent.Add(localMachineHeaders); } @@ -961,7 +961,7 @@ public async Task CreateTunnelAsync( { Requires.NotNull(tunnel, nameof(tunnel)); options ??= new TunnelRequestOptions(); - options.AdditionalHeaders ??= new List>(); + options.AdditionalHeaders ??= new List>(); options.AdditionalHeaders = options.AdditionalHeaders.Append(new KeyValuePair("If-None-Match", "*")); var tunnelId = tunnel.TunnelId; var idGenerated = string.IsNullOrEmpty(tunnelId); @@ -1007,7 +1007,7 @@ public async Task CreateTunnelAsync( return result2!; } - /// + /// public async Task CreateOrUpdateTunnelAsync( Tunnel tunnel, TunnelRequestOptions? options, @@ -1215,7 +1215,7 @@ public async Task CreateTunnelPortAsync( this.OnReportProgress(TunnelProgress.StartingCreateTunnelPort); var path = $"{PortsApiSubPath}/{tunnelPort.PortNumber}"; options ??= new TunnelRequestOptions(); - options.AdditionalHeaders ??= new List>(); + options.AdditionalHeaders ??= new List>(); options.AdditionalHeaders = options.AdditionalHeaders.Append(new KeyValuePair("If-None-Match", "*")); var result = (await this.SendTunnelRequestAsync( @@ -1250,7 +1250,7 @@ public async Task UpdateTunnelPortAsync( { Requires.NotNull(tunnelPort, nameof(tunnelPort)); options ??= new TunnelRequestOptions(); - options.AdditionalHeaders ??= new List>(); + options.AdditionalHeaders ??= new List>(); options.AdditionalHeaders = options.AdditionalHeaders.Append(new KeyValuePair("If-Match", "*")); if (tunnelPort.ClusterId != null && tunnel.ClusterId != null && @@ -1286,7 +1286,7 @@ public async Task UpdateTunnelPortAsync( return result; } - /// + /// public async Task CreateOrUpdateTunnelPortAsync( Tunnel tunnel, TunnelPort tunnelPort, @@ -1489,7 +1489,8 @@ public async Task ListUserLimitsAsync(CancellationToken cance } /// - public async Task ListClustersAsync(CancellationToken cancellation) { + public async Task ListClustersAsync(CancellationToken cancellation) + { var baseAddress = this.httpClient.BaseAddress!; var builder = new UriBuilder(baseAddress); builder.Path = ClustersPath; From 6378337aa5b003443f3c6c4b17a61bab255295f4 Mon Sep 17 00:00:00 2001 From: Neelima Potharaj Date: Mon, 26 Feb 2024 17:38:13 -0800 Subject: [PATCH 11/12] reset file to teh version from the main branch --- cs/src/Management/TunnelManagementClient.cs | 3123 +++++++++---------- 1 file changed, 1561 insertions(+), 1562 deletions(-) diff --git a/cs/src/Management/TunnelManagementClient.cs b/cs/src/Management/TunnelManagementClient.cs index a48f490a..38ce6644 100644 --- a/cs/src/Management/TunnelManagementClient.cs +++ b/cs/src/Management/TunnelManagementClient.cs @@ -1,556 +1,556 @@ -// -// Copyright (c) Microsoft. All rights reserved. -// Licensed under the MIT license. -// - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Net.Http.Headers; +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. +// + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; using System.Security.Authentication; -#if NET5_0_OR_GREATER -using System.Net.Http.Json; -#endif -using System.Threading; -using System.Threading.Tasks; -using System.Web; -using Microsoft.DevTunnels.Contracts; -using static Microsoft.DevTunnels.Contracts.TunnelContracts; - -namespace Microsoft.DevTunnels.Management -{ - /// - /// Implementation of a client that manages tunnels and tunnel ports via the tunnel service - /// management API. - /// - public class TunnelManagementClient : ITunnelManagementClient - { - private const string ApiV1Path = "/api/v1"; - private const string TunnelsV1ApiPath = ApiV1Path + "/tunnels"; - private const string SubjectsV1ApiPath = ApiV1Path + "/subjects"; - private const string UserLimitsV1ApiPath = ApiV1Path + "/userlimits"; - private const string TunnelsApiPath = "/tunnels"; - private const string SubjectsApiPath = "/subjects"; - private const string UserLimitsApiPath = "/userlimits"; - private const string EndpointsApiSubPath = "/endpoints"; - private const string PortsApiSubPath = "/ports"; - private const string ClustersApiPath = "/clusters"; - private const string ClustersV1ApiPath = ApiV1Path + "/clusters"; - private const string TunnelAuthenticationScheme = "Tunnel"; - private const string RequestIdHeaderName = "VsSaaS-Request-Id"; - private const string CheckAvailableSubPath = ":checkNameAvailability"; - private const int CreateNameRetries = 3; - - private static readonly string[] ManageAccessTokenScope = - new[] { TunnelAccessScopes.Manage }; - private static readonly string[] HostAccessTokenScope = - new[] { TunnelAccessScopes.Host }; - private static readonly string[] ManagePortsAccessTokenScopes = new[] - { - TunnelAccessScopes.Manage, - TunnelAccessScopes.ManagePorts, - TunnelAccessScopes.Host, - }; - private static readonly string[] ReadAccessTokenScopes = new[] - { - TunnelAccessScopes.Manage, - TunnelAccessScopes.ManagePorts, - TunnelAccessScopes.Host, - TunnelAccessScopes.Connect, - }; - - /// - /// Accepted management client api versions - /// - public string[] TunnelsApiVersions = - { - "2023-09-27-preview" - }; - - /// - /// Event raised to report tunnel management progress. - /// - public event EventHandler? ReportProgress; - - /// - /// ApiVersion that will be used if one is not specified - /// - public const ManagementApiVersions DefaultApiVersion = ManagementApiVersions.Version20230927Preview; - - private static readonly ProductInfoHeaderValue TunnelSdkUserAgent = - TunnelUserAgent.GetUserAgent(typeof(TunnelManagementClient).Assembly, "Dev-Tunnels-Service-CSharp-SDK")!; - - private readonly HttpClient httpClient; - private readonly Func> userTokenCallback; - - /// - /// Initializes a new instance of the class - /// with an optional client authentication callback. - /// - /// User agent. - /// Optional async callback for retrieving a client - /// authentication header, for AAD or GitHub user authentication. This may be null - /// for anonymous tunnel clients, or if tunnel access tokens will be specified via - /// . - /// Api version to use for tunnels requests, accepted - /// values are - public TunnelManagementClient( - ProductInfoHeaderValue userAgent, - Func>? userTokenCallback = null, - ManagementApiVersions apiVersion = DefaultApiVersion) - : this(new[] { userAgent }, userTokenCallback, tunnelServiceUri: null, httpHandler: null, apiVersion) - { - } - - /// - /// Initializes a new instance of the class - /// with an optional client authentication callback. - /// - /// User agent. Muiltiple user agents can be supplied in the - /// case that this SDK is used in a program, such as a CLI, that has users that want - /// to be differentiated. - /// Optional async callback for retrieving a client - /// authentication header, for AAD or GitHub user authentication. This may be null - /// for anonymous tunnel clients, or if tunnel access tokens will be specified via - /// . - /// Api version to use for tunnels requests, accepted - /// values are - public TunnelManagementClient( - ProductInfoHeaderValue[] userAgents, - Func>? userTokenCallback = null, - ManagementApiVersions apiVersion = DefaultApiVersion) - : this(userAgents, userTokenCallback, tunnelServiceUri: null, httpHandler: null, apiVersion) - { - } - - /// - /// Initializes a new instance of the class - /// with a client authentication callback, service URI, and HTTP handler. - /// - /// User agent. - /// Optional async callback for retrieving a client - /// authentication header value with access token, for AAD or GitHub user authentication. - /// This may be null for anonymous tunnel clients, or if tunnel access tokens will be - /// specified via . - /// Optional tunnel service URI (not including any path), - /// or null to use the default global service URI. - /// Optional HTTP handler or handler chain that will be invoked - /// for HTTPS requests to the tunnel service. The or - /// specified (or at the end of the chain) must have - /// automatic redirection disabled. The provided HTTP handler will not be disposed - /// by . - /// Api version to use for tunnels requests, accepted - /// values are - public TunnelManagementClient( - ProductInfoHeaderValue userAgent, - Func>? userTokenCallback = null, - Uri? tunnelServiceUri = null, - HttpMessageHandler? httpHandler = null, - ManagementApiVersions apiVersion = DefaultApiVersion) - : this(new[] { userAgent }, userTokenCallback, tunnelServiceUri, httpHandler, apiVersion) - { - } - - /// - /// Initializes a new instance of the class - /// with a client authentication callback, service URI, and HTTP handler. - /// - /// User agent. Muiltiple user agents can be supplied in the - /// case that this SDK is used in a program, such as a CLI, that has users that want - /// to be differentiated. - /// Optional async callback for retrieving a client - /// authentication header value with access token, for AAD or GitHub user authentication. - /// This may be null for anonymous tunnel clients, or if tunnel access tokens will be - /// specified via . - /// Optional tunnel service URI (not including any path), - /// or null to use the default global service URI. - /// Optional HTTP handler or handler chain that will be invoked - /// for HTTPS requests to the tunnel service. The or - /// specified (or at the end of the chain) must have - /// automatic redirection disabled. The provided HTTP handler will not be disposed - /// by . - /// Api version to use for tunnels requests, accepted - /// values are - public TunnelManagementClient( - ProductInfoHeaderValue[] userAgents, - Func>? userTokenCallback = null, - Uri? tunnelServiceUri = null, - HttpMessageHandler? httpHandler = null, - ManagementApiVersions apiVersionEnum = DefaultApiVersion) - { - Requires.NotNullEmptyOrNullElements(userAgents, nameof(userAgents)); - UserAgents = Requires.NotNull(userAgents, nameof(userAgents)); - var apiVersion = apiVersionEnum.ToVersionString(); - if (!string.IsNullOrEmpty(apiVersion) && !TunnelsApiVersions.Contains(apiVersion)) - { - throw new ArgumentException( - $"Invalid apiVersion, accpeted values are {string.Join(", ", TunnelsApiVersions)} "); - } - ApiVersion = apiVersion; - - this.userTokenCallback = userTokenCallback ?? - (() => Task.FromResult(null)); - - httpHandler ??= new SocketsHttpHandler - { - AllowAutoRedirect = false, - }; - ValidateHttpHandler(httpHandler); - - tunnelServiceUri ??= new Uri(TunnelServiceProperties.Production.ServiceUri); - if (!tunnelServiceUri.IsAbsoluteUri || tunnelServiceUri.PathAndQuery != "/") - { - throw new ArgumentException( - $"Invalid tunnel service URI: {tunnelServiceUri}", nameof(tunnelServiceUri)); - } - - // The `SocketsHttpHandler` or `HttpClientHandler` automatic redirection is disabled - // because they do not keep the Authorization header when redirecting. This handler - // will keep all headers when redirecting, and also supports switching the behavior - // per-request. - httpHandler = new FollowRedirectsHttpHandler(httpHandler); - - this.httpClient = new HttpClient(httpHandler, disposeHandler: false) - { - BaseAddress = tunnelServiceUri, - }; - } - - private static void ValidateHttpHandler(HttpMessageHandler httpHandler) - { - while (httpHandler is DelegatingHandler delegatingHandler) - { - httpHandler = delegatingHandler.InnerHandler!; - } - - if (httpHandler is SocketsHttpHandler socketsHandler) - { - if (socketsHandler.AllowAutoRedirect) - { - throw new ArgumentException( - "Tunnel client HTTP handler must have automatic redirection disabled.", - nameof(httpHandler)); - } - } - else if (httpHandler is HttpClientHandler httpClientHandler) - { - if (httpClientHandler.AllowAutoRedirect) - { - throw new ArgumentException( - "Tunnel client HTTP handler must have automatic redirection disabled.", - nameof(httpHandler)); - } - else if (httpClientHandler.UseDefaultCredentials) - { - throw new ArgumentException( - "Tunnel client HTTP handler must not use default credentials.", - nameof(httpHandler)); - } - } - else - { - throw new NotSupportedException( - $"Unsupported HTTP handler type: {httpHandler?.GetType().Name}. " + - "HTTP handler chain must consist of 0 or more DelegatingHandlers " + - "ending with a HttpClientHandler."); - } - } - - /// - /// Gets or sets additional headers that are added to every request. - /// - public IEnumerable>? AdditionalRequestHeaders { get; set; } - - private ProductInfoHeaderValue[] UserAgents { get; } - - private string? ApiVersion { get; } - - private string TunnelsPath - { - get { return string.IsNullOrEmpty(ApiVersion) ? TunnelsV1ApiPath : TunnelsApiPath; } - } - - private string ClustersPath - { - get { return string.IsNullOrEmpty(ApiVersion) ? ClustersV1ApiPath : ClustersApiPath; } - } - - private string SubjectsPath - { - get { return string.IsNullOrEmpty(ApiVersion) ? SubjectsV1ApiPath : SubjectsApiPath; } - } - - private string UserLimitsPath - { - get { return string.IsNullOrEmpty(ApiVersion) ? UserLimitsV1ApiPath : UserLimitsApiPath; } - } - - /// - /// Sends an HTTP request to the tunnel management API, targeting a specific tunnel. - /// - /// HTTP request method. - /// Tunnel that the request is targeting. - /// Required list of access scopes for tokens in - /// that could be used to - /// authorize the request. - /// Optional request sub-path relative to the tunnel. - /// Optional query string to append to the request. - /// Request options. - /// Cancellation token. - /// The expected result type. - /// Result of the request. - /// The request parameters were invalid. - /// The request was unauthorized or forbidden. - /// The WWW-Authenticate response header may be captured in the exception data. - /// The request would have caused a conflict - /// or exceeded a limit. - /// The request failed for some other - /// reason. - /// - /// This protected method enables subclasses to support additional tunnel management APIs. - /// Authentication will use one of the following, if available, in order of preference: - /// - on - /// - token provided by the user token callback - /// - token in that matches - /// one of the scopes in - /// - protected Task SendTunnelRequestAsync( - HttpMethod method, - Tunnel tunnel, - string[] accessTokenScopes, - string? path, - string? query, - TunnelRequestOptions? options, - CancellationToken cancellation) - { - return SendTunnelRequestAsync( - method, - tunnel, - accessTokenScopes, - path, - query, - options, - body: null, - cancellation); - } - - /// - /// Sends an HTTP request with body content to the tunnel management API, targeting a - /// specific tunnel. - /// - /// HTTP request method. - /// Tunnel that the request is targeting. - /// Required list of access scopes for tokens in - /// that could be used to - /// authorize the request. - /// Optional request sub-path relative to the tunnel. - /// Optional query string to append to the request. - /// Request options. - /// Request body object. - /// Cancellation token. - /// Whether the request is a create operation. - /// The request body type. - /// The expected result type. - /// Result of the request. - /// The request parameters were invalid. - /// The request was unauthorized or forbidden. - /// The WWW-Authenticate response header may be captured in the exception data. - /// The request would have caused a conflict - /// or exceeded a limit. - /// The request failed for some other - /// reason. - /// - /// This protected method enables subclasses to support additional tunnel management APIs. - /// Authentication will use one of the following, if available, in order of preference: - /// - on - /// - token provided by the user token callback - /// - token in that matches - /// one of the scopes in - /// - protected async Task SendTunnelRequestAsync( - HttpMethod method, - Tunnel tunnel, - string[] accessTokenScopes, - string? path, - string? query, - TunnelRequestOptions? options, - TRequest? body, - CancellationToken cancellation, - bool isCreate = false) - where TRequest : class - { - this.OnReportProgress(TunnelProgress.StartingRequestUri); - var uri = BuildTunnelUri(tunnel, path, query, options, isCreate); - this.OnReportProgress(TunnelProgress.StartingRequestConfig); - var authHeader = await GetAuthenticationHeaderAsync(tunnel, accessTokenScopes, options); - this.OnReportProgress(TunnelProgress.StartingSendTunnelRequest); - var result = await SendRequestAsync( - method, uri, options, authHeader, body, cancellation); - this.OnReportProgress(TunnelProgress.CompletedSendTunnelRequest); - return result; - } - - /// - /// Sends an HTTP request to the tunnel management API. - /// - /// HTTP request method. - /// Optional tunnel service cluster ID to direct the request to. - /// If unspecified, the request will use the global traffic-manager to find the nearest - /// cluster. - /// Required request path. - /// Optional query string to append to the request. - /// Request options. - /// Cancellation token. - /// The expected result type. - /// Result of the request. - /// The request parameters were invalid. - /// The request was unauthorized or forbidden. - /// The WWW-Authenticate response header may be captured in the exception data. - /// The request would have caused a conflict - /// or exceeded a limit. - /// The request failed for some other - /// reason. - /// - /// This protected method enables subclasses to support additional tunnel management APIs. - /// Authentication will use one of the following, if available, in order of preference: - /// - on - /// - token provided by the user token callback - /// - protected Task SendRequestAsync( - HttpMethod method, - string? clusterId, - string path, - string? query, - TunnelRequestOptions? options, - CancellationToken cancellation) - { - return SendRequestAsync( - method, - clusterId, - path, query, - options, - body: null, - cancellation); - } - - /// - /// Sends an HTTP request with body content to the tunnel management API. - /// - /// HTTP request method. - /// Optional tunnel service cluster ID to direct the request to. - /// If unspecified, the request will use the global traffic-manager to find the nearest - /// cluster. - /// Required request path. - /// Optional query string to append to the request. - /// Request options. - /// Request body object. - /// Cancellation token. - /// The request body type. - /// The expected result type. - /// Result of the request. - /// The request parameters were invalid. - /// The request was unauthorized or forbidden. - /// The WWW-Authenticate response header may be captured in the exception data. - /// The request would have caused a conflict - /// or exceeded a limit. - /// The request failed for some other - /// reason. - /// - /// This protected method enables subclasses to support additional tunnel management APIs. - /// Authentication will use one of the following, if available, in order of preference: - /// - on - /// - token provided by the user token callback - /// - protected async Task SendRequestAsync( - HttpMethod method, - string? clusterId, - string path, - string? query, - TunnelRequestOptions? options, - TRequest? body, - CancellationToken cancellation) - where TRequest : class - { - var uri = BuildUri(clusterId, path, query, options); - Tunnel? tunnel = null; - var authHeader = await GetAuthenticationHeaderAsync( - tunnel: tunnel, accessTokenScopes: null, options); - return await SendRequestAsync( - method, uri, options, authHeader, body, cancellation); - } - - /// - /// Sends an HTTP request with body content to the tunnel management API, with an - /// explicit authentication header value. - /// - private async Task SendRequestAsync( - HttpMethod method, - Uri uri, - TunnelRequestOptions? options, - AuthenticationHeaderValue? authHeader, - TRequest? body, - CancellationToken cancellation) - where TRequest : class - { - if (authHeader?.Scheme == TunnelAuthenticationSchemes.TunnelPlan) - { - var token = TunnelPlanTokenProperties.TryParse(authHeader.Parameter ?? string.Empty); - if (!string.IsNullOrEmpty(token?.ClusterId)) - { - var uriStr = uri.ToString().Replace("global.", $"{token.ClusterId}."); - uri = new Uri(uriStr); - } - } - - var request = new HttpRequestMessage(method, uri); - request.Headers.Authorization = authHeader; - - var emptyHeadersList = Enumerable.Empty>(); - var additionalHeaders = (AdditionalRequestHeaders ?? emptyHeadersList).Concat( - options?.AdditionalHeaders ?? emptyHeadersList); - - foreach (var headerNameAndValue in additionalHeaders) - { - request.Headers.Add(headerNameAndValue.Key, headerNameAndValue.Value); - } - - foreach (ProductInfoHeaderValue userAgent in UserAgents) - { - request.Headers.UserAgent.Add(userAgent); - } - - var localMachineHeaders = TunnelUserAgent.GetMachineHeaders(); - if (localMachineHeaders != null) - { - request.Headers.UserAgent.Add(localMachineHeaders); - } - - request.Headers.UserAgent.Add(TunnelSdkUserAgent); - - // Add Group Policies - const string policyRegKeyPath = @"Software\Policies\Microsoft\DevTunnels"; - var policyProvider = new PolicyProvider(policyRegKeyPath); - var policyHeaderValue = policyProvider.GetHeaderValue(); - if (!string.IsNullOrEmpty(policyHeaderValue)) - { - request.Headers.Add("User-Agent-Policies", policyHeaderValue); - } - - if (body != null) - { - request.Content = JsonContent.Create(body, null, JsonOptions); - } - - if (options?.FollowRedirects == false) - { - FollowRedirectsHttpHandler.SetFollowRedirectsEnabledForRequest(request, false); - } - - options?.SetRequestOptions(request); - +#if NET5_0_OR_GREATER +using System.Net.Http.Json; +#endif +using System.Threading; +using System.Threading.Tasks; +using System.Web; +using Microsoft.DevTunnels.Contracts; +using static Microsoft.DevTunnels.Contracts.TunnelContracts; + +namespace Microsoft.DevTunnels.Management +{ + /// + /// Implementation of a client that manages tunnels and tunnel ports via the tunnel service + /// management API. + /// + public class TunnelManagementClient : ITunnelManagementClient + { + private const string ApiV1Path = "/api/v1"; + private const string TunnelsV1ApiPath = ApiV1Path + "/tunnels"; + private const string SubjectsV1ApiPath = ApiV1Path + "/subjects"; + private const string UserLimitsV1ApiPath = ApiV1Path + "/userlimits"; + private const string TunnelsApiPath = "/tunnels"; + private const string SubjectsApiPath = "/subjects"; + private const string UserLimitsApiPath = "/userlimits"; + private const string EndpointsApiSubPath = "/endpoints"; + private const string PortsApiSubPath = "/ports"; + private const string ClustersApiPath = "/clusters"; + private const string ClustersV1ApiPath = ApiV1Path + "/clusters"; + private const string TunnelAuthenticationScheme = "Tunnel"; + private const string RequestIdHeaderName = "VsSaaS-Request-Id"; + private const string CheckAvailableSubPath = ":checkNameAvailability"; + private const int CreateNameRetries = 3; + + private static readonly string[] ManageAccessTokenScope = + new[] { TunnelAccessScopes.Manage }; + private static readonly string[] HostAccessTokenScope = + new[] { TunnelAccessScopes.Host }; + private static readonly string[] ManagePortsAccessTokenScopes = new[] + { + TunnelAccessScopes.Manage, + TunnelAccessScopes.ManagePorts, + TunnelAccessScopes.Host, + }; + private static readonly string[] ReadAccessTokenScopes = new[] + { + TunnelAccessScopes.Manage, + TunnelAccessScopes.ManagePorts, + TunnelAccessScopes.Host, + TunnelAccessScopes.Connect, + }; + + /// + /// Accepted management client api versions + /// + public string[] TunnelsApiVersions = + { + "2023-09-27-preview" + }; + + /// + /// Event raised to report tunnel management progress. + /// + public event EventHandler? ReportProgress; + + /// + /// ApiVersion that will be used if one is not specified + /// + public const ManagementApiVersions DefaultApiVersion = ManagementApiVersions.Version20230927Preview; + + private static readonly ProductInfoHeaderValue TunnelSdkUserAgent = + TunnelUserAgent.GetUserAgent(typeof(TunnelManagementClient).Assembly, "Dev-Tunnels-Service-CSharp-SDK")!; + + private readonly HttpClient httpClient; + private readonly Func> userTokenCallback; + + /// + /// Initializes a new instance of the class + /// with an optional client authentication callback. + /// + /// User agent. + /// Optional async callback for retrieving a client + /// authentication header, for AAD or GitHub user authentication. This may be null + /// for anonymous tunnel clients, or if tunnel access tokens will be specified via + /// . + /// Api version to use for tunnels requests, accepted + /// values are + public TunnelManagementClient( + ProductInfoHeaderValue userAgent, + Func>? userTokenCallback = null, + ManagementApiVersions apiVersion = DefaultApiVersion) + : this(new[] { userAgent }, userTokenCallback, tunnelServiceUri: null, httpHandler: null, apiVersion) + { + } + + /// + /// Initializes a new instance of the class + /// with an optional client authentication callback. + /// + /// User agent. Muiltiple user agents can be supplied in the + /// case that this SDK is used in a program, such as a CLI, that has users that want + /// to be differentiated. + /// Optional async callback for retrieving a client + /// authentication header, for AAD or GitHub user authentication. This may be null + /// for anonymous tunnel clients, or if tunnel access tokens will be specified via + /// . + /// Api version to use for tunnels requests, accepted + /// values are + public TunnelManagementClient( + ProductInfoHeaderValue[] userAgents, + Func>? userTokenCallback = null, + ManagementApiVersions apiVersion = DefaultApiVersion) + : this(userAgents, userTokenCallback, tunnelServiceUri: null, httpHandler: null, apiVersion) + { + } + + /// + /// Initializes a new instance of the class + /// with a client authentication callback, service URI, and HTTP handler. + /// + /// User agent. + /// Optional async callback for retrieving a client + /// authentication header value with access token, for AAD or GitHub user authentication. + /// This may be null for anonymous tunnel clients, or if tunnel access tokens will be + /// specified via . + /// Optional tunnel service URI (not including any path), + /// or null to use the default global service URI. + /// Optional HTTP handler or handler chain that will be invoked + /// for HTTPS requests to the tunnel service. The or + /// specified (or at the end of the chain) must have + /// automatic redirection disabled. The provided HTTP handler will not be disposed + /// by . + /// Api version to use for tunnels requests, accepted + /// values are + public TunnelManagementClient( + ProductInfoHeaderValue userAgent, + Func>? userTokenCallback = null, + Uri? tunnelServiceUri = null, + HttpMessageHandler? httpHandler = null, + ManagementApiVersions apiVersion = DefaultApiVersion) + : this(new[] { userAgent }, userTokenCallback, tunnelServiceUri, httpHandler, apiVersion) + { + } + + /// + /// Initializes a new instance of the class + /// with a client authentication callback, service URI, and HTTP handler. + /// + /// User agent. Muiltiple user agents can be supplied in the + /// case that this SDK is used in a program, such as a CLI, that has users that want + /// to be differentiated. + /// Optional async callback for retrieving a client + /// authentication header value with access token, for AAD or GitHub user authentication. + /// This may be null for anonymous tunnel clients, or if tunnel access tokens will be + /// specified via . + /// Optional tunnel service URI (not including any path), + /// or null to use the default global service URI. + /// Optional HTTP handler or handler chain that will be invoked + /// for HTTPS requests to the tunnel service. The or + /// specified (or at the end of the chain) must have + /// automatic redirection disabled. The provided HTTP handler will not be disposed + /// by . + /// Api version to use for tunnels requests, accepted + /// values are + public TunnelManagementClient( + ProductInfoHeaderValue[] userAgents, + Func>? userTokenCallback = null, + Uri? tunnelServiceUri = null, + HttpMessageHandler? httpHandler = null, + ManagementApiVersions apiVersionEnum = DefaultApiVersion) + { + Requires.NotNullEmptyOrNullElements(userAgents, nameof(userAgents)); + UserAgents = Requires.NotNull(userAgents, nameof(userAgents)); + var apiVersion = apiVersionEnum.ToVersionString(); + if (!string.IsNullOrEmpty(apiVersion) && !TunnelsApiVersions.Contains(apiVersion)) + { + throw new ArgumentException( + $"Invalid apiVersion, accpeted values are {string.Join(", ", TunnelsApiVersions)} "); + } + ApiVersion = apiVersion; + + this.userTokenCallback = userTokenCallback ?? + (() => Task.FromResult(null)); + + httpHandler ??= new SocketsHttpHandler + { + AllowAutoRedirect = false, + }; + ValidateHttpHandler(httpHandler); + + tunnelServiceUri ??= new Uri(TunnelServiceProperties.Production.ServiceUri); + if (!tunnelServiceUri.IsAbsoluteUri || tunnelServiceUri.PathAndQuery != "/") + { + throw new ArgumentException( + $"Invalid tunnel service URI: {tunnelServiceUri}", nameof(tunnelServiceUri)); + } + + // The `SocketsHttpHandler` or `HttpClientHandler` automatic redirection is disabled + // because they do not keep the Authorization header when redirecting. This handler + // will keep all headers when redirecting, and also supports switching the behavior + // per-request. + httpHandler = new FollowRedirectsHttpHandler(httpHandler); + + this.httpClient = new HttpClient(httpHandler, disposeHandler: false) + { + BaseAddress = tunnelServiceUri, + }; + } + + private static void ValidateHttpHandler(HttpMessageHandler httpHandler) + { + while (httpHandler is DelegatingHandler delegatingHandler) + { + httpHandler = delegatingHandler.InnerHandler!; + } + + if (httpHandler is SocketsHttpHandler socketsHandler) + { + if (socketsHandler.AllowAutoRedirect) + { + throw new ArgumentException( + "Tunnel client HTTP handler must have automatic redirection disabled.", + nameof(httpHandler)); + } + } + else if (httpHandler is HttpClientHandler httpClientHandler) + { + if (httpClientHandler.AllowAutoRedirect) + { + throw new ArgumentException( + "Tunnel client HTTP handler must have automatic redirection disabled.", + nameof(httpHandler)); + } + else if (httpClientHandler.UseDefaultCredentials) + { + throw new ArgumentException( + "Tunnel client HTTP handler must not use default credentials.", + nameof(httpHandler)); + } + } + else + { + throw new NotSupportedException( + $"Unsupported HTTP handler type: {httpHandler?.GetType().Name}. " + + "HTTP handler chain must consist of 0 or more DelegatingHandlers " + + "ending with a HttpClientHandler."); + } + } + + /// + /// Gets or sets additional headers that are added to every request. + /// + public IEnumerable>? AdditionalRequestHeaders { get; set; } + + private ProductInfoHeaderValue[] UserAgents { get; } + + private string? ApiVersion { get; } + + private string TunnelsPath + { + get { return string.IsNullOrEmpty(ApiVersion) ? TunnelsV1ApiPath : TunnelsApiPath; } + } + + private string ClustersPath + { + get { return string.IsNullOrEmpty(ApiVersion) ? ClustersV1ApiPath : ClustersApiPath; } + } + + private string SubjectsPath + { + get { return string.IsNullOrEmpty(ApiVersion) ? SubjectsV1ApiPath : SubjectsApiPath; } + } + + private string UserLimitsPath + { + get { return string.IsNullOrEmpty(ApiVersion) ? UserLimitsV1ApiPath : UserLimitsApiPath; } + } + + /// + /// Sends an HTTP request to the tunnel management API, targeting a specific tunnel. + /// + /// HTTP request method. + /// Tunnel that the request is targeting. + /// Required list of access scopes for tokens in + /// that could be used to + /// authorize the request. + /// Optional request sub-path relative to the tunnel. + /// Optional query string to append to the request. + /// Request options. + /// Cancellation token. + /// The expected result type. + /// Result of the request. + /// The request parameters were invalid. + /// The request was unauthorized or forbidden. + /// The WWW-Authenticate response header may be captured in the exception data. + /// The request would have caused a conflict + /// or exceeded a limit. + /// The request failed for some other + /// reason. + /// + /// This protected method enables subclasses to support additional tunnel management APIs. + /// Authentication will use one of the following, if available, in order of preference: + /// - on + /// - token provided by the user token callback + /// - token in that matches + /// one of the scopes in + /// + protected Task SendTunnelRequestAsync( + HttpMethod method, + Tunnel tunnel, + string[] accessTokenScopes, + string? path, + string? query, + TunnelRequestOptions? options, + CancellationToken cancellation) + { + return SendTunnelRequestAsync( + method, + tunnel, + accessTokenScopes, + path, + query, + options, + body: null, + cancellation); + } + + /// + /// Sends an HTTP request with body content to the tunnel management API, targeting a + /// specific tunnel. + /// + /// HTTP request method. + /// Tunnel that the request is targeting. + /// Required list of access scopes for tokens in + /// that could be used to + /// authorize the request. + /// Optional request sub-path relative to the tunnel. + /// Optional query string to append to the request. + /// Request options. + /// Request body object. + /// Cancellation token. + /// Whether the request is a create operation. + /// The request body type. + /// The expected result type. + /// Result of the request. + /// The request parameters were invalid. + /// The request was unauthorized or forbidden. + /// The WWW-Authenticate response header may be captured in the exception data. + /// The request would have caused a conflict + /// or exceeded a limit. + /// The request failed for some other + /// reason. + /// + /// This protected method enables subclasses to support additional tunnel management APIs. + /// Authentication will use one of the following, if available, in order of preference: + /// - on + /// - token provided by the user token callback + /// - token in that matches + /// one of the scopes in + /// + protected async Task SendTunnelRequestAsync( + HttpMethod method, + Tunnel tunnel, + string[] accessTokenScopes, + string? path, + string? query, + TunnelRequestOptions? options, + TRequest? body, + CancellationToken cancellation, + bool isCreate = false) + where TRequest : class + { + this.OnReportProgress(TunnelProgress.StartingRequestUri); + var uri = BuildTunnelUri(tunnel, path, query, options, isCreate); + this.OnReportProgress(TunnelProgress.StartingRequestConfig); + var authHeader = await GetAuthenticationHeaderAsync(tunnel, accessTokenScopes, options); + this.OnReportProgress(TunnelProgress.StartingSendTunnelRequest); + var result = await SendRequestAsync( + method, uri, options, authHeader, body, cancellation); + this.OnReportProgress(TunnelProgress.CompletedSendTunnelRequest); + return result; + } + + /// + /// Sends an HTTP request to the tunnel management API. + /// + /// HTTP request method. + /// Optional tunnel service cluster ID to direct the request to. + /// If unspecified, the request will use the global traffic-manager to find the nearest + /// cluster. + /// Required request path. + /// Optional query string to append to the request. + /// Request options. + /// Cancellation token. + /// The expected result type. + /// Result of the request. + /// The request parameters were invalid. + /// The request was unauthorized or forbidden. + /// The WWW-Authenticate response header may be captured in the exception data. + /// The request would have caused a conflict + /// or exceeded a limit. + /// The request failed for some other + /// reason. + /// + /// This protected method enables subclasses to support additional tunnel management APIs. + /// Authentication will use one of the following, if available, in order of preference: + /// - on + /// - token provided by the user token callback + /// + protected Task SendRequestAsync( + HttpMethod method, + string? clusterId, + string path, + string? query, + TunnelRequestOptions? options, + CancellationToken cancellation) + { + return SendRequestAsync( + method, + clusterId, + path, query, + options, + body: null, + cancellation); + } + + /// + /// Sends an HTTP request with body content to the tunnel management API. + /// + /// HTTP request method. + /// Optional tunnel service cluster ID to direct the request to. + /// If unspecified, the request will use the global traffic-manager to find the nearest + /// cluster. + /// Required request path. + /// Optional query string to append to the request. + /// Request options. + /// Request body object. + /// Cancellation token. + /// The request body type. + /// The expected result type. + /// Result of the request. + /// The request parameters were invalid. + /// The request was unauthorized or forbidden. + /// The WWW-Authenticate response header may be captured in the exception data. + /// The request would have caused a conflict + /// or exceeded a limit. + /// The request failed for some other + /// reason. + /// + /// This protected method enables subclasses to support additional tunnel management APIs. + /// Authentication will use one of the following, if available, in order of preference: + /// - on + /// - token provided by the user token callback + /// + protected async Task SendRequestAsync( + HttpMethod method, + string? clusterId, + string path, + string? query, + TunnelRequestOptions? options, + TRequest? body, + CancellationToken cancellation) + where TRequest : class + { + var uri = BuildUri(clusterId, path, query, options); + Tunnel? tunnel = null; + var authHeader = await GetAuthenticationHeaderAsync( + tunnel: tunnel, accessTokenScopes: null, options); + return await SendRequestAsync( + method, uri, options, authHeader, body, cancellation); + } + + /// + /// Sends an HTTP request with body content to the tunnel management API, with an + /// explicit authentication header value. + /// + private async Task SendRequestAsync( + HttpMethod method, + Uri uri, + TunnelRequestOptions? options, + AuthenticationHeaderValue? authHeader, + TRequest? body, + CancellationToken cancellation) + where TRequest : class + { + if (authHeader?.Scheme == TunnelAuthenticationSchemes.TunnelPlan) + { + var token = TunnelPlanTokenProperties.TryParse(authHeader.Parameter ?? string.Empty); + if (!string.IsNullOrEmpty(token?.ClusterId)) + { + var uriStr = uri.ToString().Replace("global.", $"{token.ClusterId}."); + uri = new Uri(uriStr); + } + } + + var request = new HttpRequestMessage(method, uri); + request.Headers.Authorization = authHeader; + + var emptyHeadersList = Enumerable.Empty>(); + var additionalHeaders = (AdditionalRequestHeaders ?? emptyHeadersList).Concat( + options?.AdditionalHeaders ?? emptyHeadersList); + + foreach (var headerNameAndValue in additionalHeaders) + { + request.Headers.Add(headerNameAndValue.Key, headerNameAndValue.Value); + } + + foreach (ProductInfoHeaderValue userAgent in UserAgents) + { + request.Headers.UserAgent.Add(userAgent); + } + + var localMachineHeaders = TunnelUserAgent.GetMachineHeaders(); + if(localMachineHeaders != null) + { + request.Headers.UserAgent.Add(localMachineHeaders); + } + + request.Headers.UserAgent.Add(TunnelSdkUserAgent); + + // Add Group Policies + const string policyRegKeyPath = @"Software\Policies\Microsoft\DevTunnels"; + var policyProvider = new PolicyProvider(policyRegKeyPath); + var policyHeaderValue = policyProvider.GetHeaderValue(); + if (!string.IsNullOrEmpty(policyHeaderValue)) + { + request.Headers.Add("User-Agent-Policies", policyHeaderValue); + } + + if (body != null) + { + request.Content = JsonContent.Create(body, null, JsonOptions); + } + + if (options?.FollowRedirects == false) + { + FollowRedirectsHttpHandler.SetFollowRedirectsEnabledForRequest(request, false); + } + + options?.SetRequestOptions(request); + try { var response = await this.httpClient.SendAsync(request, cancellation); @@ -564,1014 +564,1013 @@ private string UserLimitsPath { throw new HttpRequestException($"Error: Tunnel service HTTPS certificate is invalid. This may" + $" be caused by the use of a firewall intercepting the connection.", auex); - } - } - - /// - /// Converts a tunnel service HTTP response to a result object (or exception). - /// - /// Type of result expected, or bool to just check for either success or - /// not-found. - /// Request method. - /// Response from a tunnel service request. - /// Cancellation token. - /// Result object of the requested type, or false if the response was 404 and - /// the result type is boolean, or null if a GET request for a non-array result object type - /// returned 404 Not Found. - /// The service returned a - /// 400 Bad Request response. - /// The service returned a 401 Unauthorized - /// or 403 Forbidden response. - private static async Task ConvertResponseAsync( - HttpMethod method, - HttpResponseMessage response, - CancellationToken cancellation) - { - Requires.NotNull(response, nameof(response)); - - // Requests that expect a boolean result just check for success or not-found result. - // GET requests that expect a single object result return null for not found result. - // GET requests that expect an array result should throw an error for not-found result - // because empty array was expected instead. - // PUT/POST/PATCH requests should also throw an error for not-found. - bool allowNotFound = typeof(T) == typeof(bool) || - ((method == HttpMethod.Get || method == HttpMethod.Head) && !typeof(T).IsArray && typeof(T) != typeof(TunnelPortListResponse) && typeof(T) != typeof(TunnelListByRegionResponse)); - - string? errorMessage = null; - Exception? innerException = null; - if (response.IsSuccessStatusCode) - { - if (response.StatusCode == HttpStatusCode.NoContent || response.Content == null) - { - return typeof(T) == typeof(bool) ? (T?)(object)(bool?)true : default; - } - - try - { - T? result = await response.Content.ReadFromJsonAsync( - JsonOptions, cancellation); - return result; - } - catch (Exception ex) - { - innerException = ex; - errorMessage = "Tunnel service response deserialization error: " + ex.Message; - } - } - - if (errorMessage == null && response.Content != null) - { - try - { - if ((int)response.StatusCode >= 400 && (int)response.StatusCode < 500) - { - // 4xx status responses may include standard ProblemDetails. - var problemDetails = await response.Content - .ReadFromJsonAsync(JsonOptions, cancellation); - if (!string.IsNullOrEmpty(problemDetails?.Title) || - !string.IsNullOrEmpty(problemDetails?.Detail)) - { - if (allowNotFound && response.StatusCode == HttpStatusCode.NotFound && - problemDetails.Detail == null) - { - return default; - } - - errorMessage = "Tunnel service error: " + - problemDetails!.Title + " " + problemDetails.Detail; - if (problemDetails.Errors != null) - { - foreach (var error in problemDetails.Errors) - { - var messages = string.Join(" ", error.Value); - errorMessage += $"\n{error.Key}: {messages}"; - } - } - } - } - else if ((int)response.StatusCode >= 500) - { - // 5xx status responses may include VS SaaS error details. - var errorDetails = await response.Content.ReadFromJsonAsync( - JsonOptions, cancellation); - if (!string.IsNullOrEmpty(errorDetails?.Message)) - { - errorMessage = "Tunnel service error: " + errorDetails!.Message; - if (!string.IsNullOrEmpty(errorDetails.StackTrace)) - { - errorMessage += "\n" + errorDetails.StackTrace; - } - } - } - } - catch (Exception ex) - { - // A default error message will be filled in below. - innerException = ex; - } - } - - errorMessage ??= "Tunnel service response status code: " + response.StatusCode; - - if (response.Headers.TryGetValues(RequestIdHeaderName, out var requestId)) - { - errorMessage += $"\nRequest ID: {requestId.First()}"; - } - - try - { - response.EnsureSuccessStatusCode(); - } - catch (HttpRequestException hrex) - { - switch (response.StatusCode) - { - case HttpStatusCode.BadRequest: - throw new ArgumentException(errorMessage, hrex); - - case HttpStatusCode.Unauthorized: - case HttpStatusCode.Forbidden: - // Enterprise Policies - if (response.Headers.Contains("X-Enterprise-Policy-Failure")) - { - var message = response.Content != null ? await response.Content.ReadAsStringAsync() : string.Empty; - errorMessage = message; - } - - var ex = new UnauthorizedAccessException(errorMessage, hrex); - - // The HttpResponseHeaders.WwwAuthenticate property does not correctly - // handle multiple values! Get the values by name instead. - if (response.Headers.TryGetValues( - "WWW-Authenticate", out var authHeaderValues)) - { - ex.SetAuthenticationSchemes(authHeaderValues); - } - - throw ex; - - case HttpStatusCode.NotFound: - case HttpStatusCode.Conflict: - case HttpStatusCode.PreconditionFailed: - case HttpStatusCode.TooManyRequests: - throw new InvalidOperationException(errorMessage, hrex); - - case HttpStatusCode.Redirect: - case HttpStatusCode.RedirectKeepVerb: - // Add the redirect location to the exception data. - // Normally the HTTP client should automatically follow redirects, - // but this allows tests to validate the service's redirection behavior - // when client auto redirection is disabled. - hrex.Data["Location"] = response.Headers.Location; - throw; - - default: throw; - } - } - - throw new Exception(errorMessage, innerException); - } - - /// - /// Error details that may be returned from the service with 500 status responses - /// (when in development mode). - /// - /// - /// Copied from Microsoft.VsSaaS.Common to avoid taking a dependency on that assembly. - /// - private class ErrorDetails - { - public string? Message { get; set; } - public string? StackTrace { get; set; } - } - - /// - public void Dispose() - { - this.httpClient.Dispose(); - } - - private Uri BuildUri( - string? clusterId, - string path, - string? query, - TunnelRequestOptions? options) - { - Requires.NotNullOrEmpty(path, nameof(path)); - - var baseAddress = this.httpClient.BaseAddress!; - var builder = new UriBuilder(baseAddress); - - if (!string.IsNullOrEmpty(clusterId) && - baseAddress.HostNameType == UriHostNameType.Dns) - { - if (baseAddress.Host != "localhost" && - !baseAddress.Host.StartsWith($"{clusterId}.")) - { - // A specific cluster ID was specified (while not running on localhost). - // Prepend the cluster ID to the hostname, and optionally strip a global prefix. - builder.Host = $"{clusterId}.{builder.Host}".Replace("global.", string.Empty); - } - else if (baseAddress.Scheme == "https" && - clusterId.StartsWith("localhost") && builder.Port % 10 > 0 && - ushort.TryParse(clusterId.Substring("localhost".Length), out var clusterNumber)) - { - // Local testing simulates clusters by running the service on multiple ports. - // Change the port number to match the cluster ID suffix. - if (clusterNumber > 0 && clusterNumber < 10) - { - builder.Port = builder.Port - (builder.Port % 10) + clusterNumber; - } - } - } - - if (options != null) - { - var optionsQuery = options.ToQueryString(); - if (!string.IsNullOrEmpty(optionsQuery)) - { - query = optionsQuery + - (!string.IsNullOrEmpty(query) ? '&' + query : string.Empty); - } - } - - builder.Path = path; - builder.Query = query; - return builder.Uri; - } - - private Uri BuildTunnelUri( - Tunnel tunnel, - string? path, - string? query, - TunnelRequestOptions? options, - bool isCreate = false) - { - Requires.NotNull(tunnel, nameof(tunnel)); - - string tunnelPath; - var pathBase = TunnelsPath; - if (!string.IsNullOrEmpty(tunnel.TunnelId) && (!string.IsNullOrEmpty(tunnel.ClusterId) || isCreate)) - { - tunnelPath = $"{pathBase}/{tunnel.TunnelId}"; - } - else - { - Requires.Argument( - !string.IsNullOrEmpty(tunnel.Name), - nameof(tunnel), - "Tunnel object must include either a name or tunnel ID and cluster ID."); - - if (string.IsNullOrEmpty(tunnel.Domain)) - { - - tunnelPath = $"{pathBase}/{tunnel.Name}"; - } - else - { - // Append the domain to the tunnel name. - tunnelPath = $"{pathBase}/{tunnel.Name}.{tunnel.Domain}"; - } - } - - return BuildUri( - tunnel.ClusterId, - tunnelPath + (!string.IsNullOrEmpty(path) ? path : string.Empty), - query, - options); - } - - private async Task GetAuthenticationHeaderAsync( - Tunnel? tunnel, - string[]? accessTokenScopes, - TunnelRequestOptions? options) - { - AuthenticationHeaderValue? authHeader = null; - - if (!string.IsNullOrEmpty(options?.AccessToken)) - { - authHeader = new AuthenticationHeaderValue( - TunnelAuthenticationScheme, options.AccessToken); - } - - if (authHeader == null) - { - authHeader = await this.userTokenCallback(); - } - - if (authHeader == null && tunnel?.AccessTokens != null && accessTokenScopes != null) - { - foreach (var scope in accessTokenScopes) - { - if (tunnel.TryGetAccessToken(scope, out string? accessToken)) - { - authHeader = new AuthenticationHeaderValue( - TunnelAuthenticationScheme, accessToken); - break; - } - } - } - - return authHeader; - } - - /// - public async Task ListTunnelsAsync( - string? clusterId, - string? domain, - TunnelRequestOptions? options, - bool? ownedTunnelsOnly, - CancellationToken cancellation) - { - var queryParams = new string?[] - { - string.IsNullOrEmpty(clusterId) ? "global=true" : null, - !string.IsNullOrEmpty(domain) ? $"domain={HttpUtility.UrlEncode(domain)}" : null, - !string.IsNullOrEmpty(ApiVersion) ? GetApiQuery() : null, - ownedTunnelsOnly == true ? "ownedTunnelsOnly=true" : null, - }; - var query = string.Join("&", queryParams.Where((p) => p != null)); - var result = await this.SendRequestAsync( - HttpMethod.Get, - clusterId, - TunnelsPath, - query, - options, - cancellation); - if (result?.Value != null) - { - return result.Value.Where(t => t.Value != null).SelectMany(t => t.Value!).ToArray(); - } - - return Array.Empty(); - } - - /// - [Obsolete("Use ListTunnelsAsync() method with TunnelRequestOptions.Labels instead.")] - public async Task SearchTunnelsAsync( - string[] labels, - bool requireAllLabels, - string? clusterId, - string? domain, - TunnelRequestOptions? options, - CancellationToken cancellation) - { - var queryParams = new string?[] - { - string.IsNullOrEmpty(clusterId) ? "global=true" : null, - !string.IsNullOrEmpty(domain) ? $"domain={HttpUtility.UrlEncode(domain)}" : null, - $"labels={string.Join(",", labels.Select(HttpUtility.UrlEncode))}", - $"allLabels={requireAllLabels}", - !string.IsNullOrEmpty(ApiVersion) ? GetApiQuery() : null, - }; - var query = string.Join("&", queryParams.Where((p) => p != null)); - var result = await this.SendRequestAsync( - HttpMethod.Get, - clusterId, - TunnelsPath, - query, - options, - cancellation); - return result!; - } - - /// - public async Task GetTunnelAsync( - Tunnel tunnel, - TunnelRequestOptions? options, - CancellationToken cancellation) - { - var result = await this.SendTunnelRequestAsync( - HttpMethod.Get, - tunnel, - ReadAccessTokenScopes, - path: null, - query: GetApiQuery(), - options, - cancellation); - PreserveAccessTokens(tunnel, result); - return result; - } - - /// - public async Task CreateTunnelAsync( - Tunnel tunnel, - TunnelRequestOptions? options, - CancellationToken cancellation) - { - Requires.NotNull(tunnel, nameof(tunnel)); - options ??= new TunnelRequestOptions(); - options.AdditionalHeaders ??= new List>(); - options.AdditionalHeaders = options.AdditionalHeaders.Append(new KeyValuePair("If-None-Match", "*")); - var tunnelId = tunnel.TunnelId; - var idGenerated = string.IsNullOrEmpty(tunnelId); - if (idGenerated) - { - tunnel.TunnelId = IdGeneration.GenerateTunnelId(); - } - for (int retries = 0; retries <= CreateNameRetries; retries++) - { - try - { - var result = await this.SendTunnelRequestAsync( - HttpMethod.Put, - tunnel, - ManageAccessTokenScope, - path: null, - query: GetApiQuery(), - options, - ConvertTunnelForRequest(tunnel), - cancellation, - true); - PreserveAccessTokens(tunnel, result); - return result!; - } - catch (UnauthorizedAccessException) when (idGenerated && retries < CreateNameRetries) // The tunnel ID was already taken. - { - tunnel.TunnelId = IdGeneration.GenerateTunnelId(); - } - } - - // This code is unreachable, but the compiler still requires it. - var result2 = await this.SendTunnelRequestAsync( - HttpMethod.Put, - tunnel, - ManageAccessTokenScope, - path: null, - query: GetApiQuery(), - options, - ConvertTunnelForRequest(tunnel), - cancellation, - true); - PreserveAccessTokens(tunnel, result2); - return result2!; - } - - /// - public async Task CreateOrUpdateTunnelAsync( - Tunnel tunnel, - TunnelRequestOptions? options, - CancellationToken cancellation) - { - Requires.NotNull(tunnel, nameof(tunnel)); - - var tunnelId = tunnel.TunnelId; - var idGenerated = string.IsNullOrEmpty(tunnelId); - if (idGenerated) - { - tunnel.TunnelId = IdGeneration.GenerateTunnelId(); - } - for (int retries = 0; retries <= CreateNameRetries; retries++) - { - try - { - var result = await this.SendTunnelRequestAsync( - HttpMethod.Put, - tunnel, - ManageAccessTokenScope, - path: null, - query: GetApiQuery(), - options, - ConvertTunnelForRequest(tunnel), - cancellation, - true); - PreserveAccessTokens(tunnel, result); - return result!; - } - catch (UnauthorizedAccessException) when (idGenerated && retries < 3) // The tunnel ID was already taken. - { - tunnel.TunnelId = IdGeneration.GenerateTunnelId(); - } - } - - // This code is unreachable, but the compiler still requires it. - var result2 = await this.SendTunnelRequestAsync( - HttpMethod.Put, - tunnel, - ManageAccessTokenScope, - path: null, - query: GetApiQuery(), - options, - ConvertTunnelForRequest(tunnel), - cancellation, - true); - PreserveAccessTokens(tunnel, result2); - return result2!; - } - - /// - public async Task UpdateTunnelAsync( - Tunnel tunnel, - TunnelRequestOptions? options, - CancellationToken cancellation) - { - options ??= new TunnelRequestOptions(); - options.AdditionalHeaders ??= new List>(); - options.AdditionalHeaders = options.AdditionalHeaders.Append(new KeyValuePair("If-Match", "*")); - var result = await this.SendTunnelRequestAsync( - HttpMethod.Put, - tunnel, - ManageAccessTokenScope, - path: null, - query: GetApiQuery(), - options, - ConvertTunnelForRequest(tunnel), - cancellation); - PreserveAccessTokens(tunnel, result); - return result!; - } - - /// - public async Task DeleteTunnelAsync( - Tunnel tunnel, - TunnelRequestOptions? options, - CancellationToken cancellation) - { - var result = await this.SendTunnelRequestAsync( - HttpMethod.Delete, - tunnel, - ManageAccessTokenScope, - path: null, - query: GetApiQuery(), - options, - cancellation); - return result; - } - - /// - public async Task UpdateTunnelEndpointAsync( - Tunnel tunnel, - TunnelEndpoint endpoint, - TunnelRequestOptions? options = null, - CancellationToken cancellation = default) - { - Requires.NotNull(endpoint, nameof(endpoint)); - Requires.NotNullOrEmpty(endpoint.HostId!, nameof(TunnelEndpoint.HostId)); - Requires.NotNullOrEmpty(endpoint.Id!, nameof(TunnelEndpoint.Id)); - - var path = $"{EndpointsApiSubPath}/{endpoint.Id}"; - var query = GetApiQuery(); - query += "&connectionMode=" + endpoint.ConnectionMode; - var result = (await this.SendTunnelRequestAsync( - HttpMethod.Put, - tunnel, - HostAccessTokenScope, - path, - query: query, - options, - endpoint, - cancellation))!; - - - if (tunnel.Endpoints != null) - { - // Also update the endpoint in the local tunnel object. - tunnel.Endpoints = tunnel.Endpoints - .Where((e) => e.HostId != endpoint.HostId || - e.ConnectionMode != endpoint.ConnectionMode) - .Append(result) - .ToArray(); - } - - return result; - } - - /// - public async Task DeleteTunnelEndpointsAsync( - Tunnel tunnel, - string id, - TunnelRequestOptions? options = null, - CancellationToken cancellation = default) - { - Requires.NotNullOrEmpty(id, nameof(id)); - - var path = $"{EndpointsApiSubPath}/{id}"; - var result = await this.SendTunnelRequestAsync( - HttpMethod.Delete, - tunnel, - HostAccessTokenScope, - path, - query: GetApiQuery(), - options, - cancellation); - - if (result && tunnel.Endpoints != null) - { - // Also delete the endpoint in the local tunnel object. - tunnel.Endpoints = tunnel.Endpoints - .Where((e) => e.Id != id) - .ToArray(); - } - - return result; - } - - /// - public async Task ListTunnelPortsAsync( - Tunnel tunnel, - TunnelRequestOptions? options, - CancellationToken cancellation) - { - var result = await this.SendTunnelRequestAsync( - HttpMethod.Get, - tunnel, - ReadAccessTokenScopes, - PortsApiSubPath, - query: GetApiQuery(), - options, - cancellation); - return result!.Value!; - } - - /// - public async Task GetTunnelPortAsync( - Tunnel tunnel, - ushort portNumber, - TunnelRequestOptions? options, - CancellationToken cancellation) - { - this.OnReportProgress(TunnelProgress.StartingGetTunnelPort); - var path = $"{PortsApiSubPath}/{portNumber}"; - var result = await this.SendTunnelRequestAsync( - HttpMethod.Get, - tunnel, - ReadAccessTokenScopes, - path, - query: GetApiQuery(), - options, - cancellation); - this.OnReportProgress(TunnelProgress.CompletedGetTunnelPort); - return result; - } - - /// - public async Task CreateTunnelPortAsync( - Tunnel tunnel, - TunnelPort tunnelPort, - TunnelRequestOptions? options, - CancellationToken cancellation) - { - Requires.NotNull(tunnelPort, nameof(tunnelPort)); - this.OnReportProgress(TunnelProgress.StartingCreateTunnelPort); - var path = $"{PortsApiSubPath}/{tunnelPort.PortNumber}"; - options ??= new TunnelRequestOptions(); - options.AdditionalHeaders ??= new List>(); - options.AdditionalHeaders = options.AdditionalHeaders.Append(new KeyValuePair("If-None-Match", "*")); - - var result = (await this.SendTunnelRequestAsync( - HttpMethod.Put, - tunnel, - ManagePortsAccessTokenScopes, - path, - query: GetApiQuery(), - options, - ConvertTunnelPortForRequest(tunnel, tunnelPort), - cancellation))!; - PreserveAccessTokens(tunnelPort, result); - - tunnel.Ports ??= new TunnelPort[0]; - - // Also add the port to the local tunnel object. - tunnel.Ports = tunnel.Ports - .Where((p) => p.PortNumber != tunnelPort.PortNumber) - .Append(result) - .OrderBy((p) => p.PortNumber) - .ToArray(); - this.OnReportProgress(TunnelProgress.CompletedCreateTunnelPort); - return result; - } - - /// - public async Task UpdateTunnelPortAsync( - Tunnel tunnel, - TunnelPort tunnelPort, - TunnelRequestOptions? options, - CancellationToken cancellation) - { - Requires.NotNull(tunnelPort, nameof(tunnelPort)); - options ??= new TunnelRequestOptions(); - options.AdditionalHeaders ??= new List>(); - options.AdditionalHeaders = options.AdditionalHeaders.Append(new KeyValuePair("If-Match", "*")); - - if (tunnelPort.ClusterId != null && tunnel.ClusterId != null && - tunnelPort.ClusterId != tunnel.ClusterId) - { - throw new ArgumentException( - "Tunnel port cluster ID is not consistent.", nameof(tunnelPort)); - } - - var portNumber = tunnelPort.PortNumber; - var path = $"{PortsApiSubPath}/{portNumber}"; - var result = (await this.SendTunnelRequestAsync( - HttpMethod.Put, - tunnel, - ManagePortsAccessTokenScopes, - path, - query: GetApiQuery(), - options, - ConvertTunnelPortForRequest(tunnel, tunnelPort), - cancellation))!; - PreserveAccessTokens(tunnelPort, result); - - tunnel.Ports ??= new TunnelPort[0]; - - // Also add the port to the local tunnel object. - tunnel.Ports = tunnel.Ports - .Where((p) => p.PortNumber != tunnelPort.PortNumber) - .Append(result) - .OrderBy((p) => p.PortNumber) - .ToArray(); - - - return result; - } - - /// - public async Task CreateOrUpdateTunnelPortAsync( - Tunnel tunnel, - TunnelPort tunnelPort, - TunnelRequestOptions? options, - CancellationToken cancellation) - { - Requires.NotNull(tunnelPort, nameof(tunnelPort)); - - if (tunnelPort.ClusterId != null && tunnel.ClusterId != null && - tunnelPort.ClusterId != tunnel.ClusterId) - { - throw new ArgumentException( - "Tunnel port cluster ID is not consistent.", nameof(tunnelPort)); - } - - var portNumber = tunnelPort.PortNumber; - var path = $"{PortsApiSubPath}/{portNumber}"; - var result = (await this.SendTunnelRequestAsync( - HttpMethod.Put, - tunnel, - ManagePortsAccessTokenScopes, - path, - query: GetApiQuery(), - options, - ConvertTunnelPortForRequest(tunnel, tunnelPort), - cancellation))!; - PreserveAccessTokens(tunnelPort, result); - - tunnel.Ports ??= new TunnelPort[0]; - - // Also add the port to the local tunnel object. - tunnel.Ports = tunnel.Ports - .Where((p) => p.PortNumber != tunnelPort.PortNumber) - .Append(result) - .OrderBy((p) => p.PortNumber) - .ToArray(); - - - return result; - } - - /// - public async Task DeleteTunnelPortAsync( - Tunnel tunnel, - ushort portNumber, - TunnelRequestOptions? options, - CancellationToken cancellation) - { - var path = $"{PortsApiSubPath}/{portNumber}"; - var result = await this.SendTunnelRequestAsync( - HttpMethod.Delete, - tunnel, - ManagePortsAccessTokenScopes, - path, - query: GetApiQuery(), - options, - cancellation); - - if (result && tunnel.Ports != null) - { - // Also delete the port in the local tunnel object. - tunnel.Ports = tunnel.Ports - .Where((p) => p.PortNumber != portNumber) - .OrderBy((p) => p.PortNumber) - .ToArray(); - } - - return result; - } - - /// - /// Event fired when a tunnel progress event has been reported. - /// - protected virtual void OnReportProgress(TunnelProgress progress) - { - if (ReportProgress is EventHandler handler) - { - var args = new TunnelReportProgressEventArgs(progress.ToString()); - handler.Invoke(this, args); - } - } - - /// - /// Removes read-only properties like tokens and status from create/update requests. - /// - private Tunnel ConvertTunnelForRequest(Tunnel tunnel) - { - return new Tunnel - { - TunnelId = tunnel.TunnelId, - Name = tunnel.Name, - Domain = tunnel.Domain, - Description = tunnel.Description, - Labels = tunnel.Labels, - CustomExpiration = tunnel.CustomExpiration, - Options = tunnel.Options, - AccessControl = tunnel.AccessControl == null ? null : new TunnelAccessControl( - tunnel.AccessControl.Where((ace) => !ace.IsInherited)), - Endpoints = tunnel.Endpoints, - Ports = tunnel.Ports? - .Select((p) => ConvertTunnelPortForRequest(tunnel, p)) - .ToArray(), - }; - } - - /// - /// Removes read-only properties like tokens and status from create/update requests. - /// - private TunnelPort ConvertTunnelPortForRequest(Tunnel tunnel, TunnelPort tunnelPort) - { - if (tunnelPort.ClusterId != null && tunnel.ClusterId != null && - tunnelPort.ClusterId != tunnel.ClusterId) - { - throw new ArgumentException( - "Tunnel port cluster ID does not match tunnel.", nameof(tunnelPort)); - } - - if (tunnelPort.TunnelId != null && tunnel.TunnelId != null && - tunnelPort.TunnelId != tunnel.TunnelId) - { - throw new ArgumentException( - "Tunnel port tunnel ID does not match tunnel.", nameof(tunnelPort)); - } - - return new TunnelPort - { - PortNumber = tunnelPort.PortNumber, - Protocol = tunnelPort.Protocol, - IsDefault = tunnelPort.IsDefault, - Description = tunnelPort.Description, - Labels = tunnelPort.Labels, - Options = tunnelPort.Options, - AccessControl = tunnelPort.AccessControl == null ? null : new TunnelAccessControl( - tunnelPort.AccessControl.Where((ace) => !ace.IsInherited)), - SshUser = tunnelPort.SshUser, - }; - } - - /// - public async Task FormatSubjectsAsync( - TunnelAccessSubject[] subjects, - TunnelRequestOptions? options = null, - CancellationToken cancellation = default) - { - Requires.NotNull(subjects, nameof(subjects)); - - if (subjects.Length == 0) - { - return subjects; - } - - var formattedSubjects = await SendRequestAsync - ( - HttpMethod.Post, - clusterId: null, - SubjectsPath + "/format", - query: GetApiQuery(), - options, - subjects, - cancellation); - return formattedSubjects!; - } - - /// - public async Task ResolveSubjectsAsync( - TunnelAccessSubject[] subjects, - TunnelRequestOptions? options = null, - CancellationToken cancellation = default) - { - Requires.NotNull(subjects, nameof(subjects)); - - if (subjects.Length == 0) - { - return subjects; - } - - var resolvedSubjects = await SendRequestAsync - ( - HttpMethod.Post, - clusterId: null, - SubjectsPath + "/resolve", - query: GetApiQuery(), - options, - subjects, - cancellation); - return resolvedSubjects!; - } - - /// - public async Task ListUserLimitsAsync(CancellationToken cancellation = default) - { - var userLimits = await SendRequestAsync( - HttpMethod.Get, - clusterId: null, - UserLimitsPath, - query: GetApiQuery(), - options: null, - cancellation); - return userLimits!; - } - - /// - public async Task ListClustersAsync(CancellationToken cancellation) - { - var baseAddress = this.httpClient.BaseAddress!; - var builder = new UriBuilder(baseAddress); - builder.Path = ClustersPath; - builder.Query = GetApiQuery(); - var clusterDetails = await SendRequestAsync( - HttpMethod.Get, - builder.Uri, - options: null, - authHeader: null, - body: null, - cancellation); - return clusterDetails!; - } - - /// - public async Task CheckNameAvailabilityAsync( - string name, - CancellationToken cancellation = default) - { - name = Uri.EscapeDataString(name); - Requires.NotNull(name, nameof(name)); - return await this.SendRequestAsync( - HttpMethod.Get, - clusterId: null, - TunnelsPath + "/" + name + CheckAvailableSubPath, - query: GetApiQuery(), - options: null, - cancellation - ); - } - - /// - /// Gets required query string parmeters - /// - /// Query string - protected virtual string? GetApiQuery() - { - return string.IsNullOrEmpty(ApiVersion) ? null : $"api-version={ApiVersion}"; - } - - /// - /// Copy access tokens from the request object to the result object, except for any - /// tokens that were refreshed by the request. - /// - /// - /// This intentionally does not check whether any existing tokens are expired. So - /// expired tokens may be preserved also, if not refreshed. This allows for better - /// diagnostics in that case. - /// - private static void PreserveAccessTokens(Tunnel requestTunnel, Tunnel? resultTunnel) - { - if (requestTunnel.AccessTokens != null && resultTunnel != null) - { - resultTunnel.AccessTokens ??= new Dictionary(); - foreach (var scopeAndToken in requestTunnel.AccessTokens) - { - if (!resultTunnel.AccessTokens.ContainsKey(scopeAndToken.Key)) - { - resultTunnel.AccessTokens[scopeAndToken.Key] = scopeAndToken.Value; - } - } - } - } - - /// - /// Copy access tokens from the request object to the result object, except for any - /// tokens that were refreshed by the request. - /// - private static void PreserveAccessTokens(TunnelPort requestPort, TunnelPort? resultPort) - { - if (requestPort.AccessTokens != null && resultPort != null) - { - resultPort.AccessTokens ??= new Dictionary(); - foreach (var scopeAndToken in requestPort.AccessTokens) - { - if (!resultPort.AccessTokens.ContainsKey(scopeAndToken.Key)) - { - resultPort.AccessTokens[scopeAndToken.Key] = scopeAndToken.Value; - } - } - } - } - } -} \ No newline at end of file + } + } + + /// + /// Converts a tunnel service HTTP response to a result object (or exception). + /// + /// Type of result expected, or bool to just check for either success or + /// not-found. + /// Request method. + /// Response from a tunnel service request. + /// Cancellation token. + /// Result object of the requested type, or false if the response was 404 and + /// the result type is boolean, or null if a GET request for a non-array result object type + /// returned 404 Not Found. + /// The service returned a + /// 400 Bad Request response. + /// The service returned a 401 Unauthorized + /// or 403 Forbidden response. + private static async Task ConvertResponseAsync( + HttpMethod method, + HttpResponseMessage response, + CancellationToken cancellation) + { + Requires.NotNull(response, nameof(response)); + + // Requests that expect a boolean result just check for success or not-found result. + // GET requests that expect a single object result return null for not found result. + // GET requests that expect an array result should throw an error for not-found result + // because empty array was expected instead. + // PUT/POST/PATCH requests should also throw an error for not-found. + bool allowNotFound = typeof(T) == typeof(bool) || + ((method == HttpMethod.Get || method == HttpMethod.Head) && !typeof(T).IsArray && typeof(T) != typeof(TunnelPortListResponse) && typeof(T) != typeof(TunnelListByRegionResponse)); + + string? errorMessage = null; + Exception? innerException = null; + if (response.IsSuccessStatusCode) + { + if (response.StatusCode == HttpStatusCode.NoContent || response.Content == null) + { + return typeof(T) == typeof(bool) ? (T?)(object)(bool?)true : default; + } + + try + { + T? result = await response.Content.ReadFromJsonAsync( + JsonOptions, cancellation); + return result; + } + catch (Exception ex) + { + innerException = ex; + errorMessage = "Tunnel service response deserialization error: " + ex.Message; + } + } + + if (errorMessage == null && response.Content != null) + { + try + { + if ((int)response.StatusCode >= 400 && (int)response.StatusCode < 500) + { + // 4xx status responses may include standard ProblemDetails. + var problemDetails = await response.Content + .ReadFromJsonAsync(JsonOptions, cancellation); + if (!string.IsNullOrEmpty(problemDetails?.Title) || + !string.IsNullOrEmpty(problemDetails?.Detail)) + { + if (allowNotFound && response.StatusCode == HttpStatusCode.NotFound && + problemDetails.Detail == null) + { + return default; + } + + errorMessage = "Tunnel service error: " + + problemDetails!.Title + " " + problemDetails.Detail; + if (problemDetails.Errors != null) + { + foreach (var error in problemDetails.Errors) + { + var messages = string.Join(" ", error.Value); + errorMessage += $"\n{error.Key}: {messages}"; + } + } + } + } + else if ((int)response.StatusCode >= 500) + { + // 5xx status responses may include VS SaaS error details. + var errorDetails = await response.Content.ReadFromJsonAsync( + JsonOptions, cancellation); + if (!string.IsNullOrEmpty(errorDetails?.Message)) + { + errorMessage = "Tunnel service error: " + errorDetails!.Message; + if (!string.IsNullOrEmpty(errorDetails.StackTrace)) + { + errorMessage += "\n" + errorDetails.StackTrace; + } + } + } + } + catch (Exception ex) + { + // A default error message will be filled in below. + innerException = ex; + } + } + + errorMessage ??= "Tunnel service response status code: " + response.StatusCode; + + if (response.Headers.TryGetValues(RequestIdHeaderName, out var requestId)) + { + errorMessage += $"\nRequest ID: {requestId.First()}"; + } + + try + { + response.EnsureSuccessStatusCode(); + } + catch (HttpRequestException hrex) + { + switch (response.StatusCode) + { + case HttpStatusCode.BadRequest: + throw new ArgumentException(errorMessage, hrex); + + case HttpStatusCode.Unauthorized: + case HttpStatusCode.Forbidden: + // Enterprise Policies + if (response.Headers.Contains("X-Enterprise-Policy-Failure")) + { + var message = response.Content != null ? await response.Content.ReadAsStringAsync() : string.Empty; + errorMessage = message; + } + + var ex = new UnauthorizedAccessException(errorMessage, hrex); + + // The HttpResponseHeaders.WwwAuthenticate property does not correctly + // handle multiple values! Get the values by name instead. + if (response.Headers.TryGetValues( + "WWW-Authenticate", out var authHeaderValues)) + { + ex.SetAuthenticationSchemes(authHeaderValues); + } + + throw ex; + + case HttpStatusCode.NotFound: + case HttpStatusCode.Conflict: + case HttpStatusCode.PreconditionFailed: + case HttpStatusCode.TooManyRequests: + throw new InvalidOperationException(errorMessage, hrex); + + case HttpStatusCode.Redirect: + case HttpStatusCode.RedirectKeepVerb: + // Add the redirect location to the exception data. + // Normally the HTTP client should automatically follow redirects, + // but this allows tests to validate the service's redirection behavior + // when client auto redirection is disabled. + hrex.Data["Location"] = response.Headers.Location; + throw; + + default: throw; + } + } + + throw new Exception(errorMessage, innerException); + } + + /// + /// Error details that may be returned from the service with 500 status responses + /// (when in development mode). + /// + /// + /// Copied from Microsoft.VsSaaS.Common to avoid taking a dependency on that assembly. + /// + private class ErrorDetails + { + public string? Message { get; set; } + public string? StackTrace { get; set; } + } + + /// + public void Dispose() + { + this.httpClient.Dispose(); + } + + private Uri BuildUri( + string? clusterId, + string path, + string? query, + TunnelRequestOptions? options) + { + Requires.NotNullOrEmpty(path, nameof(path)); + + var baseAddress = this.httpClient.BaseAddress!; + var builder = new UriBuilder(baseAddress); + + if (!string.IsNullOrEmpty(clusterId) && + baseAddress.HostNameType == UriHostNameType.Dns) + { + if (baseAddress.Host != "localhost" && + !baseAddress.Host.StartsWith($"{clusterId}.")) + { + // A specific cluster ID was specified (while not running on localhost). + // Prepend the cluster ID to the hostname, and optionally strip a global prefix. + builder.Host = $"{clusterId}.{builder.Host}".Replace("global.", string.Empty); + } + else if (baseAddress.Scheme == "https" && + clusterId.StartsWith("localhost") && builder.Port % 10 > 0 && + ushort.TryParse(clusterId.Substring("localhost".Length), out var clusterNumber)) + { + // Local testing simulates clusters by running the service on multiple ports. + // Change the port number to match the cluster ID suffix. + if (clusterNumber > 0 && clusterNumber < 10) + { + builder.Port = builder.Port - (builder.Port % 10) + clusterNumber; + } + } + } + + if (options != null) + { + var optionsQuery = options.ToQueryString(); + if (!string.IsNullOrEmpty(optionsQuery)) + { + query = optionsQuery + + (!string.IsNullOrEmpty(query) ? '&' + query : string.Empty); + } + } + + builder.Path = path; + builder.Query = query; + return builder.Uri; + } + + private Uri BuildTunnelUri( + Tunnel tunnel, + string? path, + string? query, + TunnelRequestOptions? options, + bool isCreate = false) + { + Requires.NotNull(tunnel, nameof(tunnel)); + + string tunnelPath; + var pathBase = TunnelsPath; + if (!string.IsNullOrEmpty(tunnel.TunnelId) && (!string.IsNullOrEmpty(tunnel.ClusterId) || isCreate)) + { + tunnelPath = $"{pathBase}/{tunnel.TunnelId}"; + } + else + { + Requires.Argument( + !string.IsNullOrEmpty(tunnel.Name), + nameof(tunnel), + "Tunnel object must include either a name or tunnel ID and cluster ID."); + + if (string.IsNullOrEmpty(tunnel.Domain)) + { + + tunnelPath = $"{pathBase}/{tunnel.Name}"; + } + else + { + // Append the domain to the tunnel name. + tunnelPath = $"{pathBase}/{tunnel.Name}.{tunnel.Domain}"; + } + } + + return BuildUri( + tunnel.ClusterId, + tunnelPath + (!string.IsNullOrEmpty(path) ? path : string.Empty), + query, + options); + } + + private async Task GetAuthenticationHeaderAsync( + Tunnel? tunnel, + string[]? accessTokenScopes, + TunnelRequestOptions? options) + { + AuthenticationHeaderValue? authHeader = null; + + if (!string.IsNullOrEmpty(options?.AccessToken)) + { + authHeader = new AuthenticationHeaderValue( + TunnelAuthenticationScheme, options.AccessToken); + } + + if (authHeader == null) + { + authHeader = await this.userTokenCallback(); + } + + if (authHeader == null && tunnel?.AccessTokens != null && accessTokenScopes != null) + { + foreach (var scope in accessTokenScopes) + { + if (tunnel.TryGetAccessToken(scope, out string? accessToken)) + { + authHeader = new AuthenticationHeaderValue( + TunnelAuthenticationScheme, accessToken); + break; + } + } + } + + return authHeader; + } + + /// + public async Task ListTunnelsAsync( + string? clusterId, + string? domain, + TunnelRequestOptions? options, + bool? ownedTunnelsOnly, + CancellationToken cancellation) + { + var queryParams = new string?[] + { + string.IsNullOrEmpty(clusterId) ? "global=true" : null, + !string.IsNullOrEmpty(domain) ? $"domain={HttpUtility.UrlEncode(domain)}" : null, + !string.IsNullOrEmpty(ApiVersion) ? GetApiQuery() : null, + ownedTunnelsOnly == true ? "ownedTunnelsOnly=true" : null, + }; + var query = string.Join("&", queryParams.Where((p) => p != null)); + var result = await this.SendRequestAsync( + HttpMethod.Get, + clusterId, + TunnelsPath, + query, + options, + cancellation); + if (result?.Value != null) + { + return result.Value.Where(t => t.Value != null).SelectMany(t => t.Value!).ToArray(); + } + + return Array.Empty(); + } + + /// + [Obsolete("Use ListTunnelsAsync() method with TunnelRequestOptions.Labels instead.")] + public async Task SearchTunnelsAsync( + string[] labels, + bool requireAllLabels, + string? clusterId, + string? domain, + TunnelRequestOptions? options, + CancellationToken cancellation) + { + var queryParams = new string?[] + { + string.IsNullOrEmpty(clusterId) ? "global=true" : null, + !string.IsNullOrEmpty(domain) ? $"domain={HttpUtility.UrlEncode(domain)}" : null, + $"labels={string.Join(",", labels.Select(HttpUtility.UrlEncode))}", + $"allLabels={requireAllLabels}", + !string.IsNullOrEmpty(ApiVersion) ? GetApiQuery() : null, + }; + var query = string.Join("&", queryParams.Where((p) => p != null)); + var result = await this.SendRequestAsync( + HttpMethod.Get, + clusterId, + TunnelsPath, + query, + options, + cancellation); + return result!; + } + + /// + public async Task GetTunnelAsync( + Tunnel tunnel, + TunnelRequestOptions? options, + CancellationToken cancellation) + { + var result = await this.SendTunnelRequestAsync( + HttpMethod.Get, + tunnel, + ReadAccessTokenScopes, + path: null, + query: GetApiQuery(), + options, + cancellation); + PreserveAccessTokens(tunnel, result); + return result; + } + + /// + public async Task CreateTunnelAsync( + Tunnel tunnel, + TunnelRequestOptions? options, + CancellationToken cancellation) + { + Requires.NotNull(tunnel, nameof(tunnel)); + options ??= new TunnelRequestOptions(); + options.AdditionalHeaders ??= new List>(); + options.AdditionalHeaders = options.AdditionalHeaders.Append(new KeyValuePair("If-None-Match", "*")); + var tunnelId = tunnel.TunnelId; + var idGenerated = string.IsNullOrEmpty(tunnelId); + if (idGenerated) + { + tunnel.TunnelId = IdGeneration.GenerateTunnelId(); + } + for (int retries = 0; retries <= CreateNameRetries; retries++) + { + try + { + var result = await this.SendTunnelRequestAsync( + HttpMethod.Put, + tunnel, + ManageAccessTokenScope, + path: null, + query: GetApiQuery(), + options, + ConvertTunnelForRequest(tunnel), + cancellation, + true); + PreserveAccessTokens(tunnel, result); + return result!; + } + catch (UnauthorizedAccessException) when (idGenerated && retries < CreateNameRetries) // The tunnel ID was already taken. + { + tunnel.TunnelId = IdGeneration.GenerateTunnelId(); + } + } + + // This code is unreachable, but the compiler still requires it. + var result2 = await this.SendTunnelRequestAsync( + HttpMethod.Put, + tunnel, + ManageAccessTokenScope, + path: null, + query: GetApiQuery(), + options, + ConvertTunnelForRequest(tunnel), + cancellation, + true); + PreserveAccessTokens(tunnel, result2); + return result2!; + } + + /// + public async Task CreateOrUpdateTunnelAsync( + Tunnel tunnel, + TunnelRequestOptions? options, + CancellationToken cancellation) + { + Requires.NotNull(tunnel, nameof(tunnel)); + + var tunnelId = tunnel.TunnelId; + var idGenerated = string.IsNullOrEmpty(tunnelId); + if (idGenerated) + { + tunnel.TunnelId = IdGeneration.GenerateTunnelId(); + } + for (int retries = 0; retries <= CreateNameRetries; retries++) + { + try + { + var result = await this.SendTunnelRequestAsync( + HttpMethod.Put, + tunnel, + ManageAccessTokenScope, + path: null, + query: GetApiQuery(), + options, + ConvertTunnelForRequest(tunnel), + cancellation, + true); + PreserveAccessTokens(tunnel, result); + return result!; + } + catch (UnauthorizedAccessException) when (idGenerated && retries < 3) // The tunnel ID was already taken. + { + tunnel.TunnelId = IdGeneration.GenerateTunnelId(); + } + } + + // This code is unreachable, but the compiler still requires it. + var result2 = await this.SendTunnelRequestAsync( + HttpMethod.Put, + tunnel, + ManageAccessTokenScope, + path: null, + query: GetApiQuery(), + options, + ConvertTunnelForRequest(tunnel), + cancellation, + true); + PreserveAccessTokens(tunnel, result2); + return result2!; + } + + /// + public async Task UpdateTunnelAsync( + Tunnel tunnel, + TunnelRequestOptions? options, + CancellationToken cancellation) + { + options ??= new TunnelRequestOptions(); + options.AdditionalHeaders ??= new List>(); + options.AdditionalHeaders = options.AdditionalHeaders.Append(new KeyValuePair("If-Match", "*")); + var result = await this.SendTunnelRequestAsync( + HttpMethod.Put, + tunnel, + ManageAccessTokenScope, + path: null, + query: GetApiQuery(), + options, + ConvertTunnelForRequest(tunnel), + cancellation); + PreserveAccessTokens(tunnel, result); + return result!; + } + + /// + public async Task DeleteTunnelAsync( + Tunnel tunnel, + TunnelRequestOptions? options, + CancellationToken cancellation) + { + var result = await this.SendTunnelRequestAsync( + HttpMethod.Delete, + tunnel, + ManageAccessTokenScope, + path: null, + query: GetApiQuery(), + options, + cancellation); + return result; + } + + /// + public async Task UpdateTunnelEndpointAsync( + Tunnel tunnel, + TunnelEndpoint endpoint, + TunnelRequestOptions? options = null, + CancellationToken cancellation = default) + { + Requires.NotNull(endpoint, nameof(endpoint)); + Requires.NotNullOrEmpty(endpoint.HostId!, nameof(TunnelEndpoint.HostId)); + Requires.NotNullOrEmpty(endpoint.Id!, nameof(TunnelEndpoint.Id)); + + var path = $"{EndpointsApiSubPath}/{endpoint.Id}"; + var query = GetApiQuery(); + query += "&connectionMode=" + endpoint.ConnectionMode; + var result = (await this.SendTunnelRequestAsync( + HttpMethod.Put, + tunnel, + HostAccessTokenScope, + path, + query: query, + options, + endpoint, + cancellation))!; + + + if (tunnel.Endpoints != null) + { + // Also update the endpoint in the local tunnel object. + tunnel.Endpoints = tunnel.Endpoints + .Where((e) => e.HostId != endpoint.HostId || + e.ConnectionMode != endpoint.ConnectionMode) + .Append(result) + .ToArray(); + } + + return result; + } + + /// + public async Task DeleteTunnelEndpointsAsync( + Tunnel tunnel, + string id, + TunnelRequestOptions? options = null, + CancellationToken cancellation = default) + { + Requires.NotNullOrEmpty(id, nameof(id)); + + var path = $"{EndpointsApiSubPath}/{id}"; + var result = await this.SendTunnelRequestAsync( + HttpMethod.Delete, + tunnel, + HostAccessTokenScope, + path, + query: GetApiQuery(), + options, + cancellation); + + if (result && tunnel.Endpoints != null) + { + // Also delete the endpoint in the local tunnel object. + tunnel.Endpoints = tunnel.Endpoints + .Where((e) => e.Id != id) + .ToArray(); + } + + return result; + } + + /// + public async Task ListTunnelPortsAsync( + Tunnel tunnel, + TunnelRequestOptions? options, + CancellationToken cancellation) + { + var result = await this.SendTunnelRequestAsync( + HttpMethod.Get, + tunnel, + ReadAccessTokenScopes, + PortsApiSubPath, + query: GetApiQuery(), + options, + cancellation); + return result!.Value!; + } + + /// + public async Task GetTunnelPortAsync( + Tunnel tunnel, + ushort portNumber, + TunnelRequestOptions? options, + CancellationToken cancellation) + { + this.OnReportProgress(TunnelProgress.StartingGetTunnelPort); + var path = $"{PortsApiSubPath}/{portNumber}"; + var result = await this.SendTunnelRequestAsync( + HttpMethod.Get, + tunnel, + ReadAccessTokenScopes, + path, + query: GetApiQuery(), + options, + cancellation); + this.OnReportProgress(TunnelProgress.CompletedGetTunnelPort); + return result; + } + + /// + public async Task CreateTunnelPortAsync( + Tunnel tunnel, + TunnelPort tunnelPort, + TunnelRequestOptions? options, + CancellationToken cancellation) + { + Requires.NotNull(tunnelPort, nameof(tunnelPort)); + this.OnReportProgress(TunnelProgress.StartingCreateTunnelPort); + var path = $"{PortsApiSubPath}/{tunnelPort.PortNumber}"; + options ??= new TunnelRequestOptions(); + options.AdditionalHeaders ??= new List>(); + options.AdditionalHeaders = options.AdditionalHeaders.Append(new KeyValuePair("If-None-Match", "*")); + + var result = (await this.SendTunnelRequestAsync( + HttpMethod.Put, + tunnel, + ManagePortsAccessTokenScopes, + path, + query: GetApiQuery(), + options, + ConvertTunnelPortForRequest(tunnel, tunnelPort), + cancellation))!; + PreserveAccessTokens(tunnelPort, result); + + tunnel.Ports ??= new TunnelPort[0]; + + // Also add the port to the local tunnel object. + tunnel.Ports = tunnel.Ports + .Where((p) => p.PortNumber != tunnelPort.PortNumber) + .Append(result) + .OrderBy((p) => p.PortNumber) + .ToArray(); + this.OnReportProgress(TunnelProgress.CompletedCreateTunnelPort); + return result; + } + + /// + public async Task UpdateTunnelPortAsync( + Tunnel tunnel, + TunnelPort tunnelPort, + TunnelRequestOptions? options, + CancellationToken cancellation) + { + Requires.NotNull(tunnelPort, nameof(tunnelPort)); + options ??= new TunnelRequestOptions(); + options.AdditionalHeaders ??= new List>(); + options.AdditionalHeaders = options.AdditionalHeaders.Append(new KeyValuePair("If-Match", "*")); + + if (tunnelPort.ClusterId != null && tunnel.ClusterId != null && + tunnelPort.ClusterId != tunnel.ClusterId) + { + throw new ArgumentException( + "Tunnel port cluster ID is not consistent.", nameof(tunnelPort)); + } + + var portNumber = tunnelPort.PortNumber; + var path = $"{PortsApiSubPath}/{portNumber}"; + var result = (await this.SendTunnelRequestAsync( + HttpMethod.Put, + tunnel, + ManagePortsAccessTokenScopes, + path, + query: GetApiQuery(), + options, + ConvertTunnelPortForRequest(tunnel, tunnelPort), + cancellation))!; + PreserveAccessTokens(tunnelPort, result); + + tunnel.Ports ??= new TunnelPort[0]; + + // Also add the port to the local tunnel object. + tunnel.Ports = tunnel.Ports + .Where((p) => p.PortNumber != tunnelPort.PortNumber) + .Append(result) + .OrderBy((p) => p.PortNumber) + .ToArray(); + + + return result; + } + + /// + public async Task CreateOrUpdateTunnelPortAsync( + Tunnel tunnel, + TunnelPort tunnelPort, + TunnelRequestOptions? options, + CancellationToken cancellation) + { + Requires.NotNull(tunnelPort, nameof(tunnelPort)); + + if (tunnelPort.ClusterId != null && tunnel.ClusterId != null && + tunnelPort.ClusterId != tunnel.ClusterId) + { + throw new ArgumentException( + "Tunnel port cluster ID is not consistent.", nameof(tunnelPort)); + } + + var portNumber = tunnelPort.PortNumber; + var path = $"{PortsApiSubPath}/{portNumber}"; + var result = (await this.SendTunnelRequestAsync( + HttpMethod.Put, + tunnel, + ManagePortsAccessTokenScopes, + path, + query: GetApiQuery(), + options, + ConvertTunnelPortForRequest(tunnel, tunnelPort), + cancellation))!; + PreserveAccessTokens(tunnelPort, result); + + tunnel.Ports ??= new TunnelPort[0]; + + // Also add the port to the local tunnel object. + tunnel.Ports = tunnel.Ports + .Where((p) => p.PortNumber != tunnelPort.PortNumber) + .Append(result) + .OrderBy((p) => p.PortNumber) + .ToArray(); + + + return result; + } + + /// + public async Task DeleteTunnelPortAsync( + Tunnel tunnel, + ushort portNumber, + TunnelRequestOptions? options, + CancellationToken cancellation) + { + var path = $"{PortsApiSubPath}/{portNumber}"; + var result = await this.SendTunnelRequestAsync( + HttpMethod.Delete, + tunnel, + ManagePortsAccessTokenScopes, + path, + query: GetApiQuery(), + options, + cancellation); + + if (result && tunnel.Ports != null) + { + // Also delete the port in the local tunnel object. + tunnel.Ports = tunnel.Ports + .Where((p) => p.PortNumber != portNumber) + .OrderBy((p) => p.PortNumber) + .ToArray(); + } + + return result; + } + + /// + /// Event fired when a tunnel progress event has been reported. + /// + protected virtual void OnReportProgress(TunnelProgress progress) + { + if (ReportProgress is EventHandler handler) + { + var args = new TunnelReportProgressEventArgs(progress.ToString()); + handler.Invoke(this, args); + } + } + + /// + /// Removes read-only properties like tokens and status from create/update requests. + /// + private Tunnel ConvertTunnelForRequest(Tunnel tunnel) + { + return new Tunnel + { + TunnelId = tunnel.TunnelId, + Name = tunnel.Name, + Domain = tunnel.Domain, + Description = tunnel.Description, + Labels = tunnel.Labels, + CustomExpiration = tunnel.CustomExpiration, + Options = tunnel.Options, + AccessControl = tunnel.AccessControl == null ? null : new TunnelAccessControl( + tunnel.AccessControl.Where((ace) => !ace.IsInherited)), + Endpoints = tunnel.Endpoints, + Ports = tunnel.Ports? + .Select((p) => ConvertTunnelPortForRequest(tunnel, p)) + .ToArray(), + }; + } + + /// + /// Removes read-only properties like tokens and status from create/update requests. + /// + private TunnelPort ConvertTunnelPortForRequest(Tunnel tunnel, TunnelPort tunnelPort) + { + if (tunnelPort.ClusterId != null && tunnel.ClusterId != null && + tunnelPort.ClusterId != tunnel.ClusterId) + { + throw new ArgumentException( + "Tunnel port cluster ID does not match tunnel.", nameof(tunnelPort)); + } + + if (tunnelPort.TunnelId != null && tunnel.TunnelId != null && + tunnelPort.TunnelId != tunnel.TunnelId) + { + throw new ArgumentException( + "Tunnel port tunnel ID does not match tunnel.", nameof(tunnelPort)); + } + + return new TunnelPort + { + PortNumber = tunnelPort.PortNumber, + Protocol = tunnelPort.Protocol, + IsDefault = tunnelPort.IsDefault, + Description = tunnelPort.Description, + Labels = tunnelPort.Labels, + Options = tunnelPort.Options, + AccessControl = tunnelPort.AccessControl == null ? null : new TunnelAccessControl( + tunnelPort.AccessControl.Where((ace) => !ace.IsInherited)), + SshUser = tunnelPort.SshUser, + }; + } + + /// + public async Task FormatSubjectsAsync( + TunnelAccessSubject[] subjects, + TunnelRequestOptions? options = null, + CancellationToken cancellation = default) + { + Requires.NotNull(subjects, nameof(subjects)); + + if (subjects.Length == 0) + { + return subjects; + } + + var formattedSubjects = await SendRequestAsync + ( + HttpMethod.Post, + clusterId: null, + SubjectsPath + "/format", + query: GetApiQuery(), + options, + subjects, + cancellation); + return formattedSubjects!; + } + + /// + public async Task ResolveSubjectsAsync( + TunnelAccessSubject[] subjects, + TunnelRequestOptions? options = null, + CancellationToken cancellation = default) + { + Requires.NotNull(subjects, nameof(subjects)); + + if (subjects.Length == 0) + { + return subjects; + } + + var resolvedSubjects = await SendRequestAsync + ( + HttpMethod.Post, + clusterId: null, + SubjectsPath + "/resolve", + query: GetApiQuery(), + options, + subjects, + cancellation); + return resolvedSubjects!; + } + + /// + public async Task ListUserLimitsAsync(CancellationToken cancellation = default) + { + var userLimits = await SendRequestAsync( + HttpMethod.Get, + clusterId: null, + UserLimitsPath, + query: GetApiQuery(), + options: null, + cancellation); + return userLimits!; + } + + /// + public async Task ListClustersAsync(CancellationToken cancellation) { + var baseAddress = this.httpClient.BaseAddress!; + var builder = new UriBuilder(baseAddress); + builder.Path = ClustersPath; + builder.Query = GetApiQuery(); + var clusterDetails = await SendRequestAsync( + HttpMethod.Get, + builder.Uri, + options: null, + authHeader: null, + body: null, + cancellation); + return clusterDetails!; + } + + /// + public async Task CheckNameAvailabilityAsync( + string name, + CancellationToken cancellation = default) + { + name = Uri.EscapeDataString(name); + Requires.NotNull(name, nameof(name)); + return await this.SendRequestAsync( + HttpMethod.Get, + clusterId: null, + TunnelsPath + "/" + name + CheckAvailableSubPath, + query: GetApiQuery(), + options: null, + cancellation + ); + } + + /// + /// Gets required query string parmeters + /// + /// Query string + protected virtual string? GetApiQuery() + { + return string.IsNullOrEmpty(ApiVersion) ? null : $"api-version={ApiVersion}"; + } + + /// + /// Copy access tokens from the request object to the result object, except for any + /// tokens that were refreshed by the request. + /// + /// + /// This intentionally does not check whether any existing tokens are expired. So + /// expired tokens may be preserved also, if not refreshed. This allows for better + /// diagnostics in that case. + /// + private static void PreserveAccessTokens(Tunnel requestTunnel, Tunnel? resultTunnel) + { + if (requestTunnel.AccessTokens != null && resultTunnel != null) + { + resultTunnel.AccessTokens ??= new Dictionary(); + foreach (var scopeAndToken in requestTunnel.AccessTokens) + { + if (!resultTunnel.AccessTokens.ContainsKey(scopeAndToken.Key)) + { + resultTunnel.AccessTokens[scopeAndToken.Key] = scopeAndToken.Value; + } + } + } + } + + /// + /// Copy access tokens from the request object to the result object, except for any + /// tokens that were refreshed by the request. + /// + private static void PreserveAccessTokens(TunnelPort requestPort, TunnelPort? resultPort) + { + if (requestPort.AccessTokens != null && resultPort != null) + { + resultPort.AccessTokens ??= new Dictionary(); + foreach (var scopeAndToken in requestPort.AccessTokens) + { + if (!resultPort.AccessTokens.ContainsKey(scopeAndToken.Key)) + { + resultPort.AccessTokens[scopeAndToken.Key] = scopeAndToken.Value; + } + } + } + } + } +} From 500e3dd9ca76181e7603915a7b4e48e0438acf15 Mon Sep 17 00:00:00 2001 From: Neelima Potharaj Date: Tue, 27 Feb 2024 09:35:12 -0800 Subject: [PATCH 12/12] readded code --- cs/src/Management/TunnelManagementClient.cs | 39 +++++++++++++++------ 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/cs/src/Management/TunnelManagementClient.cs b/cs/src/Management/TunnelManagementClient.cs index 38ce6644..4a6d0a89 100644 --- a/cs/src/Management/TunnelManagementClient.cs +++ b/cs/src/Management/TunnelManagementClient.cs @@ -10,6 +10,7 @@ using System.Net.Http; using System.Net.Http.Headers; using System.Security.Authentication; +using System.Text.Json; #if NET5_0_OR_GREATER using System.Net.Http.Json; @@ -690,12 +691,29 @@ private string UserLimitsPath throw new ArgumentException(errorMessage, hrex); case HttpStatusCode.Unauthorized: - case HttpStatusCode.Forbidden: - // Enterprise Policies - if (response.Headers.Contains("X-Enterprise-Policy-Failure")) - { - var message = response.Content != null ? await response.Content.ReadAsStringAsync() : string.Empty; - errorMessage = message; + case HttpStatusCode.Forbidden: + // Enterprise Policies + if (response.Headers.Contains("X-Enterprise-Policy-Failure")) + { + var options = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }; + var message = response.Content != null ? await response.Content.ReadAsStringAsync() : string.Empty; + + ErrorDetails? errorDetails = null; + try + { + errorDetails = JsonSerializer.Deserialize(message, options); + } + catch (JsonException) + { + // If deserialization fails, it means the message is not in JSON format. + // In this case, use the message directly as the error message. + } + + // Use the deserialized error detail if available, otherwise use the raw message. + errorMessage = errorDetails?.Detail ?? message; } var ex = new UnauthorizedAccessException(errorMessage, hrex); @@ -739,10 +757,11 @@ private string UserLimitsPath /// /// Copied from Microsoft.VsSaaS.Common to avoid taking a dependency on that assembly. /// - private class ErrorDetails - { - public string? Message { get; set; } - public string? StackTrace { get; set; } + private class ErrorDetails + { + public string? Message { get; set; } + public string? StackTrace { get; set; } + public string? Detail { get; set; } } ///