Skip to content

Commit 5d157b9

Browse files
authored
Merge pull request #14 from StackExchange/http-ver
Allow control over the protocol version
2 parents 6c1ec26 + 9bdaa11 commit 5d157b9

11 files changed

+238
-7
lines changed

StackExchange.Utils.sln

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11

22
Microsoft Visual Studio Solution File, Format Version 12.00
3-
# Visual Studio 15
4-
VisualStudioVersion = 15.0.27705.2000
3+
# Visual Studio Version 16
4+
VisualStudioVersion = 16.0.30223.230
55
MinimumVisualStudioVersion = 10.0.40219.1
66
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{F0CFAC4D-516B-45DC-8F66-D58E3B1C04E1}"
77
ProjectSection(SolutionItems) = preProject

appveyor.yml

+1-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
image: Visual Studio 2017
1+
image: Visual Studio 2019
22

33
init:
44
- git config --global core.autocrlf input
@@ -17,7 +17,6 @@ nuget:
1717
disable_publish_on_pr: true
1818

1919
build_script:
20-
- ps: .\build\dotnet-install.ps1 -Version 2.1.300
2120
- ps: .\build.ps1 -PullRequestNumber "$env:APPVEYOR_PULL_REQUEST_NUMBER" -CreatePackages $true
2221

2322
test: off

global.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"sdk": {
3-
"version": "2.2.100",
3+
"version": "3.1.201",
44
"rollForward": "latestMajor",
55
"allowPrerelease": false
66
}

src/StackExchange.Utils.Http/DefaultHttpClientPool.cs

+5
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@ private HttpClient CreateHttpClient(HttpClientCacheKey options)
3434
{
3535
UseCookies = false
3636
};
37+
var serverCertificateCustomValidationCallback = Settings?.ServerCertificateCustomValidationCallback;
38+
if (serverCertificateCustomValidationCallback is object)
39+
{
40+
handler.ServerCertificateCustomValidationCallback = serverCertificateCustomValidationCallback;
41+
}
3742
if (options.Proxy != null)
3843
{
3944
handler.UseProxy = true;

src/StackExchange.Utils.Http/Extensions.Modifier.cs

+9
Original file line numberDiff line numberDiff line change
@@ -192,5 +192,14 @@ public static IRequestBuilder AddHeaders(this IRequestBuilder builder, IDictiona
192192
}
193193
return builder;
194194
}
195+
196+
/// <summary>
197+
/// Specifies the HTTP version to use for this request
198+
/// </summary>
199+
public static IRequestBuilder WithProtocolVersion(this IRequestBuilder builder, Version version)
200+
{
201+
builder.Message.Version = version;
202+
return builder;
203+
}
195204
}
196205
}

src/StackExchange.Utils.Http/Http.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ internal static async Task<HttpCallResponse<T>> SendAsync<T>(IRequestBuilder<T>
7575
}
7676
}
7777
}
78-
catch (TaskCanceledException ex)
78+
catch (OperationCanceledException ex)
7979
{
8080
exception = cancellationToken.IsCancellationRequested
8181
? new HttpClientException("HttpClient request cancelled by token request.", builder.Inner.Message.RequestUri, ex)

src/StackExchange.Utils.Http/HttpSettings.cs

+17
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
using System;
22
using System.Net;
33
using System.Net.Http;
4+
using System.Net.Security;
5+
using System.Security.Cryptography.X509Certificates;
46

57
namespace StackExchange.Utils
68
{
@@ -67,6 +69,11 @@ public class HttpSettings
6769
/// </summary>
6870
public Func<IWebProxy> DefaultProxyFactory { get; set; } = null;
6971

72+
/// <summary>
73+
/// Gets or sets a server certificate validator to use.
74+
/// </summary>
75+
public Func<HttpRequestMessage, X509Certificate2, X509Chain, SslPolicyErrors, bool> ServerCertificateCustomValidationCallback { get; set; }
76+
7077
internal void OnBeforeSend(object sender, IRequestBuilder builder) => BeforeSend?.Invoke(sender, builder);
7178
internal void OnException(object sender, HttpExceptionArgs args) => Exception?.Invoke(sender, args);
7279

@@ -78,5 +85,15 @@ public HttpSettings()
7885
// Default pool by default
7986
ClientPool = new DefaultHttpClientPool(this);
8087
}
88+
89+
private const string Switch_AllowUnencryptedHttp2 = "System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport";
90+
/// <summary>
91+
/// Allows <see cref="HttpClient"/> to use HTTP/2 without TLS; this is an application-wide setting
92+
/// </summary>
93+
public static bool GlobalAllowUnencryptedHttp2
94+
{
95+
get => AppContext.TryGetSwitch(Switch_AllowUnencryptedHttp2, out var enabled) && enabled;
96+
set => AppContext.SetSwitch(Switch_AllowUnencryptedHttp2, value);
97+
}
8198
}
8299
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
#if KESTREL
2+
using System;
3+
using System.Linq;
4+
using System.Net;
5+
using System.Net.Http;
6+
using System.Threading.Tasks;
7+
using Microsoft.AspNetCore.Builder;
8+
using Microsoft.AspNetCore.Hosting;
9+
using Microsoft.AspNetCore.Http;
10+
using Microsoft.AspNetCore.Server.Kestrel.Core;
11+
using Xunit;
12+
using Xunit.Abstractions;
13+
14+
namespace StackExchange.Utils.Tests
15+
{
16+
public class Http2Tests : IClassFixture<Http2Tests.Http2Server>
17+
{
18+
private readonly Http2Server _server;
19+
private readonly ITestOutputHelper _log;
20+
private void Log(string message) => _log.WriteLine(message);
21+
public Http2Tests(ITestOutputHelper log, Http2Server server)
22+
{
23+
_server = server ?? throw new ArgumentNullException(nameof(server));
24+
_log = log ?? throw new ArgumentNullException(nameof(log));
25+
}
26+
27+
[Theory]
28+
// non-TLS http1: should work (returning 1.1)
29+
[InlineData(HttpProtocols.Http1, false, false, null, "1.1", "HTTP/1.1")]
30+
[InlineData(HttpProtocols.Http1, false, false, "1.1", "1.1", "HTTP/1.1")]
31+
[InlineData(HttpProtocols.Http1, false, false, "2.0", "1.1", "HTTP/1.1")]
32+
33+
// non-TLS http2 without the global override: should always fail
34+
[InlineData(HttpProtocols.Http2, false, false, null, "1.1", "HTTP/1.1", true)]
35+
[InlineData(HttpProtocols.Http2, false, false, "1.1", "1.1", "HTTP/1.1", true)]
36+
[InlineData(HttpProtocols.Http2, false, false, "2.0", "2.0", "HTTP/2", true)]
37+
38+
// non-TLS http2 with the global override: should work if we specify http2
39+
[InlineData(HttpProtocols.Http2, false, true, null, "1.1", "HTTP/1.1", true)]
40+
[InlineData(HttpProtocols.Http2, false, true, "1.1", "1.1", "HTTP/1.1", true)]
41+
[InlineData(HttpProtocols.Http2, false, true, "2.0", "2.0", "HTTP/2")]
42+
43+
// non-TLS http* without the global override: should work, server prefers 1.1
44+
[InlineData(HttpProtocols.Http1AndHttp2, false, false, null, "1.1", "HTTP/1.1")]
45+
[InlineData(HttpProtocols.Http1AndHttp2, false, false, "1.1", "1.1", "HTTP/1.1")]
46+
[InlineData(HttpProtocols.Http1AndHttp2, false, false, "2.0", "1.1", "HTTP/1.1")]
47+
48+
// non-TLS http* with the global override: should work for 1.1; with 2, client and server argue
49+
[InlineData(HttpProtocols.Http1AndHttp2, false, true, null, "1.1", "HTTP/1.1")]
50+
[InlineData(HttpProtocols.Http1AndHttp2, false, true, "1.1", "1.1", "HTTP/1.1")]
51+
[InlineData(HttpProtocols.Http1AndHttp2, false, true, "2.0", "2.0", "HTTP/2", true)]
52+
53+
// TLS http1: should always work, but http2 attempt is ignored
54+
[InlineData(HttpProtocols.Http1, true, false, null, "1.1", "HTTP/1.1")]
55+
[InlineData(HttpProtocols.Http1, true, false, "1.1", "1.1", "HTTP/1.1")]
56+
[InlineData(HttpProtocols.Http1, true, false, "2.0", "1.1", "HTTP/1.1")]
57+
58+
// TLS http2: should work as long as we actually send http2
59+
[InlineData(HttpProtocols.Http2, true, false, null, "1.1", "HTTP/1.1", true)]
60+
[InlineData(HttpProtocols.Http2, true, false, "1.1", "1.1", "HTTP/1.1", true)]
61+
[InlineData(HttpProtocols.Http2, true, false, "2.0", "2.0", "HTTP/2")]
62+
63+
// TLS http*: should always work
64+
[InlineData(HttpProtocols.Http1AndHttp2, true, false, null, "1.1", "HTTP/1.1")]
65+
[InlineData(HttpProtocols.Http1AndHttp2, true, false, "1.1", "1.1", "HTTP/1.1")]
66+
[InlineData(HttpProtocols.Http1AndHttp2, true, false, "2.0", "2.0", "HTTP/2")]
67+
public async Task UsesVersion(HttpProtocols protocols, bool tls, bool allowUnencryptedHttp2, string specified, string expectedVersion, string expectedResponse, bool failure = false)
68+
{
69+
bool oldMode = HttpSettings.GlobalAllowUnencryptedHttp2;
70+
try
71+
{
72+
HttpSettings.GlobalAllowUnencryptedHttp2 = allowUnencryptedHttp2;
73+
74+
var uri = _server.GetUri(protocols, tls);
75+
Log($"Server is on {uri}; specifying: '{specified}'");
76+
var request = Http.Request(uri, new HttpSettings {
77+
ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
78+
});
79+
if (specified is object) request = request.WithProtocolVersion(new Version(specified));
80+
HttpCallResponse<string> result;
81+
try
82+
{
83+
result = await request.ExpectString().GetAsync();
84+
}
85+
catch(Exception ex)
86+
{
87+
if (failure)
88+
{
89+
Log(ex.ToString());
90+
return;
91+
}
92+
else
93+
{
94+
throw;
95+
}
96+
}
97+
98+
Log($"As sent: {result?.RawRequest?.Version}, received: {result?.RawResponse?.Version}");
99+
100+
if (failure)
101+
{
102+
Assert.NotNull(result.Error);
103+
Log(result.Error.ToString());
104+
}
105+
else
106+
{
107+
Assert.Null(result.Error);
108+
Assert.Equal(expectedVersion, result.RawResponse?.Version?.ToString());
109+
Assert.Equal(expectedResponse, result.Data);
110+
}
111+
}
112+
finally
113+
{
114+
HttpSettings.GlobalAllowUnencryptedHttp2 = oldMode;
115+
}
116+
}
117+
118+
public class Http2Server : IDisposable
119+
{
120+
private readonly IWebHost _host;
121+
122+
private readonly int[] _ports = Enumerable.Range(10123, 6).ToArray();
123+
124+
public string GetUri(HttpProtocols protocols, bool tls)
125+
{
126+
var index = protocols switch
127+
{
128+
HttpProtocols.Http1 => tls ? 3 : 0,
129+
HttpProtocols.Http2 => tls ? 4 : 1,
130+
HttpProtocols.Http1AndHttp2 => tls ? 5 : 2,
131+
_ => throw new ArgumentOutOfRangeException(nameof(protocols)),
132+
};
133+
return $"{(tls ? "https" : "http")}://localhost:{_ports[index]}/";
134+
}
135+
public Task WaitForShutdownAsync() => _host.WaitForShutdownAsync();
136+
137+
public Http2Server()
138+
{
139+
_host = new WebHostBuilder()
140+
.UseKestrel(options => {
141+
options.ListenLocalhost(_ports[0], listenOptions =>
142+
{
143+
listenOptions.Protocols = HttpProtocols.Http1;
144+
});
145+
options.ListenLocalhost(_ports[1], listenOptions =>
146+
{
147+
listenOptions.Protocols = HttpProtocols.Http2;
148+
});
149+
options.ListenLocalhost(_ports[2], listenOptions =>
150+
{
151+
listenOptions.Protocols = HttpProtocols.Http1AndHttp2;
152+
});
153+
options.ListenLocalhost(_ports[3], listenOptions =>
154+
{
155+
listenOptions.Protocols = HttpProtocols.Http1;
156+
listenOptions.UseHttps("certificate.pfx");
157+
});
158+
options.ListenLocalhost(_ports[4], listenOptions =>
159+
{
160+
listenOptions.Protocols = HttpProtocols.Http2;
161+
listenOptions.UseHttps("certificate.pfx");
162+
});
163+
options.ListenLocalhost(_ports[5], listenOptions =>
164+
{
165+
listenOptions.Protocols = HttpProtocols.Http1AndHttp2;
166+
listenOptions.UseHttps("certificate.pfx");
167+
});
168+
})
169+
.Configure(app => {
170+
app.Run(context => context.Response.WriteAsync(context.Request.Protocol));
171+
})
172+
.Build();
173+
_ = _host.RunAsync();
174+
}
175+
void IDisposable.Dispose()
176+
=> _ = _host.StopAsync();
177+
}
178+
}
179+
}
180+
#endif
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,20 @@
11
<Project Sdk="Microsoft.NET.Sdk">
22
<PropertyGroup>
3-
<TargetFramework>netcoreapp2.2</TargetFramework>
3+
<TargetFrameworks>netcoreapp2.2;netcoreapp3.1</TargetFrameworks>
44
</PropertyGroup>
55
<ItemGroup>
66
<ProjectReference Include="../../src/StackExchange.Utils.Http/StackExchange.Utils.Http.csproj" />
77
</ItemGroup>
8+
9+
<PropertyGroup Condition="'$(TargetFramework)'=='netcoreapp3.1'">
10+
<DefineConstants>$(DefineConstants);KESTREL</DefineConstants>
11+
</PropertyGroup>
12+
<ItemGroup Condition="'$(TargetFramework)'=='netcoreapp3.1'">
13+
<PackageReference Include="Microsoft.AspNetCore.Server.Kestrel" Version="2.2.0" />
14+
</ItemGroup>
15+
<ItemGroup>
16+
<None Update="certificate.pfx">
17+
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
18+
</None>
19+
</ItemGroup>
820
</Project>
2.32 KB
Binary file not shown.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
generated by:
2+
3+
> openssl req -new -x509 -newkey rsa:2048 -keyout localhost.key -out localhost.cer -days 365 -subj /CN=localhost
4+
5+
pick any key password... perhaps "password", then
6+
7+
> openssl pkcs12 -export -out certificate.pfx -inkey localhost.key -in localhost.cer
8+
9+
put in the same key password - you do *not* need to specify an export password

0 commit comments

Comments
 (0)