/// <summary> /// Proxies the incoming request to the destination server, and the response back to the client. /// </summary> /// <remarks> /// In what follows, as well as throughout in Reverse Proxy, we consider /// the following picture as illustrative of the Proxy. /// <code> /// +-------------------+ /// | Destination + /// +-------------------+ /// ▲ | /// (b) | | (c) /// | ▼ /// +-------------------+ /// | Proxy + /// +-------------------+ /// ▲ | /// (a) | | (d) /// | ▼ /// +-------------------+ /// | Client + /// +-------------------+ /// </code> /// /// (a) and (b) show the *request* path, going from the client to the target. /// (c) and (d) show the *response* path, going from the destination back to the client. /// /// Normal proxying comprises the following steps: /// (0) Disable ASP .NET Core limits for streaming requests /// (1) Create outgoing HttpRequestMessage /// (2) Setup copy of request body (background) Client --► Proxy --► Destination /// (3) Copy request headers Client --► Proxy --► Destination /// (4) Send the outgoing request using HttpMessageInvoker Client --► Proxy --► Destination /// (5) Copy response status line Client ◄-- Proxy ◄-- Destination /// (6) Copy response headers Client ◄-- Proxy ◄-- Destination /// (7-A) Check for a 101 upgrade response, this takes care of WebSockets as well as any other upgradeable protocol. /// (7-A-1) Upgrade client channel Client ◄--- Proxy ◄--- Destination /// (7-A-2) Copy duplex streams and return Client ◄--► Proxy ◄--► Destination /// (7-B) Copy (normal) response body Client ◄-- Proxy ◄-- Destination /// (8) Copy response trailer headers and finish response Client ◄-- Proxy ◄-- Destination /// (9) Wait for completion of step 2: copying request body Client --► Proxy --► Destination /// /// ASP .NET Core (Kestrel) will finally send response trailers (if any) /// after we complete the steps above and relinquish control. /// </remarks> public async Task ProxyAsync( HttpContext context, string destinationPrefix, HttpMessageInvoker httpClient, RequestProxyOptions requestOptions, HttpTransformer transformer) { _ = context ?? throw new ArgumentNullException(nameof(context)); _ = destinationPrefix ?? throw new ArgumentNullException(nameof(destinationPrefix)); _ = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); transformer ??= HttpTransformer.Default; // HttpClient overload for SendAsync changes response behavior to fully buffered which impacts performance // See discussion in https://github.com/microsoft/reverse-proxy/issues/458 if (httpClient is HttpClient) { throw new ArgumentException($"The http client must be of type HttpMessageInvoker, not HttpClient", nameof(httpClient)); } ProxyTelemetry.Log.ProxyStart(destinationPrefix); try { var requestAborted = context.RequestAborted; var isClientHttp2 = ProtocolHelper.IsHttp2(context.Request.Protocol); // NOTE: We heuristically assume gRPC-looking requests may require streaming semantics. // See https://github.com/microsoft/reverse-proxy/issues/118 for design discussion. var isStreamingRequest = isClientHttp2 && ProtocolHelper.IsGrpcContentType(context.Request.ContentType); // :: Step 1-3: Create outgoing HttpRequestMessage var(destinationRequest, requestContent) = await CreateRequestMessageAsync( context, destinationPrefix, transformer, requestOptions, isStreamingRequest, requestAborted); // :: Step 4: Send the outgoing request using HttpClient HttpResponseMessage destinationResponse; var requestTimeoutSource = CancellationTokenSource.CreateLinkedTokenSource(requestAborted); requestTimeoutSource.CancelAfter(requestOptions?.Timeout ?? DefaultTimeout); var requestTimeoutToken = requestTimeoutSource.Token; try { ProxyTelemetry.Log.ProxyStage(ProxyStage.SendAsyncStart); destinationResponse = await httpClient.SendAsync(destinationRequest, requestTimeoutToken); ProxyTelemetry.Log.ProxyStage(ProxyStage.SendAsyncStop); } catch (OperationCanceledException canceledException) { if (!requestAborted.IsCancellationRequested && requestTimeoutToken.IsCancellationRequested) { ReportProxyError(context, ProxyError.RequestTimedOut, canceledException); context.Response.StatusCode = StatusCodes.Status504GatewayTimeout; return; } ReportProxyError(context, ProxyError.RequestCanceled, canceledException); context.Response.StatusCode = StatusCodes.Status502BadGateway; return; } catch (Exception requestException) { await HandleRequestFailureAsync(context, requestContent, requestException); return; } finally { requestTimeoutSource.Dispose(); } // Detect connection downgrade, which may be problematic for e.g. gRPC. if (isClientHttp2 && destinationResponse.Version.Major != 2) { // TODO: Do something on connection downgrade... Log.HttpDowngradeDetected(_logger); } // Assert that, if we are proxying content to the destination, it must have started by now // (since HttpClient.SendAsync has already completed asynchronously). // If this check fails, there is a coding defect which would otherwise // cause us to wait forever in step 9, so fail fast here. if (requestContent != null && !requestContent.Started) { // TODO: HttpClient might not need to read the body in some scenarios, such as an early auth failure with Expect: 100-continue. // https://github.com/microsoft/reverse-proxy/issues/617 throw new InvalidOperationException("Proxying the Client request body to the Destination server hasn't started. This is a coding defect."); } // :: Step 5: Copy response status line Client ◄-- Proxy ◄-- Destination // :: Step 6: Copy response headers Client ◄-- Proxy ◄-- Destination await CopyResponseStatusAndHeadersAsync(destinationResponse, context, transformer); // :: Step 7-A: Check for a 101 upgrade response, this takes care of WebSockets as well as any other upgradeable protocol. if (destinationResponse.StatusCode == HttpStatusCode.SwitchingProtocols) { await HandleUpgradedResponse(context, destinationResponse, requestAborted); return; } // NOTE: it may *seem* wise to call `context.Response.StartAsync()` at this point // since it looks like we are ready to send back response headers // (and this might help reduce extra delays while we wait to receive the body from the destination). // HOWEVER, this would produce the wrong result if it turns out that there is no content // from the destination -- instead of sending headers and terminating the stream at once, // we would send headers thinking a body may be coming, and there is none. // This is problematic on gRPC connections when the destination server encounters an error, // in which case it immediately returns the response headers and trailing headers, but no content, // and clients misbehave if the initial headers response does not indicate stream end. // :: Step 7-B: Copy response body Client ◄-- Proxy ◄-- Destination var(responseBodyCopyResult, responseBodyException) = await CopyResponseBodyAsync(destinationResponse.Content, context.Response.Body, requestAborted); if (responseBodyCopyResult != StreamCopyResult.Success) { await HandleResponseBodyErrorAsync(context, requestContent, responseBodyCopyResult, responseBodyException); return; } // :: Step 8: Copy response trailer headers and finish response Client ◄-- Proxy ◄-- Destination await CopyResponseTrailingHeadersAsync(destinationResponse, context, transformer); if (isStreamingRequest) { // NOTE: We must call `CompleteAsync` so that Kestrel will flush all bytes to the client. // In the case where there was no response body, // this is also when headers and trailing headers are sent to the client. // Without this, the client might wait forever waiting for response bytes, // while we might wait forever waiting for request bytes, // leading to a stuck connection and no way to make progress. await context.Response.CompleteAsync(); } // :: Step 9: Wait for completion of step 2: copying request body Client --► Proxy --► Destination if (requestContent != null) { var(requestBodyCopyResult, requestBodyException) = await requestContent.ConsumptionTask; if (requestBodyCopyResult != StreamCopyResult.Success) { // The response succeeded. If there was a request body error then it was probably because the client or destination decided // to cancel it. Report as low severity. var error = requestBodyCopyResult switch { StreamCopyResult.InputError => ProxyError.RequestBodyClient, StreamCopyResult.OutputError => ProxyError.RequestBodyDestination, StreamCopyResult.Canceled => ProxyError.RequestBodyCanceled, _ => throw new NotImplementedException(requestBodyCopyResult.ToString()) }; ReportProxyError(context, error, requestBodyException); } } } finally { ProxyTelemetry.Log.ProxyStop(context.Response.StatusCode); } }
private HttpRequestMessage CreateRequestMessage(HttpContext context, string destinationAddress, bool isUpgradeRequest, RequestProxyOptions proxyOptions) { // "http://a".Length = 8 if (destinationAddress == null || destinationAddress.Length < 8) { throw new ArgumentException(nameof(destinationAddress)); } // Default to HTTP/1.1 for proxying upgradeable requests. This is already the default as of .NET Core 3.1 // Otherwise request what's set in proxyOptions (e.g. default HTTP/2) and let HttpClient negotiate the protocol // based on VersionPolicy (for .NET 5 and higher). For example, downgrading to HTTP/1.1 if it cannot establish HTTP/2 with the target. // This is done without extra round-trips thanks to ALPN. We can detect a downgrade after calling HttpClient.SendAsync // (see Step 3 below). TBD how this will change when HTTP/3 is supported. var httpVersion = isUpgradeRequest ? ProtocolHelper.Http11Version : proxyOptions.Version; #if NET var httpVersionPolicy = isUpgradeRequest ? HttpVersionPolicy.RequestVersionOrLower : proxyOptions.VersionPolicy; #endif // TODO Perf: We could probably avoid splitting this and just append the final path and query UriHelper.FromAbsolute(destinationAddress, out var destinationScheme, out var destinationHost, out var destinationPathBase, out _, out _); // Query and Fragment are not supported here. var request = context.Request; var transforms = proxyOptions.Transforms.RequestTransforms; if (transforms.Count == 0) { var url = UriHelper.BuildAbsolute(destinationScheme, destinationHost, destinationPathBase, request.Path, request.QueryString); Log.Proxying(_logger, url); var uri = new Uri(url, UriKind.Absolute); return(new HttpRequestMessage(HttpUtilities.GetHttpMethod(context.Request.Method), uri) { Version = httpVersion, #if NET VersionPolicy = httpVersionPolicy, #endif }); } var transformContext = new RequestParametersTransformContext() { HttpContext = context, Version = httpVersion, Method = request.Method, Path = request.Path, Query = new QueryTransformContext(request), #if NET VersionPolicy = httpVersionPolicy, #endif }; foreach (var transform in transforms) { transform.Apply(transformContext); } var targetUrl = UriHelper.BuildAbsolute(destinationScheme, destinationHost, destinationPathBase, transformContext.Path, transformContext.Query.QueryString); Log.Proxying(_logger, targetUrl); var targetUri = new Uri(targetUrl, UriKind.Absolute); return(new HttpRequestMessage(HttpUtilities.GetHttpMethod(transformContext.Method), targetUri) { Version = transformContext.Version, #if NET VersionPolicy = transformContext.VersionPolicy, #endif }); }
private async Task <(HttpRequestMessage, StreamCopyHttpContent)> CreateRequestMessageAsync(HttpContext context, string destinationPrefix, HttpTransformer transformer, RequestProxyOptions requestOptions, bool isStreamingRequest, CancellationToken requestAborted) { // "http://a".Length = 8 if (destinationPrefix == null || destinationPrefix.Length < 8) { throw new ArgumentException(nameof(destinationPrefix)); } var destinationRequest = new HttpRequestMessage(); destinationRequest.Method = HttpUtilities.GetHttpMethod(context.Request.Method); var upgradeFeature = context.Features.Get <IHttpUpgradeFeature>(); var upgradeHeader = context.Request.Headers[HeaderNames.Upgrade].ToString(); var isUpgradeRequest = (upgradeFeature?.IsUpgradableRequest ?? false) // Mitigate https://github.com/microsoft/reverse-proxy/issues/255, IIS considers all requests upgradeable. && (string.Equals("WebSocket", upgradeHeader, StringComparison.OrdinalIgnoreCase) // https://github.com/microsoft/reverse-proxy/issues/467 for kubernetes APIs || upgradeHeader.StartsWith("SPDY/", StringComparison.OrdinalIgnoreCase)); // Default to HTTP/1.1 for proxying upgradeable requests. This is already the default as of .NET Core 3.1 // Otherwise request what's set in proxyOptions (e.g. default HTTP/2) and let HttpClient negotiate the protocol // based on VersionPolicy (for .NET 5 and higher). For example, downgrading to HTTP/1.1 if it cannot establish HTTP/2 with the target. // This is done without extra round-trips thanks to ALPN. We can detect a downgrade after calling HttpClient.SendAsync // (see Step 3 below). TBD how this will change when HTTP/3 is supported. destinationRequest.Version = isUpgradeRequest ? ProtocolHelper.Http11Version : (requestOptions?.Version ?? DefaultVersion); #if NET destinationRequest.VersionPolicy = isUpgradeRequest ? HttpVersionPolicy.RequestVersionOrLower : (requestOptions?.VersionPolicy ?? DefaultVersionPolicy); #endif // :: Step 2: Setup copy of request body (background) Client --► Proxy --► Destination // Note that we must do this before step (3) because step (3) may also add headers to the HttpContent that we set up here. var requestContent = SetupRequestBodyCopy(context.Request, isStreamingRequest, requestAborted); destinationRequest.Content = requestContent; // :: Step 3: Copy request headers Client --► Proxy --► Destination await transformer.TransformRequestAsync(context, destinationRequest, destinationPrefix); // Allow someone to custom build the request uri, otherwise provide a default for them. var request = context.Request; destinationRequest.RequestUri ??= RequestUtilities.MakeDestinationAddress(destinationPrefix, request.Path, request.QueryString); Log.Proxying(_logger, destinationRequest.RequestUri.AbsoluteUri); // TODO: What if they replace the HttpContent object? That would mess with our tracking and error handling. return(destinationRequest, requestContent); }
private HttpRequestMessage CreateRequestMessage(HttpContext context, string destinationAddress, IReadOnlyList <RequestParametersTransform> requestTransforms, RequestProxyOptions requestOptions) { // "http://a".Length = 8 if (destinationAddress == null || destinationAddress.Length < 8) { throw new ArgumentException(nameof(destinationAddress)); } var upgradeFeature = context.Features.Get <IHttpUpgradeFeature>(); var upgradeHeader = context.Request.Headers[HeaderNames.Upgrade].ToString(); var isUpgradeRequest = (upgradeFeature?.IsUpgradableRequest ?? false) // Mitigate https://github.com/microsoft/reverse-proxy/issues/255, IIS considers all requests upgradeable. && (string.Equals("WebSocket", upgradeHeader, StringComparison.OrdinalIgnoreCase) // https://github.com/microsoft/reverse-proxy/issues/467 for kubernetes APIs || upgradeHeader.StartsWith("SPDY/", StringComparison.OrdinalIgnoreCase)); // Default to HTTP/1.1 for proxying upgradeable requests. This is already the default as of .NET Core 3.1 // Otherwise request what's set in proxyOptions (e.g. default HTTP/2) and let HttpClient negotiate the protocol // based on VersionPolicy (for .NET 5 and higher). For example, downgrading to HTTP/1.1 if it cannot establish HTTP/2 with the target. // This is done without extra round-trips thanks to ALPN. We can detect a downgrade after calling HttpClient.SendAsync // (see Step 3 below). TBD how this will change when HTTP/3 is supported. var httpVersion = isUpgradeRequest ? ProtocolHelper.Http11Version : (requestOptions.Version ?? DefaultVersion); #if NET var httpVersionPolicy = isUpgradeRequest ? HttpVersionPolicy.RequestVersionOrLower : (requestOptions.VersionPolicy ?? DefaultVersionPolicy); #endif // TODO Perf: We could probably avoid splitting this and just append the final path and query UriHelper.FromAbsolute(destinationAddress, out var destinationScheme, out var destinationHost, out var destinationPathBase, out _, out _); // Query and Fragment are not supported here. var request = context.Request; if (requestTransforms.Count == 0) { var url = UriHelper.BuildAbsolute(destinationScheme, destinationHost, destinationPathBase, request.Path, request.QueryString); Log.Proxying(_logger, url); var uri = new Uri(url, UriKind.Absolute); return(new HttpRequestMessage(HttpUtilities.GetHttpMethod(context.Request.Method), uri) { Version = httpVersion, #if NET VersionPolicy = httpVersionPolicy, #endif }); } var transformContext = new RequestParametersTransformContext() { HttpContext = context, Version = httpVersion, Method = request.Method, Path = request.Path, Query = new QueryTransformContext(request), #if NET VersionPolicy = httpVersionPolicy, #endif }; foreach (var requestTransform in requestTransforms) { requestTransform.Apply(transformContext); } var targetUrl = UriHelper.BuildAbsolute(destinationScheme, destinationHost, destinationPathBase, transformContext.Path, transformContext.Query.QueryString); Log.Proxying(_logger, targetUrl); var targetUri = new Uri(targetUrl, UriKind.Absolute); return(new HttpRequestMessage(HttpUtilities.GetHttpMethod(transformContext.Method), targetUri) { Version = transformContext.Version, #if NET VersionPolicy = transformContext.VersionPolicy, #endif }); }