Skip to content

Commit

Permalink
HTTP Version Policy (#479)
Browse files Browse the repository at this point in the history
  • Loading branch information
ManickaP authored Oct 20, 2020
1 parent e9f7bb8 commit 99b3d78
Show file tree
Hide file tree
Showing 5 changed files with 166 additions and 15 deletions.
33 changes: 27 additions & 6 deletions src/ReverseProxy/Service/Proxy/HttpProxy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ public async Task ProxyAsync(
// Mitigate https://github.com/microsoft/reverse-proxy/issues/255, IIS considers all requests upgradeable.
&& string.Equals("WebSocket", context.Request.Headers[HeaderNames.Upgrade], StringComparison.OrdinalIgnoreCase);

var destinationRequest = CreateRequestMessage(context, destinationPrefix, isUpgradeRequest, proxyOptions.Transforms.RequestTransforms);
var destinationRequest = CreateRequestMessage(context, destinationPrefix, isUpgradeRequest, proxyOptions);

var isClientHttp2 = ProtocolHelper.IsHttp2(context.Request.Protocol);

Expand Down Expand Up @@ -231,7 +231,7 @@ public async Task ProxyAsync(
}
}

private HttpRequestMessage CreateRequestMessage(HttpContext context, string destinationAddress, bool isUpgradeRequest, IReadOnlyList<RequestParametersTransform> transforms)
private HttpRequestMessage CreateRequestMessage(HttpContext context, string destinationAddress, bool isUpgradeRequest, RequestProxyOptions proxyOptions)
{
// "http://a".Length = 8
if (destinationAddress == null || destinationAddress.Length < 8)
Expand All @@ -240,21 +240,32 @@ private HttpRequestMessage CreateRequestMessage(HttpContext context, string dest
}

// Default to HTTP/1.1 for proxying upgradeable requests. This is already the default as of .NET Core 3.1
// Otherwise request HTTP/2 and let HttpClient fallback to HTTP/1.1 if it cannot establish HTTP/2 with the target.
// 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 : ProtocolHelper.Http2Version;
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 };
return new HttpRequestMessage(HttpUtilities.GetHttpMethod(context.Request.Method), uri)
{
Version = httpVersion,
#if NET
VersionPolicy = httpVersionPolicy,
#endif
};
}

var transformContext = new RequestParametersTransformContext()
Expand All @@ -264,6 +275,10 @@ private HttpRequestMessage CreateRequestMessage(HttpContext context, string dest
Method = request.Method,
Path = request.Path,
Query = new QueryTransformContext(request),
#if NET
VersionPolicy = httpVersionPolicy,
#endif

};
foreach (var transform in transforms)
{
Expand All @@ -273,7 +288,13 @@ private HttpRequestMessage CreateRequestMessage(HttpContext context, string dest
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 };
return new HttpRequestMessage(HttpUtilities.GetHttpMethod(transformContext.Method), targetUri)
{
Version = transformContext.Version,
#if NET
VersionPolicy = transformContext.VersionPolicy,
#endif
};
}

private StreamCopyHttpContent SetupRequestBodyCopy(HttpRequest request, HttpRequestMessage destinationRequest,
Expand Down
20 changes: 16 additions & 4 deletions src/ReverseProxy/Service/Proxy/RequestProxyOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
// Licensed under the MIT License.

using System;
using System.Net;
using System.Net.Http;
using Microsoft.ReverseProxy.Service.RuntimeModel.Transforms;

namespace Microsoft.ReverseProxy.Service.Proxy
Expand All @@ -22,9 +24,19 @@ public class RequestProxyOptions
/// </summary>
public TimeSpan RequestTimeout { get; set; } = TimeSpan.FromSeconds(100);

// Future:
// ResponseBodyTimeout - The time allowed to receive the full response body. Default to infinite. Not applied to Upgraded requests or gRPC streams.
// HttpVersion - Default to HTTP/2?
// HttpVersionPolicy - Default to OrLower?
/// <summary>
/// Preferred version of the outgoing request.
/// The default is HTTP/2.0.
/// </summary>
public Version Version { get; set; } = HttpVersion.Version20;

#if NET
/// <summary>
/// The policy applied to version selection, e.g. whether to prefer downgrades, upgrades or request an exact version.
/// The default is `RequestVersionOrLower`.
/// </summary>
public HttpVersionPolicy VersionPolicy { get; set; } = HttpVersionPolicy.RequestVersionOrLower;
#endif
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Licensed under the MIT License.

using System;
using System.Net.Http;
using Microsoft.AspNetCore.Http;

namespace Microsoft.ReverseProxy.Service.RuntimeModel.Transforms
Expand All @@ -21,6 +22,13 @@ public class RequestParametersTransformContext
/// </summary>
public Version Version { get; set; }

#if NET
/// <summary>
/// The HTTP version policy to use for the proxy request.
/// </summary>
public HttpVersionPolicy VersionPolicy { get; set; }
#endif

/// <summary>
/// The HTTP method to use for the proxy request.
/// </summary>
Expand Down
6 changes: 1 addition & 5 deletions test/ReverseProxy.FunctionalTests/HttpProxyCookieTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ public override async Task ProcessHttpRequest(Uri proxyHostUri)
}
}

#if NET5_0
#if NET
public class HttpProxyCookieTests_Http2 : HttpProxyCookieTests
{
public override HttpProtocols HttpProtocol => HttpProtocols.Http2;
Expand All @@ -205,9 +205,5 @@ public override async Task ProcessHttpRequest(Uri proxyHostUri)
response.EnsureSuccessStatusCode();
}
}
#elif NETCOREAPP3_1
// Do not test HTTP/2 on .NET Core 3.1
#else
#error A target framework was added to the project and needs to be added to this condition.
#endif
}
114 changes: 114 additions & 0 deletions test/ReverseProxy.Tests/Service/Proxy/HttpProxyTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -620,6 +620,105 @@ public async Task ProxyAsync_RequestWithCookieHeaders()
Assert.Equal(StatusCodes.Status200OK, httpContext.Response.StatusCode);
}

[Fact]
public async Task ProxyAsync_OptionsWithVersion()
{
// Use any non-default value
var version = new Version(5, 5);
#if NET
var versionPolicy = HttpVersionPolicy.RequestVersionExact;
#endif

var httpContext = new DefaultHttpContext();
httpContext.Request.Method = "GET";

var destinationPrefix = "https://localhost/";
var sut = CreateProxy();
var client = MockHttpHandler.CreateClient(
(HttpRequestMessage request, CancellationToken cancellationToken) =>
{
Assert.Equal(version, request.Version);
#if NET
Assert.Equal(versionPolicy, request.VersionPolicy);
#endif
Assert.Equal("GET", request.Method.Method, StringComparer.OrdinalIgnoreCase);
Assert.Null(request.Content);
var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(Array.Empty<byte>()) };
return Task.FromResult(response);
});

var options = new RequestProxyOptions()
{
Version = version,
#if NET
VersionPolicy = versionPolicy,
#endif
};
await sut.ProxyAsync(httpContext, destinationPrefix, client, options);

Assert.Null(httpContext.Features.Get<IProxyErrorFeature>());
Assert.Equal(StatusCodes.Status200OK, httpContext.Response.StatusCode);
}

[Fact]
public async Task ProxyAsync_OptionsWithVersion_Transformed()
{
// Use any non-default value
var version = new Version(5, 5);
var transformedVersion = new Version(6, 6);
#if NET
var versionPolicy = HttpVersionPolicy.RequestVersionExact;
var transformedVersionPolicy = HttpVersionPolicy.RequestVersionOrHigher;
#endif

var httpContext = new DefaultHttpContext();
httpContext.Request.Method = "GET";

var destinationPrefix = "https://localhost/";
var sut = CreateProxy();
var client = MockHttpHandler.CreateClient(
(HttpRequestMessage request, CancellationToken cancellationToken) =>
{
Assert.Equal(transformedVersion, request.Version);
#if NET
Assert.Equal(transformedVersionPolicy, request.VersionPolicy);
#endif
Assert.Equal("GET", request.Method.Method, StringComparer.OrdinalIgnoreCase);
Assert.Null(request.Content);
var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(Array.Empty<byte>()) };
return Task.FromResult(response);
});

var transforms = new Transforms(copyRequestHeaders: false,
requestTransforms: new []{ new TestRequestParametersTransform(context =>
{
Assert.Equal(version, context.Version);
context.Version = transformedVersion;
#if NET
Assert.Equal(versionPolicy, context.VersionPolicy);
context.VersionPolicy = transformedVersionPolicy;
#endif
})},
requestHeaderTransforms: new Dictionary<string, RequestHeaderTransform>(StringComparer.OrdinalIgnoreCase),
responseHeaderTransforms: new Dictionary<string, ResponseHeaderTransform>(StringComparer.OrdinalIgnoreCase),
responseTrailerTransforms: new Dictionary<string, ResponseHeaderTransform>(StringComparer.OrdinalIgnoreCase));

var options = new RequestProxyOptions()
{
Version = version,
Transforms = transforms,
#if NET
VersionPolicy = versionPolicy,
#endif
};
await sut.ProxyAsync(httpContext, destinationPrefix, client, options);

Assert.Null(httpContext.Features.Get<IProxyErrorFeature>());
Assert.Equal(StatusCodes.Status200OK, httpContext.Response.StatusCode);
}

[Fact]
public async Task ProxyAsync_UnableToConnect_Returns502()
{
Expand Down Expand Up @@ -1718,5 +1817,20 @@ public override Task WriteAsync(byte[] buffer, int offset, int count, Cancellati
throw new NotImplementedException();
}
}

private class TestRequestParametersTransform : RequestParametersTransform
{
private Action<RequestParametersTransformContext> _transformation;

public TestRequestParametersTransform(Action<RequestParametersTransformContext> transformation)
{
_transformation = transformation;
}

public override void Apply(RequestParametersTransformContext context)
{
_transformation(context);
}
}
}
}

0 comments on commit 99b3d78

Please sign in to comment.