public async Task SerializeToStreamAsync_RespectsContentCancellation() { var tcs = new TaskCompletionSource <byte>(TaskCreationOptions.RunContinuationsAsynchronously); var source = new ReadDelegatingStream(new MemoryStream(), async(buffer, cancellation) => { if (buffer.Length == 0) { return(0); } Assert.False(cancellation.IsCancellationRequested); await tcs.Task; Assert.True(cancellation.IsCancellationRequested); return(0); }); using var contentCts = ActivityCancellationTokenSource.Rent(TimeSpan.FromSeconds(10), CancellationToken.None); var sut = CreateContent(source, contentCancellation: contentCts); var copyToTask = sut.CopyToWithCancellationAsync(new MemoryStream()); contentCts.Cancel(); tcs.SetResult(0); await copyToTask; }
public async Task SlowStreams_TelemetryReportsCorrectTime(bool isRequest) { var events = TestEventListener.Collect(); const int SourceSize = 3; var sourceBytes = new byte[SourceSize]; var source = new MemoryStream(sourceBytes); var destination = new MemoryStream(); var clock = new ManualClock(); var sourceWaitTime = TimeSpan.FromMilliseconds(12345); var destinationWaitTime = TimeSpan.FromMilliseconds(42); using var cts = ActivityCancellationTokenSource.Rent(TimeSpan.FromSeconds(10), CancellationToken.None); await StreamCopier.CopyAsync( isRequest, new SlowStream(source, clock, sourceWaitTime), new SlowStream(destination, clock, destinationWaitTime), SourceSize, clock, cts, cts.Token); Assert.Equal(sourceBytes, destination.ToArray()); AssertContentTransferred(events, isRequest, SourceSize, iops: SourceSize + 1, firstReadTime: sourceWaitTime, readTime: (SourceSize + 1) * sourceWaitTime, writeTime: SourceSize * destinationWaitTime); }
public async Task DestinationThrows_Reported(bool isRequest) { var events = TestEventListener.Collect(); const int SourceSize = 10; const int BytesPerRead = 3; var clock = new ManualClock(); var sourceWaitTime = TimeSpan.FromMilliseconds(12345); var destinationWaitTime = TimeSpan.FromMilliseconds(42); var source = new SlowStream(new MemoryStream(new byte[SourceSize]), clock, sourceWaitTime) { MaxBytesPerRead = BytesPerRead }; var destination = new SlowStream(new ThrowStream(), clock, destinationWaitTime); using var cts = ActivityCancellationTokenSource.Rent(TimeSpan.FromSeconds(10), CancellationToken.None); var(result, error) = await StreamCopier.CopyAsync(isRequest, source, destination, SourceSize, clock, cts, cts.Token); Assert.Equal(StreamCopyResult.OutputError, result); Assert.IsAssignableFrom <IOException>(error); AssertContentTransferred(events, isRequest, contentLength: BytesPerRead, iops: 1, firstReadTime: sourceWaitTime, readTime: sourceWaitTime, writeTime: destinationWaitTime); }
private static StreamCopyHttpContent CreateContent(HttpRequest request = null, bool autoFlushHttpClientOutgoingStream = false, IClock clock = null, ActivityCancellationTokenSource contentCancellation = null) { request ??= new DefaultHttpContext().Request; clock ??= new Clock(); contentCancellation ??= ActivityCancellationTokenSource.Rent(TimeSpan.FromSeconds(10), CancellationToken.None); return(new StreamCopyHttpContent(request, autoFlushHttpClientOutgoingStream, clock, contentCancellation)); }
private static StreamCopyHttpContent CreateContent(Stream source = null, bool autoFlushHttpClientOutgoingStream = false, IClock clock = null, ActivityCancellationTokenSource contentCancellation = null) { source ??= new MemoryStream(); clock ??= new Clock(); contentCancellation ??= ActivityCancellationTokenSource.Rent(TimeSpan.FromSeconds(10), CancellationToken.None); return(new StreamCopyHttpContent(source, autoFlushHttpClientOutgoingStream, clock, contentCancellation)); }
public void ActivityCancellationTokenSource_RespectsLinkedToken() { var linkedCts = new CancellationTokenSource(); var cts = ActivityCancellationTokenSource.Rent(TimeSpan.FromSeconds(10), linkedCts.Token); linkedCts.Cancel(); Assert.True(cts.IsCancellationRequested); }
public void ActivityCancellationTokenSource_DoesNotPoolsCanceledSources() { var cts = ActivityCancellationTokenSource.Rent(TimeSpan.FromSeconds(10), CancellationToken.None); cts.Cancel(); var cts2 = ActivityCancellationTokenSource.Rent(TimeSpan.FromSeconds(10), CancellationToken.None); Assert.NotSame(cts, cts2); }
public void ActivityCancellationTokenSource_ClearsRegistrations() { var linkedCts = new CancellationTokenSource(); var cts = ActivityCancellationTokenSource.Rent(TimeSpan.FromSeconds(10), linkedCts.Token); cts.Return(); linkedCts.Cancel(); Assert.False(cts.IsCancellationRequested); }
public async Task ActivityCancellationTokenSource_RespectsTimeout() { var cts = ActivityCancellationTokenSource.Rent(TimeSpan.FromMilliseconds(1), CancellationToken.None); for (var i = 0; i < 1000; i++) { if (cts.IsCancellationRequested) { return; } await Task.Delay(1); } Assert.True(false, "Cts was not canceled"); }
private async ValueTask <(StreamCopyResult, Exception?)> CopyResponseBodyAsync(HttpContent destinationResponseContent, Stream clientResponseStream, ActivityCancellationTokenSource activityCancellationSource) { // SocketHttpHandler and similar transports always provide an HttpContent object, even if it's empty. // In 3.1 this is only likely to return null in tests. // As of 5.0 HttpResponse.Content never returns null. // https://github.com/dotnet/runtime/blame/8fc68f626a11d646109a758cb0fc70a0aa7826f1/src/libraries/System.Net.Http/src/System/Net/Http/HttpResponseMessage.cs#L46 if (destinationResponseContent != null) { using var destinationResponseStream = await destinationResponseContent.ReadAsStreamAsync(); // The response content-length is enforced by the server. return(await StreamCopier.CopyAsync(isRequest : false, destinationResponseStream, clientResponseStream, StreamCopier.UnknownLength, _clock, activityCancellationSource, activityCancellationSource.Token)); } return(StreamCopyResult.Success, null); }
public async Task CopyAsync_Works(bool isRequest) { var events = TestEventListener.Collect(); const int SourceSize = (128 * 1024) - 3; var sourceBytes = Enumerable.Range(0, SourceSize).Select(i => (byte)(i % 256)).ToArray(); var source = new MemoryStream(sourceBytes); var destination = new MemoryStream(); using var cts = ActivityCancellationTokenSource.Rent(TimeSpan.FromSeconds(10), CancellationToken.None); await StreamCopier.CopyAsync(isRequest, source, destination, SourceSize, new Clock(), cts, cts.Token); Assert.False(cts.Token.IsCancellationRequested); Assert.Equal(sourceBytes, destination.ToArray()); AssertContentTransferred(events, isRequest, SourceSize); }
public void ActivityCancellationTokenSource_PoolsSources() { // This test can run in parallel with others making use of ActivityCancellationTokenSource // A different thread could have already added/removed a source from the queue for (var i = 0; i < 1000; i++) { var cts = ActivityCancellationTokenSource.Rent(TimeSpan.FromSeconds(10), CancellationToken.None); cts.Return(); var cts2 = ActivityCancellationTokenSource.Rent(TimeSpan.FromSeconds(10), CancellationToken.None); cts2.Return(); if (ReferenceEquals(cts, cts2)) { return; } } Assert.True(false, "CancellationTokenSources were not pooled"); }
public async Task Cancelled_Reported(bool isRequest) { var events = TestEventListener.Collect(); var source = new MemoryStream(new byte[10]); var destination = new MemoryStream(); using var cts = ActivityCancellationTokenSource.Rent(TimeSpan.FromSeconds(10), CancellationToken.None); cts.Cancel(); var(result, error) = await StreamCopier.CopyAsync(isRequest, source, destination, StreamCopier.UnknownLength, new ManualClock(), cts, cts.Token); Assert.Equal(StreamCopyResult.Canceled, result); Assert.IsAssignableFrom <OperationCanceledException>(error); AssertContentTransferred(events, isRequest, contentLength: 0, iops: 1, firstReadTime: TimeSpan.Zero, readTime: TimeSpan.Zero, writeTime: TimeSpan.Zero); }
public async Task SourceThrows_Reported(bool isRequest) { var events = TestEventListener.Collect(); var clock = new ManualClock(); var sourceWaitTime = TimeSpan.FromMilliseconds(12345); var source = new SlowStream(new ThrowStream(), clock, sourceWaitTime); var destination = new MemoryStream(); using var cts = ActivityCancellationTokenSource.Rent(TimeSpan.FromSeconds(10), CancellationToken.None); var(result, error) = await StreamCopier.CopyAsync(isRequest, source, destination, StreamCopier.UnknownLength, clock, cts, cts.Token); Assert.Equal(StreamCopyResult.InputError, result); Assert.IsAssignableFrom <IOException>(error); AssertContentTransferred(events, isRequest, contentLength: 0, iops: 1, firstReadTime: sourceWaitTime, readTime: sourceWaitTime, writeTime: TimeSpan.Zero); }
/// <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 ValueTask <ForwarderError> SendAsync( HttpContext context, string destinationPrefix, HttpMessageInvoker httpClient, ForwarderRequestConfig requestConfig, HttpTransformer transformer) { _ = context ?? throw new ArgumentNullException(nameof(context)); _ = destinationPrefix ?? throw new ArgumentNullException(nameof(destinationPrefix)); _ = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); _ = requestConfig ?? throw new ArgumentNullException(nameof(requestConfig)); _ = transformer ?? throw new ArgumentNullException(nameof(transformer)); // 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)); } ForwarderTelemetry.Log.ForwarderStart(destinationPrefix); var activityCancellationSource = ActivityCancellationTokenSource.Rent(requestConfig?.ActivityTimeout ?? DefaultTimeout, context.RequestAborted); try { 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, requestConfig, isStreamingRequest, activityCancellationSource); // :: Step 4: Send the outgoing request using HttpClient HttpResponseMessage destinationResponse; try { ForwarderTelemetry.Log.ForwarderStage(ForwarderStage.SendAsyncStart); destinationResponse = await httpClient.SendAsync(destinationRequest, activityCancellationSource.Token); ForwarderTelemetry.Log.ForwarderStage(ForwarderStage.SendAsyncStop); // Reset the timeout since we received the response headers. activityCancellationSource.ResetTimeout(); } catch (Exception requestException) { return(await HandleRequestFailureAsync(context, requestContent, requestException, transformer, activityCancellationSource)); } // 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); } try { // :: Step 5: Copy response status line Client ◄-- Proxy ◄-- Destination // :: Step 6: Copy response headers Client ◄-- Proxy ◄-- Destination var copyBody = await CopyResponseStatusAndHeadersAsync(destinationResponse, context, transformer); if (!copyBody) { // The transforms callback decided that the response body should be discarded. destinationResponse.Dispose(); if (requestContent is not null && requestContent.InProgress) { activityCancellationSource.Cancel(); await requestContent.ConsumptionTask; } return(ForwarderError.None); } } catch (Exception ex) { destinationResponse.Dispose(); if (requestContent is not null && requestContent.InProgress) { activityCancellationSource.Cancel(); await requestContent.ConsumptionTask; } ReportProxyError(context, ForwarderError.ResponseHeaders, ex); // Clear the response since status code, reason and some headers might have already been copied and we want clean 502 response. context.Response.Clear(); context.Response.StatusCode = StatusCodes.Status502BadGateway; return(ForwarderError.ResponseHeaders); } // :: 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) { Debug.Assert(requestContent?.Started != true); return(await HandleUpgradedResponse(context, destinationResponse, activityCancellationSource)); } // 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, activityCancellationSource); if (responseBodyCopyResult != StreamCopyResult.Success) { return(await HandleResponseBodyErrorAsync(context, requestContent, responseBodyCopyResult, responseBodyException !, activityCancellationSource)); } // :: 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 // NOTE: It is possible for the request body to NOT be copied even when there was an incoming requet body, // e.g. when the request includes header `Expect: 100-continue` and the destination produced a non-1xx response. // We must only wait for the request body to complete if it actually started, // otherwise we run the risk of waiting indefinitely for a task that will never complete. if (requestContent is not null && requestContent.Started) { 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 => ForwarderError.RequestBodyClient, StreamCopyResult.OutputError => ForwarderError.RequestBodyDestination, StreamCopyResult.Canceled => ForwarderError.RequestBodyCanceled, _ => throw new NotImplementedException(requestBodyCopyResult.ToString()) }; ReportProxyError(context, error, requestBodyException !); return(error); } } } finally { activityCancellationSource.Return(); ForwarderTelemetry.Log.ForwarderStop(context.Response.StatusCode); } return(ForwarderError.None); }
public async Task LongContentTransfer_TelemetryReportsTransferringEvents(bool isRequest) { var events = TestEventListener.Collect(); const int SourceSize = 123; var sourceBytes = new byte[SourceSize]; var source = new MemoryStream(sourceBytes); var destination = new MemoryStream(); var clock = new ManualClock(); var sourceWaitTime = TimeSpan.FromMilliseconds(789); // Every second read triggers ContentTransferring var destinationWaitTime = TimeSpan.FromMilliseconds(42); const int BytesPerRead = 3; var contentReads = (int)Math.Ceiling((double)SourceSize / BytesPerRead); using var cts = ActivityCancellationTokenSource.Rent(TimeSpan.FromSeconds(10), CancellationToken.None); await StreamCopier.CopyAsync( isRequest, new SlowStream(source, clock, sourceWaitTime) { MaxBytesPerRead = BytesPerRead }, new SlowStream(destination, clock, destinationWaitTime), SourceSize, clock, cts, cts.Token); Assert.Equal(sourceBytes, destination.ToArray()); AssertContentTransferred(events, isRequest, SourceSize, iops: contentReads + 1, firstReadTime: sourceWaitTime, readTime: (contentReads + 1) * sourceWaitTime, writeTime: contentReads * destinationWaitTime); var transferringEvents = events.Where(e => e.EventName == "ContentTransferring").ToArray(); Assert.Equal(contentReads / 2, transferringEvents.Length); for (var i = 0; i < transferringEvents.Length; i++) { var payload = transferringEvents[i].Payload; Assert.Equal(5, payload.Count); Assert.Equal(isRequest, (bool)payload[0]); var contentLength = (long)payload[1]; var iops = (long)payload[2]; Assert.Equal((i + 1) * 2, iops); if (contentLength % BytesPerRead == 0) { Assert.Equal(iops * BytesPerRead, contentLength); } else { Assert.Equal(transferringEvents.Length - 1, i); Assert.Equal(SourceSize, contentLength); } var readTime = new TimeSpan((long)payload[3]); Assert.Equal(iops * sourceWaitTime, readTime, new ApproximateTimeSpanComparer()); var writeTime = new TimeSpan((long)payload[4]); Assert.Equal(iops * destinationWaitTime, writeTime, new ApproximateTimeSpanComparer()); } }
private async ValueTask <ForwarderError> HandleUpgradedResponse(HttpContext context, HttpResponseMessage destinationResponse, ActivityCancellationTokenSource activityCancellationSource) { ForwarderTelemetry.Log.ForwarderStage(ForwarderStage.ResponseUpgrade); // SocketHttpHandler and similar transports always provide an HttpContent object, even if it's empty. // Note as of 5.0 HttpResponse.Content never returns null. // https://github.com/dotnet/runtime/blame/8fc68f626a11d646109a758cb0fc70a0aa7826f1/src/libraries/System.Net.Http/src/System/Net/Http/HttpResponseMessage.cs#L46 if (destinationResponse.Content == null) { throw new InvalidOperationException("A response content is required for upgrades."); } // :: Step 7-A-1: Upgrade the client channel. This will also send response headers. var upgradeFeature = context.Features.Get <IHttpUpgradeFeature>(); if (upgradeFeature == null) { var ex = new InvalidOperationException("Invalid 101 response when upgrades aren't supported."); destinationResponse.Dispose(); context.Response.StatusCode = StatusCodes.Status502BadGateway; ReportProxyError(context, ForwarderError.UpgradeResponseDestination, ex); return(ForwarderError.UpgradeResponseDestination); } RestoreUpgradeHeaders(context, destinationResponse); Stream upgradeResult; try { upgradeResult = await upgradeFeature.UpgradeAsync(); } catch (Exception ex) { destinationResponse.Dispose(); ReportProxyError(context, ForwarderError.UpgradeResponseClient, ex); return(ForwarderError.UpgradeResponseClient); } using var clientStream = upgradeResult; // :: Step 7-A-2: Copy duplex streams using var destinationStream = await destinationResponse.Content.ReadAsStreamAsync(); var requestTask = StreamCopier.CopyAsync(isRequest: true, clientStream, destinationStream, StreamCopier.UnknownLength, _clock, activityCancellationSource, activityCancellationSource.Token).AsTask(); var responseTask = StreamCopier.CopyAsync(isRequest: false, destinationStream, clientStream, StreamCopier.UnknownLength, _clock, activityCancellationSource, activityCancellationSource.Token).AsTask(); // Make sure we report the first failure. var firstTask = await Task.WhenAny(requestTask, responseTask); var requestFinishedFirst = firstTask == requestTask; var secondTask = requestFinishedFirst ? responseTask : requestTask; ForwarderError error; var(firstResult, firstException) = await firstTask; if (firstResult != StreamCopyResult.Success) { error = ReportResult(context, requestFinishedFirst, firstResult, firstException); // Cancel the other direction activityCancellationSource.Cancel(); // Wait for this to finish before exiting so the resources get cleaned up properly. await secondTask; } else { var(secondResult, secondException) = await secondTask; if (secondResult != StreamCopyResult.Success) { error = ReportResult(context, !requestFinishedFirst, secondResult, secondException !); } else { error = ForwarderError.None; } } return(error); ForwarderError ReportResult(HttpContext context, bool reqeuest, StreamCopyResult result, Exception exception) { var error = result switch { StreamCopyResult.InputError => reqeuest ? ForwarderError.UpgradeRequestClient : ForwarderError.UpgradeResponseDestination, StreamCopyResult.OutputError => reqeuest ? ForwarderError.UpgradeRequestDestination : ForwarderError.UpgradeResponseClient, StreamCopyResult.Canceled => reqeuest ? ForwarderError.UpgradeRequestCanceled : ForwarderError.UpgradeResponseCanceled, _ => throw new NotImplementedException(result.ToString()), }; ReportProxyError(context, error, exception); return(error); } }
private StreamCopyHttpContent?SetupRequestBodyCopy(HttpRequest request, bool isStreamingRequest, ActivityCancellationTokenSource activityToken) { // If we generate an HttpContent without a Content-Length then for HTTP/1.1 HttpClient will add a Transfer-Encoding: chunked header // even if it's a GET request. Some servers reject requests containing a Transfer-Encoding header if they're not expecting a body. // Try to be as specific as possible about the client's intent to send a body. The one thing we don't want to do is to start // reading the body early because that has side-effects like 100-continue. var hasBody = true; var contentLength = request.Headers.ContentLength; var method = request.Method; #if NET var canHaveBodyFeature = request.HttpContext.Features.Get <IHttpRequestBodyDetectionFeature>(); if (canHaveBodyFeature != null) { // 5.0 servers provide a definitive answer for us. hasBody = canHaveBodyFeature.CanHaveBody; } else #endif // https://tools.ietf.org/html/rfc7230#section-3.3.3 // All HTTP/1.1 requests should have Transfer-Encoding or Content-Length. // Http.Sys/IIS will even add a Transfer-Encoding header to HTTP/2 requests with bodies for back-compat. // HTTP/1.0 Connection: close bodies are only allowed on responses, not requests. // https://tools.ietf.org/html/rfc1945#section-7.2.2 // // Transfer-Encoding overrides Content-Length per spec if (request.Headers.TryGetValue(HeaderNames.TransferEncoding, out var transferEncoding) && transferEncoding.Count == 1 && string.Equals("chunked", transferEncoding.ToString(), StringComparison.OrdinalIgnoreCase)) { hasBody = true; } else if (contentLength.HasValue) { hasBody = contentLength > 0; } // Kestrel HTTP/2: There are no required headers that indicate if there is a request body so we need to sniff other fields. else if (!ProtocolHelper.IsHttp2OrGreater(request.Protocol)) { hasBody = false; } // https://tools.ietf.org/html/rfc7231#section-4.3.1 // A payload within a GET/HEAD/DELETE/CONNECT request message has no defined semantics; sending a payload body on a // GET/HEAD/DELETE/CONNECT request might cause some existing implementations to reject the request. // https://tools.ietf.org/html/rfc7231#section-4.3.8 // A client MUST NOT send a message body in a TRACE request. else if (HttpMethods.IsGet(method) || HttpMethods.IsHead(method) || HttpMethods.IsDelete(method) || HttpMethods.IsConnect(method) || HttpMethods.IsTrace(method)) { hasBody = false; } // else hasBody defaults to true if (hasBody) { if (isStreamingRequest) { DisableMinRequestBodyDataRateAndMaxRequestBodySize(request.HttpContext); } // Note on `autoFlushHttpClientOutgoingStream: isStreamingRequest`: // The.NET Core HttpClient stack keeps its own buffers on top of the underlying outgoing connection socket. // We flush those buffers down to the socket on every write when this is set, // but it does NOT result in calls to flush on the underlying socket. // This is necessary because we proxy http2 transparently, // and we are deliberately unaware of packet structure used e.g. in gRPC duplex channels. // Because the sockets aren't flushed, the perf impact of this choice is expected to be small. // Future: It may be wise to set this to true for *all* http2 incoming requests, // but for now, out of an abundance of caution, we only do it for requests that look like gRPC. return(new StreamCopyHttpContent( source: request.Body, autoFlushHttpClientOutgoingStream: isStreamingRequest, clock: _clock, activityToken)); } return(null); }
private async ValueTask <(HttpRequestMessage, StreamCopyHttpContent?)> CreateRequestMessageAsync(HttpContext context, string destinationPrefix, HttpTransformer transformer, ForwarderRequestConfig?requestConfig, bool isStreamingRequest, ActivityCancellationTokenSource activityToken) { // "http://a".Length = 8 if (destinationPrefix == null || destinationPrefix.Length < 8) { throw new ArgumentException("Invalid destination prefix.", nameof(destinationPrefix)); } var destinationRequest = new HttpRequestMessage(); destinationRequest.Method = RequestUtilities.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 : (requestConfig?.Version ?? DefaultVersion); #if NET destinationRequest.VersionPolicy = isUpgradeRequest ? HttpVersionPolicy.RequestVersionOrLower : (requestConfig?.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, activityToken); destinationRequest.Content = requestContent; // :: Step 3: Copy request headers Client --► Proxy --► Destination await transformer.TransformRequestAsync(context, destinationRequest, destinationPrefix); if (isUpgradeRequest) { RestoreUpgradeHeaders(context, destinationRequest); } // 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, isStreamingRequest); if (requestConfig?.AllowResponseBuffering != true) { context.Features.Get <IHttpResponseBodyFeature>()?.DisableBuffering(); } // TODO: What if they replace the HttpContent object? That would mess with our tracking and error handling. return(destinationRequest, requestContent); }
public static ValueTask <(StreamCopyResult, Exception?)> CopyAsync(bool isRequest, Stream input, Stream output, long promisedContentLength, IClock clock, ActivityCancellationTokenSource activityToken, CancellationToken cancellation) { Debug.Assert(input is not null); Debug.Assert(output is not null); Debug.Assert(clock is not null); Debug.Assert(activityToken is not null); // Avoid capturing 'isRequest' and 'clock' in the state machine when telemetry is disabled var telemetry = ForwarderTelemetry.Log.IsEnabled(EventLevel.Informational, EventKeywords.All) ? new StreamCopierTelemetry(isRequest, clock) : null; return(CopyAsync(input, output, promisedContentLength, telemetry, activityToken, cancellation)); }
private static async ValueTask <(StreamCopyResult, Exception?)> CopyAsync(Stream input, Stream output, long promisedContentLength, StreamCopierTelemetry?telemetry, ActivityCancellationTokenSource activityToken, CancellationToken cancellation) { var buffer = ArrayPool <byte> .Shared.Rent(DefaultBufferSize); var read = 0; long contentLength = 0; try { while (true) { read = 0; // Issue a zero-byte read to the input stream to defer buffer allocation until data is available. // Note that if the underlying stream does not supporting blocking on zero byte reads, then this will // complete immediately and won't save any memory, but will still function correctly. var zeroByteReadTask = input.ReadAsync(Memory <byte> .Empty, cancellation); if (zeroByteReadTask.IsCompletedSuccessfully) { // Consume the ValueTask's result in case it is backed by an IValueTaskSource _ = zeroByteReadTask.Result; } else { // Take care not to return the same buffer to the pool twice in case zeroByteReadTask throws var bufferToReturn = buffer; buffer = null; ArrayPool <byte> .Shared.Return(bufferToReturn); await zeroByteReadTask; buffer = ArrayPool <byte> .Shared.Rent(DefaultBufferSize); } read = await input.ReadAsync(buffer.AsMemory(), cancellation); contentLength += read; // Normally this is enforced by the server, but it could get out of sync if something in the proxy modified the body. if (promisedContentLength != UnknownLength && contentLength > promisedContentLength) { return(StreamCopyResult.InputError, new InvalidOperationException("More bytes received than the specified Content-Length.")); } telemetry?.AfterRead(contentLength); // Success, reset the activity monitor. activityToken.ResetTimeout(); // End of the source stream. if (read == 0) { if (promisedContentLength == UnknownLength || contentLength == promisedContentLength) { return(StreamCopyResult.Success, null); } else { // This can happen if something in the proxy consumes or modifies part or all of the request body before proxying. return(StreamCopyResult.InputError, new InvalidOperationException($"Sent {contentLength} request content bytes, but Content-Length promised {promisedContentLength}.")); } } await output.WriteAsync(buffer.AsMemory(0, read), cancellation); telemetry?.AfterWrite(); // Success, reset the activity monitor. activityToken.ResetTimeout(); } } catch (Exception ex) { if (read == 0) { telemetry?.AfterRead(contentLength); } else { telemetry?.AfterWrite(); } var result = ex is OperationCanceledException ? StreamCopyResult.Canceled : (read == 0 ? StreamCopyResult.InputError : StreamCopyResult.OutputError); return(result, ex); } finally { if (buffer is not null) { ArrayPool <byte> .Shared.Return(buffer); } telemetry?.Stop(); } }