Skip to content

Implement Http/2 WebSockets #41558

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 22 commits into from
Jun 8, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 0 additions & 41 deletions src/Hosting/TestHost/src/RequestFeature.cs

This file was deleted.

30 changes: 30 additions & 0 deletions src/Http/Http.Features/src/IHttpExtendedConnectFeature.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Diagnostics.CodeAnalysis;

namespace Microsoft.AspNetCore.Http.Features;

/// <summary>
/// Used with protocols that require the Extended CONNECT handshake such as HTTP/2 WebSockets and WebTransport.
/// https://www.rfc-editor.org/rfc/rfc8441#section-4
/// </summary>
public interface IHttpExtendedConnectFeature
{
/// <summary>
/// Indicates if the current request is a Extended CONNECT request that can be transitioned to an opaque connection via AcceptAsync.
/// </summary>
[MemberNotNullWhen(true, nameof(Protocol))]
bool IsExtendedConnect { get; }

/// <summary>
/// The <c>:protocol</c> header included in the request.
/// </summary>
string? Protocol { get; }

/// <summary>
/// Send the response headers with a 200 status code and transition to opaque streaming.
/// </summary>
/// <returns>An opaque bidirectional stream.</returns>
ValueTask<Stream> AcceptAsync();
}
4 changes: 4 additions & 0 deletions src/Http/Http.Features/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
#nullable enable
Microsoft.AspNetCore.Http.Features.IHttpExtendedConnectFeature
Microsoft.AspNetCore.Http.Features.IHttpExtendedConnectFeature.AcceptAsync() -> System.Threading.Tasks.ValueTask<System.IO.Stream!>
Microsoft.AspNetCore.Http.Features.IHttpExtendedConnectFeature.IsExtendedConnect.get -> bool
Microsoft.AspNetCore.Http.Features.IHttpExtendedConnectFeature.Protocol.get -> string?
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"EchoApp": {
"commandName": "Project",
"launchBrowser": true,
"launchUrl": "http://localhost:5000",
"launchUrl": "https://localhost:5001",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
Expand Down
2 changes: 1 addition & 1 deletion src/Middleware/WebSockets/samples/EchoApp/Startup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env, ILoggerF
{
if (context.WebSockets.IsWebSocketRequest)
{
var webSocket = await context.WebSockets.AcceptWebSocketAsync();
var webSocket = await context.WebSockets.AcceptWebSocketAsync(new WebSocketAcceptContext() { DangerousEnableCompression = true });
await Echo(context, webSocket, loggerFactory.CreateLogger("Echo"));
}
else
Expand Down
14 changes: 8 additions & 6 deletions src/Middleware/WebSockets/src/HandshakeHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,17 @@ internal static class HandshakeHelpers
// This uses C# compiler's ability to refer to static data directly. For more information see https://vcsjones.dev/2019/02/01/csharp-readonly-span-bytes-static
private static ReadOnlySpan<byte> EncodedWebSocketKey => "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"u8;

// Verify Method, Upgrade, Connection, version, key, etc..
public static void GenerateResponseHeaders(string key, string? subProtocol, IHeaderDictionary headers)
public static void GenerateResponseHeaders(bool isHttp1, IHeaderDictionary requestHeaders, string? subProtocol, IHeaderDictionary responseHeaders)
{
headers.Connection = HeaderNames.Upgrade;
headers.Upgrade = Constants.Headers.UpgradeWebSocket;
headers.SecWebSocketAccept = CreateResponseKey(key);
if (isHttp1)
{
responseHeaders.Connection = HeaderNames.Upgrade;
responseHeaders.Upgrade = Constants.Headers.UpgradeWebSocket;
responseHeaders.SecWebSocketAccept = CreateResponseKey(requestHeaders.SecWebSocketKey.ToString());
}
if (!string.IsNullOrWhiteSpace(subProtocol))
{
headers.SecWebSocketProtocol = subProtocol;
responseHeaders.SecWebSocketProtocol = subProtocol;
}
}

Expand Down
94 changes: 66 additions & 28 deletions src/Middleware/WebSockets/src/WebSocketMiddleware.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,11 @@ public Task Invoke(HttpContext context)
{
// Detect if an opaque upgrade is available. If so, add a websocket upgrade.
var upgradeFeature = context.Features.Get<IHttpUpgradeFeature>();
if (upgradeFeature != null && context.Features.Get<IHttpWebSocketFeature>() == null)
var connectFeature = context.Features.Get<IHttpExtendedConnectFeature>();
if ((upgradeFeature != null || connectFeature != null) && context.Features.Get<IHttpWebSocketFeature>() == null)
{
var webSocketFeature = new UpgradeHandshake(context, upgradeFeature, _options, _logger);
var webSocketFeature = new WebSocketHandshake(context, upgradeFeature, connectFeature, _options, _logger);
context.Features.Set<IHttpWebSocketFeature>(webSocketFeature);

if (!_anyOriginAllowed)
{
// Check for Origin header
Expand All @@ -88,18 +88,21 @@ public Task Invoke(HttpContext context)
return _next(context);
}

private sealed class UpgradeHandshake : IHttpWebSocketFeature
private sealed class WebSocketHandshake : IHttpWebSocketFeature
{
private readonly HttpContext _context;
private readonly IHttpUpgradeFeature _upgradeFeature;
private readonly IHttpUpgradeFeature? _upgradeFeature;
private readonly IHttpExtendedConnectFeature? _connectFeature;
private readonly WebSocketOptions _options;
private readonly ILogger _logger;
private bool? _isWebSocketRequest;
private bool _isH2WebSocket;

public UpgradeHandshake(HttpContext context, IHttpUpgradeFeature upgradeFeature, WebSocketOptions options, ILogger logger)
public WebSocketHandshake(HttpContext context, IHttpUpgradeFeature? upgradeFeature, IHttpExtendedConnectFeature? connectFeature, WebSocketOptions options, ILogger logger)
{
_context = context;
_upgradeFeature = upgradeFeature;
_connectFeature = connectFeature;
_options = options;
_logger = logger;
}
Expand All @@ -110,14 +113,19 @@ public bool IsWebSocketRequest
{
if (_isWebSocketRequest == null)
{
if (!_upgradeFeature.IsUpgradableRequest)
if (_connectFeature?.IsExtendedConnect == true)
{
_isWebSocketRequest = false;
_isH2WebSocket = CheckSupportedWebSocketRequestH2(_context.Request.Method, _connectFeature.Protocol, _context.Request.Headers);
_isWebSocketRequest = _isH2WebSocket;
}
else
else if (_upgradeFeature?.IsUpgradableRequest == true)
{
_isWebSocketRequest = CheckSupportedWebSocketRequest(_context.Request.Method, _context.Request.Headers);
}
else
{
_isWebSocketRequest = false;
}
}
return _isWebSocketRequest.Value;
}
Expand All @@ -127,7 +135,7 @@ public async Task<WebSocket> AcceptAsync(WebSocketAcceptContext acceptContext)
{
if (!IsWebSocketRequest)
{
throw new InvalidOperationException("Not a WebSocket request."); // TODO: LOC
throw new InvalidOperationException("Not a WebSocket request.");
}

string? subProtocol = null;
Expand All @@ -154,8 +162,7 @@ public async Task<WebSocket> AcceptAsync(WebSocketAcceptContext acceptContext)
}
}

var key = _context.Request.Headers.SecWebSocketKey.ToString();
HandshakeHelpers.GenerateResponseHeaders(key, subProtocol, _context.Response.Headers);
HandshakeHelpers.GenerateResponseHeaders(!_isH2WebSocket, _context.Request.Headers, subProtocol, _context.Response.Headers);

WebSocketDeflateOptions? deflateOptions = null;
if (enableCompression)
Expand Down Expand Up @@ -187,7 +194,18 @@ public async Task<WebSocket> AcceptAsync(WebSocketAcceptContext acceptContext)
}
}

Stream opaqueTransport = await _upgradeFeature.UpgradeAsync(); // Sets status code to 101
Stream opaqueTransport;
// HTTP/2
if (_isH2WebSocket)
{
// Send the response headers
opaqueTransport = await _connectFeature!.AcceptAsync();
}
// HTTP/1.1
else
{
opaqueTransport = await _upgradeFeature!.UpgradeAsync(); // Sets status code to 101
}

return WebSocket.CreateFromStream(opaqueTransport, new WebSocketCreationOptions()
{
Expand All @@ -205,17 +223,22 @@ public static bool CheckSupportedWebSocketRequest(string method, IHeaderDictiona
return false;
}

if (!CheckWebSocketVersion(requestHeaders))
{
return false;
}

var foundHeader = false;

var values = requestHeaders.GetCommaSeparatedValues(HeaderNames.SecWebSocketVersion);
var values = requestHeaders.GetCommaSeparatedValues(HeaderNames.Upgrade);
foreach (var value in values)
{
if (string.Equals(value, Constants.Headers.SupportedVersion, StringComparison.OrdinalIgnoreCase))
if (string.Equals(value, Constants.Headers.UpgradeWebSocket, StringComparison.OrdinalIgnoreCase))
{
// WebSockets are long lived; so if the header values are valid we switch them out for the interned versions.
if (values.Length == 1)
{
requestHeaders.SecWebSocketVersion = Constants.Headers.SupportedVersion;
requestHeaders.Upgrade = Constants.Headers.UpgradeWebSocket;
}
foundHeader = true;
break;
Expand Down Expand Up @@ -245,28 +268,43 @@ public static bool CheckSupportedWebSocketRequest(string method, IHeaderDictiona
{
return false;
}
foundHeader = false;

values = requestHeaders.GetCommaSeparatedValues(HeaderNames.Upgrade);
return HandshakeHelpers.IsRequestKeyValid(requestHeaders.SecWebSocketKey.ToString());
}

// https://datatracker.ietf.org/doc/html/rfc8441
// :method = CONNECT
// :protocol = websocket
// :scheme = https
// :path = /chat
// :authority = server.example.com
// sec-websocket-protocol = chat, superchat
// sec-websocket-extensions = permessage-deflate
// sec-websocket-version = 13
// origin = http://www.example.com
public static bool CheckSupportedWebSocketRequestH2(string method, string? protocol, IHeaderDictionary requestHeaders)
{
return HttpMethods.IsConnect(method)
&& string.Equals(protocol, Constants.Headers.UpgradeWebSocket, StringComparison.OrdinalIgnoreCase)
&& CheckWebSocketVersion(requestHeaders);
}

public static bool CheckWebSocketVersion(IHeaderDictionary requestHeaders)
{
var values = requestHeaders.GetCommaSeparatedValues(HeaderNames.SecWebSocketVersion);
foreach (var value in values)
{
if (string.Equals(value, Constants.Headers.UpgradeWebSocket, StringComparison.OrdinalIgnoreCase))
if (string.Equals(value, Constants.Headers.SupportedVersion, StringComparison.OrdinalIgnoreCase))
{
// WebSockets are long lived; so if the header values are valid we switch them out for the interned versions.
if (values.Length == 1)
{
requestHeaders.Upgrade = Constants.Headers.UpgradeWebSocket;
requestHeaders.SecWebSocketVersion = Constants.Headers.SupportedVersion;
}
foundHeader = true;
break;
return true;
}
}
if (!foundHeader)
{
return false;
}

return HandshakeHelpers.IsRequestKeyValid(requestHeaders.SecWebSocketKey.ToString());
return false;
}
}

Expand Down
74 changes: 74 additions & 0 deletions src/Middleware/WebSockets/test/UnitTests/Http2WebSocketTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.Hosting;
using Microsoft.Net.Http.Headers;

namespace Microsoft.AspNetCore.WebSockets.Tests;

public class Http2WebSocketTests
{
[Fact]
public async Task Http2Handshake_Success()
{
using var host = new HostBuilder()
.ConfigureWebHost(webHost =>
{
webHost.UseTestServer();
webHost.Configure(app =>
{
app.UseWebSockets();
app.Run(httpContext =>
{
Assert.True(httpContext.WebSockets.IsWebSocketRequest);
Assert.Equal(new[] { "p1", "p2" }, httpContext.WebSockets.WebSocketRequestedProtocols);
return httpContext.WebSockets.AcceptWebSocketAsync("p2");
});
});
}).Start();

var testServer = host.GetTestServer();

var result = await testServer.SendAsync(httpContext =>
{
httpContext.Request.Method = HttpMethods.Connect;
httpContext.Features.Set<IHttpExtendedConnectFeature>(new ConnectFeature()
{
IsExtendedConnect = true,
Protocol = "WebSocket",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we test sending and receiving data frames over the WebSocket and then gracefully closing it here? I understand that nothing should change relative to HTTP/1.1 WebSockets once you get the underlying Stream, but it's good to verify.

It's also tempting to open up the stream and connection windows in both directions Http2ConnectionTests and create a duplex Stream over the data frames for the request and wrap that in a client WebSocket so we can get better end-to-end testing of this scenario since we don't have any HttpClient or JS WebSocket based tests for the end-to-end. That'd be a more work, but probably not too much.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm planning to add more functional tests once the HttpClient implementation is available. Without that it's pretty hard to write any realistic tests. The client team has asked us to merge this ASAP so they can use it develop the client.

});
httpContext.Request.Headers.SecWebSocketVersion = Constants.Headers.SupportedVersion;
httpContext.Request.Headers.SecWebSocketProtocol = "p1, p2";
});

Assert.Equal(StatusCodes.Status200OK, result.Response.StatusCode);
var headers = result.Response.Headers;
Assert.Equal("p2", headers.SecWebSocketProtocol);
Assert.False(headers.TryGetValue(HeaderNames.Connection, out var _));
Assert.False(headers.TryGetValue(HeaderNames.Upgrade, out var _));
Assert.False(headers.TryGetValue(HeaderNames.SecWebSocketAccept, out var _));
}

public sealed class ConnectFeature : IHttpExtendedConnectFeature
{
public bool IsExtendedConnect { get; set; }
public string Protocol { get; set; }
public Stream Stream { get; set; } = Stream.Null;

/// <inheritdoc/>
public ValueTask<Stream> AcceptAsync()
{
if (!IsExtendedConnect)
{
throw new InvalidOperationException("This is not an Extended CONNECT request.");
}

return new ValueTask<Stream>(Stream);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
<ProjectReference Include="$(RepoRoot)src\Hosting\Server.IntegrationTesting\src\Microsoft.AspNetCore.Server.IntegrationTesting.csproj" />

<Reference Include="Microsoft.AspNetCore.Server.Kestrel" />
<Reference Include="Microsoft.AspNetCore.TestHost" />
<Reference Include="Microsoft.AspNetCore.WebSockets" />
</ItemGroup>

Expand Down
Loading