diff --git a/AspNetCore.sln b/AspNetCore.sln index 01245d8a7259144ad44b8742fd34cdd6df0b33ff..93566801b7b8543e049ad2267cf5e5f644bb6196 100644 --- a/AspNetCore.sln +++ b/AspNetCore.sln @@ -1737,8 +1737,12 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Templates.Blazor.WebAssembl EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RateLimitingSample", "src\Middleware\RateLimiting\samples\RateLimitingSample\RateLimitingSample.csproj", "{91C3C03E-EA56-4ABA-9E73-A3DA4C2833D9}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WebTransportSampleApp", "src\Servers\Kestrel\samples\WebTransportSampleApp\WebTransportSampleApp.csproj", "{F4A9CC79-5FE3-4F2B-9CC3-7F42DEDB4853}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MinimalOpenIdConnectSample", "src\Security\Authentication\OpenIdConnect\samples\MinimalOpenIdConnectSample\MinimalOpenIdConnectSample.csproj", "{07FDBE0D-B7A1-43DE-B120-F699C30E7CEF}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WebTransportInteractiveSampleApp", "src\Servers\Kestrel\samples\WebTransportInteractiveSampleApp\WebTransportInteractiveSampleApp.csproj", "{BA649043-EF2B-42DC-B422-A46127BE8296}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -9539,22 +9543,6 @@ Global {40F493E2-FE59-4787-BE44-3AED39D585BF}.Release|x64.Build.0 = Release|Any CPU {40F493E2-FE59-4787-BE44-3AED39D585BF}.Release|x86.ActiveCfg = Release|Any CPU {40F493E2-FE59-4787-BE44-3AED39D585BF}.Release|x86.Build.0 = Release|Any CPU - {05F4BC5A-060D-49B2-9069-95088402F99B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {05F4BC5A-060D-49B2-9069-95088402F99B}.Debug|Any CPU.Build.0 = Debug|Any CPU - {05F4BC5A-060D-49B2-9069-95088402F99B}.Debug|arm64.ActiveCfg = Debug|Any CPU - {05F4BC5A-060D-49B2-9069-95088402F99B}.Debug|arm64.Build.0 = Debug|Any CPU - {05F4BC5A-060D-49B2-9069-95088402F99B}.Debug|x64.ActiveCfg = Debug|Any CPU - {05F4BC5A-060D-49B2-9069-95088402F99B}.Debug|x64.Build.0 = Debug|Any CPU - {05F4BC5A-060D-49B2-9069-95088402F99B}.Debug|x86.ActiveCfg = Debug|Any CPU - {05F4BC5A-060D-49B2-9069-95088402F99B}.Debug|x86.Build.0 = Debug|Any CPU - {05F4BC5A-060D-49B2-9069-95088402F99B}.Release|Any CPU.ActiveCfg = Release|Any CPU - {05F4BC5A-060D-49B2-9069-95088402F99B}.Release|Any CPU.Build.0 = Release|Any CPU - {05F4BC5A-060D-49B2-9069-95088402F99B}.Release|arm64.ActiveCfg = Release|Any CPU - {05F4BC5A-060D-49B2-9069-95088402F99B}.Release|arm64.Build.0 = Release|Any CPU - {05F4BC5A-060D-49B2-9069-95088402F99B}.Release|x64.ActiveCfg = Release|Any CPU - {05F4BC5A-060D-49B2-9069-95088402F99B}.Release|x64.Build.0 = Release|Any CPU - {05F4BC5A-060D-49B2-9069-95088402F99B}.Release|x86.ActiveCfg = Release|Any CPU - {05F4BC5A-060D-49B2-9069-95088402F99B}.Release|x86.Build.0 = Release|Any CPU {6F335C66-C1D6-45FA-8529-6503B7CD42CC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {6F335C66-C1D6-45FA-8529-6503B7CD42CC}.Debug|Any CPU.Build.0 = Debug|Any CPU {6F335C66-C1D6-45FA-8529-6503B7CD42CC}.Debug|arm64.ActiveCfg = Debug|Any CPU @@ -10436,6 +10424,22 @@ Global {91C3C03E-EA56-4ABA-9E73-A3DA4C2833D9}.Release|x64.Build.0 = Release|Any CPU {91C3C03E-EA56-4ABA-9E73-A3DA4C2833D9}.Release|x86.ActiveCfg = Release|Any CPU {91C3C03E-EA56-4ABA-9E73-A3DA4C2833D9}.Release|x86.Build.0 = Release|Any CPU + {F4A9CC79-5FE3-4F2B-9CC3-7F42DEDB4853}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F4A9CC79-5FE3-4F2B-9CC3-7F42DEDB4853}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F4A9CC79-5FE3-4F2B-9CC3-7F42DEDB4853}.Debug|arm64.ActiveCfg = Debug|Any CPU + {F4A9CC79-5FE3-4F2B-9CC3-7F42DEDB4853}.Debug|arm64.Build.0 = Debug|Any CPU + {F4A9CC79-5FE3-4F2B-9CC3-7F42DEDB4853}.Debug|x64.ActiveCfg = Debug|Any CPU + {F4A9CC79-5FE3-4F2B-9CC3-7F42DEDB4853}.Debug|x64.Build.0 = Debug|Any CPU + {F4A9CC79-5FE3-4F2B-9CC3-7F42DEDB4853}.Debug|x86.ActiveCfg = Debug|Any CPU + {F4A9CC79-5FE3-4F2B-9CC3-7F42DEDB4853}.Debug|x86.Build.0 = Debug|Any CPU + {F4A9CC79-5FE3-4F2B-9CC3-7F42DEDB4853}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F4A9CC79-5FE3-4F2B-9CC3-7F42DEDB4853}.Release|Any CPU.Build.0 = Release|Any CPU + {F4A9CC79-5FE3-4F2B-9CC3-7F42DEDB4853}.Release|arm64.ActiveCfg = Release|Any CPU + {F4A9CC79-5FE3-4F2B-9CC3-7F42DEDB4853}.Release|arm64.Build.0 = Release|Any CPU + {F4A9CC79-5FE3-4F2B-9CC3-7F42DEDB4853}.Release|x64.ActiveCfg = Release|Any CPU + {F4A9CC79-5FE3-4F2B-9CC3-7F42DEDB4853}.Release|x64.Build.0 = Release|Any CPU + {F4A9CC79-5FE3-4F2B-9CC3-7F42DEDB4853}.Release|x86.ActiveCfg = Release|Any CPU + {F4A9CC79-5FE3-4F2B-9CC3-7F42DEDB4853}.Release|x86.Build.0 = Release|Any CPU {07FDBE0D-B7A1-43DE-B120-F699C30E7CEF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {07FDBE0D-B7A1-43DE-B120-F699C30E7CEF}.Debug|Any CPU.Build.0 = Debug|Any CPU {07FDBE0D-B7A1-43DE-B120-F699C30E7CEF}.Debug|arm64.ActiveCfg = Debug|Any CPU @@ -10452,6 +10456,22 @@ Global {07FDBE0D-B7A1-43DE-B120-F699C30E7CEF}.Release|x64.Build.0 = Release|Any CPU {07FDBE0D-B7A1-43DE-B120-F699C30E7CEF}.Release|x86.ActiveCfg = Release|Any CPU {07FDBE0D-B7A1-43DE-B120-F699C30E7CEF}.Release|x86.Build.0 = Release|Any CPU + {BA649043-EF2B-42DC-B422-A46127BE8296}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BA649043-EF2B-42DC-B422-A46127BE8296}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BA649043-EF2B-42DC-B422-A46127BE8296}.Debug|arm64.ActiveCfg = Debug|Any CPU + {BA649043-EF2B-42DC-B422-A46127BE8296}.Debug|arm64.Build.0 = Debug|Any CPU + {BA649043-EF2B-42DC-B422-A46127BE8296}.Debug|x64.ActiveCfg = Debug|Any CPU + {BA649043-EF2B-42DC-B422-A46127BE8296}.Debug|x64.Build.0 = Debug|Any CPU + {BA649043-EF2B-42DC-B422-A46127BE8296}.Debug|x86.ActiveCfg = Debug|Any CPU + {BA649043-EF2B-42DC-B422-A46127BE8296}.Debug|x86.Build.0 = Debug|Any CPU + {BA649043-EF2B-42DC-B422-A46127BE8296}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BA649043-EF2B-42DC-B422-A46127BE8296}.Release|Any CPU.Build.0 = Release|Any CPU + {BA649043-EF2B-42DC-B422-A46127BE8296}.Release|arm64.ActiveCfg = Release|Any CPU + {BA649043-EF2B-42DC-B422-A46127BE8296}.Release|arm64.Build.0 = Release|Any CPU + {BA649043-EF2B-42DC-B422-A46127BE8296}.Release|x64.ActiveCfg = Release|Any CPU + {BA649043-EF2B-42DC-B422-A46127BE8296}.Release|x64.Build.0 = Release|Any CPU + {BA649043-EF2B-42DC-B422-A46127BE8296}.Release|x86.ActiveCfg = Release|Any CPU + {BA649043-EF2B-42DC-B422-A46127BE8296}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -11310,7 +11330,9 @@ Global {0BB58FB6-8B66-4C6D-BA8A-DF3AFAF9AB8F} = {60D51C98-2CC0-40DF-B338-44154EFEE2FF} {7CA0A9AF-9088-471C-B0B6-EBF43F21D3B9} = {08D53E58-4AAE-40C4-8497-63EC8664F304} {91C3C03E-EA56-4ABA-9E73-A3DA4C2833D9} = {1D865E78-7A66-4CA9-92EE-2B350E45281F} + {F4A9CC79-5FE3-4F2B-9CC3-7F42DEDB4853} = {7B976D8F-EA31-4C0B-97BD-DFD9B3CC86FB} {07FDBE0D-B7A1-43DE-B120-F699C30E7CEF} = {E19E55A2-1562-47A7-8EA6-B51F2CA0CC4C} + {BA649043-EF2B-42DC-B422-A46127BE8296} = {7B976D8F-EA31-4C0B-97BD-DFD9B3CC86FB} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {3E8720B3-DBDD-498C-B383-2CC32A054E8F} diff --git a/docs/README.md b/docs/README.md index 4d8d6831181f7be9c614f375ecb6d9a8966100cc..d73a3afb2c15689280f1201de7356a495ba34976 100644 --- a/docs/README.md +++ b/docs/README.md @@ -27,3 +27,4 @@ The table below outlines the different docs in this folder and what they are hel | [Updating Major Version & TFM](UpdatingMajorVersionAndTFM.md)| Instructions for updating the repo branding & TFM in preparation for a new major release | Repo developers who want to know more about our branding & release process | | [Assembly trimming guide](Trimming.md)| Guidance on adding trimming support to an ASP.NET Core assembly | Repo developers who want to help add support for trimming to ASP.NET Core | | [Adding new Projects to the Repo](AddingNewProjects.md) | Outlines the process of adding new projects (i.e. `.csproj` files) to the repo | Anyone who finds themselves trying to add a new project and including it in the build. +| [Using WebTransport in Kestrel](WebTransport.md) | Outlines how to setup Kestrel to use WebTransport | Anyone looking to support WebTransport | diff --git a/docs/WebTransport.md b/docs/WebTransport.md new file mode 100644 index 0000000000000000000000000000000000000000..e397510500af16d68befbab2cc337a9d13ce86ab --- /dev/null +++ b/docs/WebTransport.md @@ -0,0 +1,301 @@ +# Using WebTransport in Kestrel + +Kestrel currently implements most of the WebTransport [draft-02](https://ietf-wg-webtrans.github.io/draft-ietf-webtrans-http3/draft-ietf-webtrans-http3.html) specification, except for datagrams. Datagrams will be implemented at a later date. This document outlines how to use the already implemented functionality. + +## Running the sample apps + +To help applications get started on implementing WebTransport, there are two sample apps. + +- ### `WebTransportSampleApp` project located at `src\Servers\Kestrel\samples\WebTransportSampleApp` +To use it, simply run from VS. This will launch the server and a terminal which will show logs from Kestrel as it interacts with the client. Now you should be able to connect to the sample from any client that implements the standard WebTransport draft02 specification. + +**Note:** Once you run the `WebTransportSampleApp`, it will print the certificate hash that it is using for the SSL connection. You will need to copy it into your client to make sure that both the server and the client use the same one. + +- ### `WebTransportInteractiveSampleApp` project located at `src\Middleware\WebTransport\samples\WebTransportInteractiveSampleApp` +To use it, simply run from VS. This will launch the server and terminal. Now you can open any browser that supports WebTransport and navigate to `https://localhost:5001`. You will see an interactive WebTransport test page where you can interact with the API and most of its main functionalities. + +**Note:** this sample automatically injects the certificate into the client-side code. Therefore, you do not need to handle it manually. + +## Using Edge or Chrome DevTools as a client + +The Chromium project has implemented a WebTransport client and can be accessed via their JS API from the Chrome or Edge DevTools console. A good sample app demonstrating how to use that API can be found [here](https://github.com/myjimmy/google-webtransport-sample/blob/ee13bde656c4d421d1f2a8e88fd71f572272c163/client.js). + +## Note about preview features + +WebTransport is a preview feature. Therefore, you must manually enable it via the `EnablePreviewFeatures` property and toggle the `Microsoft.AspNetCore.Server.Kestrel.Experimental.WebTransportAndH3Datagrams` `RuntimeHostConfigurationOption`. This can be done by adding the following `ItemGroup` to your csproj file: +```xml +<ItemGroup> + <RuntimeHostConfigurationOption Include="Microsoft.AspNetCore.Server.Kestrel.Experimental.WebTransportAndH3Datagrams" Value="true" /> +</ItemGroup> +``` + +## Obtaining a test certificate + +The current Kestrel default testing certificate cannot be used for WebTransport connections as it does not meet the requirements needed for WebTransport over HTTP/3. You can generate a new certificate for testing via the following C# (this function will also automatically handle cert rotation every time one expires): +```C# +static X509Certificate2 GenerateManualCertificate() +{ + X509Certificate2 cert = null; + var store = new X509Store("KestrelWebTransportCertificates", StoreLocation.CurrentUser); + store.Open(OpenFlags.ReadWrite); + if (store.Certificates.Count > 0) + { + cert = store.Certificates[^1]; + + // rotate key after it expires + if (DateTime.Parse(cert.GetExpirationDateString(), null) < DateTimeOffset.UtcNow) + { + cert = null; + } + } + if (cert == null) + { + // generate a new cert + var now = DateTimeOffset.UtcNow; + SubjectAlternativeNameBuilder sanBuilder = new(); + sanBuilder.AddDnsName("localhost"); + using var ec = ECDsa.Create(ECCurve.NamedCurves.nistP256); + CertificateRequest req = new("CN=localhost", ec, HashAlgorithmName.SHA256); + // Adds purpose + req.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(new OidCollection + { + new("1.3.6.1.5.5.7.3.1") // serverAuth + }, false)); + // Adds usage + req.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, false)); + // Adds subject alternate names + req.CertificateExtensions.Add(sanBuilder.Build()); + // Sign + using var crt = req.CreateSelfSigned(now, now.AddDays(14)); // 14 days is the max duration of a certificate for this + cert = new(crt.Export(X509ContentType.Pfx)); + + // Save + store.Add(cert); + } + store.Close(); + + var hash = SHA256.HashData(cert.RawData); + var certStr = Convert.ToBase64String(hash); + Console.WriteLine($"\n\n\n\n\nCertificate: {certStr}\n\n\n\n"); // <-- you will need to put this output into the JS API call to allow the connection + return cert; +} +// Adapted from: https://github.com/wegylexy/webtransport +``` + +## Overview of the Kestrel WebTransport API + +### Setting up a connection + +To setup a WebTransport connection, you will first need to configure a host upon which you open a port. A very minimal example is shown below: +```C# +var builder = WebApplication.CreateBuilder(args); +builder.WebHost.ConfigureKestrel((context, options) => +{ + // Port configured for WebTransport + options.Listen([SOME IP ADDRESS], [SOME PORT], listenOptions => + { + listenOptions.UseHttps(GenerateManualCertificate()); + listenOptions.UseConnectionLogging(); + listenOptions.Protocols = HttpProtocols.Http1AndHttp2AndHttp3; + }); +}); +var host = builder.Build(); +``` +**Note:** As WebTransport uses HTTP/3, you must make sure to select the `listenOptions.UseHttps` setting as well as set the `listenOptions.Protocols` to include HTTP/3. + +**Note:** The default Kestrel certificate cannot be used for WebTransport connections. For local testing you can use the workaround described in the [Obtaining a test certificate section](#Obtaining-a-test-certificate). + +Next, we defined the code that will run when Kestrel receives a connection. +```C# +host.Run(async (context) => +{ + var feature = context.Features.GetRequiredFeature<IHttpWebTransportFeature>(); + if (!feature.IsWebTransportRequest) + { + return; + } + var session = await feature.AcceptAsync(CancellationToken.None); + + // Use WebTransport via the newly established session. +}); + +await host.RunAsync(); +``` +The `Run` method is the main entry-point of your application logic. It is triggered every time there is a connection request. Once the request is a WebTransport request (which is defined by getting the `IHttpWebTransportFeature` feature and then checking the `IsWebTransportRequest` property), you will be able to accept WebTransport sessions and interact with the client. The last line (`await host.RunAsync();`) will start the server and start accepting connections. + +### Available WebTransport Features in Kestrel + +This section highlights some of the most significant features of WebTransport that Kestrel implements. However, this is not an exhaustive list. + +- Accept a WebTransport Session +```C# +var session = await feature.AcceptAsync(CancellationToken token); +``` +This will wait for the next incoming WebTransport session and return an instance of `IWebTransportSession` when a connection is completed. A session must be created prior to any streams being created or any data is sent. Note that only clients can initiate a session, thus the server passively waits until one is received and cannot initiate its own session. The cancellation token can be used to stop the operation. + +- Accepting a WebTransport stream +```C# +var connectionContext = await session.AcceptStreamAsync(CancellationToken token); +``` +This will wait for the next incoming WebTransport stream and return an instance of `ConnectionContext`. Note that streams are buffered in order. So, this call will return the next least recently received stream by popping from the front of the queue of pending streams. If no streams are pending, it will block until it receives one. You can use the cancellation token to stop the operation. + +**Note:** This method will return both bidirectional and unidirectional streams. They can be distinguished based on the `IStreamDirectionFeature.CanRead` and `IStreamDirectionFeature.CanWrite` properties. + +- Opening a new WebTransport stream from the server +```C# +var connectionContext = await session.OpenUnidirectionalStreamAsync(CancellationToken token); +``` +This will attempt to open a new unidirectional stream from the server to the client and return an instance of `ConnectionContext`. You can use the cancellation token to stop the operation. + +- Sending data over a WebTransport stream +```C# +var stream = connectionContext.Transport.Output; +await stream.WriteAsync(ReadOnlyMemory<byte> bytes); +``` +`stream.WriteAsync` will write data to the stream and then automatically flush (i.e. send it to the client). + +**Note:** You can only send data on streams that have `IStreamDirectionFeature.CanWrite` set as `true`. Sending data on non-writable streams will throw an `NotSupportedException` exception. + +- Reading data from a WebTransport stream +```C# +var stream = connectionContext.Transport.Input.AsStream(); +var length = await stream.ReadAsync(Memory<byte> memory); +``` +`stream.ReadAsync` will read data from the stream and copy it into the provided `memory` parameter. It will then return the number of bytes read. + +**Note:** You can only read data from streams that have `IStreamDirectionFeature.CanRead` set as `true`. Reading data on non-readable streams will throw an `NotSupportedException` exception. + +- Aborting a WebTransport session +```C# +session.Abort(int errorCode); +``` +Aborting a WebTransport session will result in severing the connection with the client and aborting all the streams. You can optionally specify an error code that will be passed down into the logs. The default value (256) represents no error. + +**Note:** valid error codes are defined [here](https://www.rfc-editor.org/rfc/rfc9114.html#name-http-3-error-codes). + +- Aborting a WebTransport stream +```C# +stream.Abort(ConnectionAbortedException exception); +``` +Aborting a WebTransport stream will result in abruptly ending all data transmission over the stream. You can optionally specify an aborted exception that will be passed down into the logs. A default message is used if no message is provided. + +- Soft closing a WebTransport stream +```C# +stream.DisposeAsync(); +``` +Disposing a WebTransport stream will result in ending data transmission and closing the stream gracefully. + + +### Examples + +#### Example 1 + +This example waits for a bidirectional stream. Once it receives one, it will read the data from it, reverse it and then write it back to the stream. +```C# +var builder = WebApplication.CreateBuilder(args); +builder.WebHost.ConfigureKestrel((context, options) => +{ + // Port configured for WebTransport + options.Listen(IPAddress.Any, 5007, listenOptions => + { + listenOptions.UseHttps(GenerateManualCertificate()); + listenOptions.UseConnectionLogging(); + listenOptions.Protocols = HttpProtocols.Http1AndHttp2AndHttp3; + }); +}); +var host = builder.Build(); + +host.Run(async (context) => +{ + var feature = context.Features.GetRequiredFeature<IHttpWebTransportFeature>(); + if (!feature.IsWebTransportRequest) + { + return; + } + var session = await feature.AcceptAsync(CancellationToken.None); + + ConnectionContext? stream = null; + IStreamDirectionFeature? direction = null; + while (true) + { + // wait until we get a stream + stream = await session.AcceptStreamAsync(CancellationToken.None); + if (stream is not null) + { + + // check that the stream is bidirectional. If yes, keep going, otherwise + // dispose its resources and keep waiting. + direction = stream.Features.GetRequiredFeature<IStreamDirectionFeature>(); + if (direction.CanRead && direction.CanWrite) + { + break; + } + else + { + await stream.DisposeAsync(); + } + } + else + { + // if a stream is null, this means that the session failed to get the next one. + // Thus, the session has ended or some other issue has occurred. We end the + // connection in this case. + return; + } + } + + var inputPipe = stream!.Transport.Input; + var outputPipe = stream!.Transport.Output; + + // read some data from the stream into the memory + var length = await inputPipe.AsStream().ReadAsync(memory); + + // slice to only keep the relevant parts of the memory + var outputMemory = memory[..length]; + + // do some operations on the contents of the data + outputMemory.Span.Reverse(); + + // write back the data to the stream + await outputPipe.WriteAsync(outputMemory); +}); + +await host.RunAsync(); +``` + +#### Example 2 + +This example opens a new stream from the server side and then sends data. +```C# +var builder = WebApplication.CreateBuilder(args); +builder.WebHost.ConfigureKestrel((context, options) => +{ + // Port configured for WebTransport + options.Listen(IPAddress.Any, 5007, listenOptions => + { + listenOptions.UseHttps(GenerateManualCertificate()); + listenOptions.UseConnectionLogging(); + listenOptions.Protocols = HttpProtocols.Http1AndHttp2AndHttp3; + }); +}); +var host = builder.Build(); + +host.Run(async (context) => +{ + var feature = context.Features.GetRequiredFeature<IHttpWebTransportFeature>(); + if (!feature.IsWebTransportRequest) + { + return; + } + var session = await feature.AcceptAsync(CancellationToken.None); + // open a new stream from the server to the client + var stream = await session.OpenUnidirectionalStreamAsync(CancellationToken.None); + + // write data to the stream + var outputPipe = stream.Transport.Output; + await outputPipe.WriteAsync(new Memory<byte>(new byte[] { 65, 66, 67, 68, 69 }), CancellationToken.None); + await outputPipe.FlushAsync(CancellationToken.None); +}); + +await host.RunAsync(); +``` diff --git a/src/Http/Http.Features/src/IHttpWebTransportFeature.cs b/src/Http/Http.Features/src/IHttpWebTransportFeature.cs new file mode 100644 index 0000000000000000000000000000000000000000..4dd69ad495d3060afd5f4adaea16c5b81a1ab591 --- /dev/null +++ b/src/Http/Http.Features/src/IHttpWebTransportFeature.cs @@ -0,0 +1,25 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Runtime.Versioning; + +namespace Microsoft.AspNetCore.Http.Features; + +/// <summary> +/// API for accepting and retrieving WebTransport sessions. +/// </summary> +public interface IHttpWebTransportFeature +{ + /// <summary> + /// Indicates if this request is a WebTransport request. + /// </summary> + bool IsWebTransportRequest { get; } + + /// <summary> + /// Accept the session request and allow streams to start being used. + /// </summary> + /// <param name="cancellationToken">The cancellation token to cancel waiting for the session.</param> + /// <returns>An instance of a WebTransportSession which will be used to control the connection.</returns> + [RequiresPreviewFeatures("WebTransport is a preview feature")] + ValueTask<IWebTransportSession> AcceptAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Http/Http.Features/src/IWebTransportSession.cs b/src/Http/Http.Features/src/IWebTransportSession.cs new file mode 100644 index 0000000000000000000000000000000000000000..78cd5b45d64bed566d757a9abf7d56e4fa6bcc89 --- /dev/null +++ b/src/Http/Http.Features/src/IWebTransportSession.cs @@ -0,0 +1,39 @@ +// 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.Connections; + +namespace Microsoft.AspNetCore.Http.Features; + +/// <summary> +/// Controls the session and streams of a WebTransport session. +/// </summary> +public interface IWebTransportSession +{ + /// <summary> + /// The id of the WebTransport session. + /// </summary> + long SessionId { get; } + + /// <summary> + /// Abruptly close the WebTransport session and stop all the streams. + /// </summary> + /// <param name="errorCode">HTTP error code that corresponds to the reason for causing the abort.</param> + /// <remarks>Error codes are described here: https://www.rfc-editor.org/rfc/rfc9114.html#name-http-3-error-codes</remarks> + void Abort(int errorCode); + + /// <summary> + /// Returns the next incoming stream in the order the server received it. The stream can be either bidirectional or unidirectional. + /// </summary> + /// <remarks>To use WebTransport, you must first enable the <c>Microsoft.AspNetCore.Server.Kestrel.Experimental.WebTransportAndH3Datagrams</c> AppContextSwitch</remarks> + /// <param name="cancellationToken">The cancellation token used to cancel the operation.</param> + /// <returns>The unidirectional or bidirectional stream that is next in the queue, or <c>null</c> if the session has ended.</returns> + ValueTask<ConnectionContext?> AcceptStreamAsync(CancellationToken cancellationToken = default); + + /// <summary> + /// Opens a new unidirectional output stream. + /// </summary> + /// <param name="cancellationToken">The cancellation token used to cancel the operation.</param> + /// <returns>The unidirectional stream that was opened.</returns> + ValueTask<ConnectionContext?> OpenUnidirectionalStreamAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Http/Http.Features/src/Microsoft.AspNetCore.Http.Features.csproj b/src/Http/Http.Features/src/Microsoft.AspNetCore.Http.Features.csproj index 50fe70a3365f8f9d44566d0faea5fa1e43c2b209..53c207c8974156545f8c7df02720dcb566302555 100644 --- a/src/Http/Http.Features/src/Microsoft.AspNetCore.Http.Features.csproj +++ b/src/Http/Http.Features/src/Microsoft.AspNetCore.Http.Features.csproj @@ -1,4 +1,4 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <Description>ASP.NET Core HTTP feature interface definitions.</Description> @@ -11,6 +11,7 @@ </PropertyGroup> <ItemGroup> + <Reference Include="Microsoft.AspNetCore.Connections.Abstractions" /> <Reference Include="Microsoft.Extensions.Features" /> <Reference Include="Microsoft.Extensions.Primitives" /> <Reference Include="Microsoft.Net.Http.Headers" /> diff --git a/src/Http/Http.Features/src/PublicAPI.Unshipped.txt b/src/Http/Http.Features/src/PublicAPI.Unshipped.txt index 1ace2c6534a1f8a190c5d1962a88970cebe47820..fe3881f254e7be321c959779eed9ec454cfad172 100644 --- a/src/Http/Http.Features/src/PublicAPI.Unshipped.txt +++ b/src/Http/Http.Features/src/PublicAPI.Unshipped.txt @@ -6,3 +6,11 @@ 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? +Microsoft.AspNetCore.Http.Features.IHttpWebTransportFeature +Microsoft.AspNetCore.Http.Features.IHttpWebTransportFeature.AcceptAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask<Microsoft.AspNetCore.Http.Features.IWebTransportSession!> +Microsoft.AspNetCore.Http.Features.IHttpWebTransportFeature.IsWebTransportRequest.get -> bool +Microsoft.AspNetCore.Http.Features.IWebTransportSession +Microsoft.AspNetCore.Http.Features.IWebTransportSession.Abort(int errorCode) -> void +Microsoft.AspNetCore.Http.Features.IWebTransportSession.AcceptStreamAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask<Microsoft.AspNetCore.Connections.ConnectionContext?> +Microsoft.AspNetCore.Http.Features.IWebTransportSession.OpenUnidirectionalStreamAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask<Microsoft.AspNetCore.Connections.ConnectionContext?> +Microsoft.AspNetCore.Http.Features.IWebTransportSession.SessionId.get -> long diff --git a/src/Middleware/Middleware.slnf b/src/Middleware/Middleware.slnf index c71da8528b7a00414115c59d6846732f8e04d22e..de0c7112205f722a0404df37066bc4ecb53253d8 100644 --- a/src/Middleware/Middleware.slnf +++ b/src/Middleware/Middleware.slnf @@ -79,8 +79,8 @@ "src\\Middleware\\OutputCaching\\samples\\OutputCachingSample\\OutputCachingSample.csproj", "src\\Middleware\\OutputCaching\\src\\Microsoft.AspNetCore.OutputCaching.csproj", "src\\Middleware\\OutputCaching\\test\\Microsoft.AspNetCore.OutputCaching.Tests.csproj", - "src\\Middleware\\RateLimiting\\src\\Microsoft.AspNetCore.RateLimiting.csproj", "src\\Middleware\\RateLimiting\\samples\\RateLimitingSample\\RateLimitingSample.csproj", + "src\\Middleware\\RateLimiting\\src\\Microsoft.AspNetCore.RateLimiting.csproj", "src\\Middleware\\RateLimiting\\test\\Microsoft.AspNetCore.RateLimiting.Tests.csproj", "src\\Middleware\\RequestDecompression\\perf\\Microbenchmarks\\Microsoft.AspNetCore.RequestDecompression.Microbenchmarks.csproj", "src\\Middleware\\RequestDecompression\\sample\\RequestDecompressionSample.csproj", diff --git a/src/Servers/Kestrel/Core/src/CoreStrings.resx b/src/Servers/Kestrel/Core/src/CoreStrings.resx index bd4d09cc2bb4d9c16f807427161d9a528517080f..c0a409e703f1503f5386de25089d76454949ea2e 100644 --- a/src/Servers/Kestrel/Core/src/CoreStrings.resx +++ b/src/Servers/Kestrel/Core/src/CoreStrings.resx @@ -689,4 +689,22 @@ For more information on configuring HTTPS see https://go.microsoft.com/fwlink/?l <data name="ConnectStatusMustBe200" xml:space="preserve"> <value>The response status code for a Extended CONNECT request must be 200.</value> </data> + <data name="AttemptedToReadHeaderOnAbortedStream" xml:space="preserve"> + <value>Attempted to read header on aborted stream.</value> + </data> + <data name="ReceivedLooseWebTransportStream" xml:space="preserve"> + <value>Received a WebTransport stream that is not associated with an existing WebTransport session.</value> + </data> + <data name="UnidentifiedStream" xml:space="preserve"> + <value>Unidentified stream {stream}.</value> + </data> + <data name="WebTransportFailedToAddStreamToPendingQueue" xml:space="preserve"> + <value>Failed to add incoming stream to pending queue.</value> + </data> + <data name="FailedToNegotiateCommonWebTransportVersion" xml:space="preserve"> + <value>Failed to negotiate a common WebTransport version with client. Kestrel only supports {currentSuppportedVersion}.</value> + </data> + <data name="WebTransportIsDisabled" xml:space="preserve"> + <value>WebTransport is disabled. Please enable it before starting a session.</value> + </data> </root> diff --git a/src/Servers/Kestrel/Core/src/Internal/Http/HttpProtocol.FeatureCollection.cs b/src/Servers/Kestrel/Core/src/Internal/Http/HttpProtocol.FeatureCollection.cs index c4e4d186521aeb108bc578dfde8237ccedac2d86..b8c85408c0efe255d14f791a95a01327eacb3de7 100644 --- a/src/Servers/Kestrel/Core/src/Internal/Http/HttpProtocol.FeatureCollection.cs +++ b/src/Servers/Kestrel/Core/src/Internal/Http/HttpProtocol.FeatureCollection.cs @@ -342,4 +342,12 @@ internal partial class HttpProtocol { return CompleteAsync(); } + +#pragma warning disable CA2252 // WebTransport is a preview feature. Suppress this warning + public bool IsWebTransportRequest { get; set; } + public virtual ValueTask<IWebTransportSession> AcceptAsync(CancellationToken token) + { + throw new NotSupportedException(); + } +#pragma warning restore CA2252 } diff --git a/src/Servers/Kestrel/Core/src/Internal/Http/HttpProtocol.Generated.cs b/src/Servers/Kestrel/Core/src/Internal/Http/HttpProtocol.Generated.cs index da4756c675514624dfb873653dde489e6430802e..69466b657920309ab7bc0e54802c1d4e685116f0 100644 --- a/src/Servers/Kestrel/Core/src/Internal/Http/HttpProtocol.Generated.cs +++ b/src/Servers/Kestrel/Core/src/Internal/Http/HttpProtocol.Generated.cs @@ -31,6 +31,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http IHttpBodyControlFeature, IHttpMaxRequestBodySizeFeature, IHttpRequestBodyDetectionFeature, + IHttpWebTransportFeature, IBadRequestExceptionFeature { // Implemented features @@ -49,6 +50,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http internal protected IHttpBodyControlFeature? _currentIHttpBodyControlFeature; internal protected IHttpMaxRequestBodySizeFeature? _currentIHttpMaxRequestBodySizeFeature; internal protected IHttpRequestBodyDetectionFeature? _currentIHttpRequestBodyDetectionFeature; + internal protected IHttpWebTransportFeature? _currentIHttpWebTransportFeature; internal protected IBadRequestExceptionFeature? _currentIBadRequestExceptionFeature; // Other reserved feature slots @@ -90,6 +92,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http _currentIHttpBodyControlFeature = this; _currentIHttpMaxRequestBodySizeFeature = this; _currentIHttpRequestBodyDetectionFeature = this; + _currentIHttpWebTransportFeature = this; _currentIBadRequestExceptionFeature = this; _currentIServiceProvidersFeature = null; @@ -267,6 +270,10 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http { feature = _currentIHttpWebSocketFeature; } + else if (key == typeof(IHttpWebTransportFeature)) + { + feature = _currentIHttpWebTransportFeature; + } else if (key == typeof(IBadRequestExceptionFeature)) { feature = _currentIBadRequestExceptionFeature; @@ -407,6 +414,10 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http { _currentIHttpWebSocketFeature = (IHttpWebSocketFeature?)value; } + else if (key == typeof(IHttpWebTransportFeature)) + { + _currentIHttpWebTransportFeature = (IHttpWebTransportFeature?)value; + } else if (key == typeof(IBadRequestExceptionFeature)) { _currentIBadRequestExceptionFeature = (IBadRequestExceptionFeature?)value; @@ -549,6 +560,10 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http { feature = Unsafe.As<IHttpWebSocketFeature?, TFeature?>(ref _currentIHttpWebSocketFeature); } + else if (typeof(TFeature) == typeof(IHttpWebTransportFeature)) + { + feature = Unsafe.As<IHttpWebTransportFeature?, TFeature?>(ref _currentIHttpWebTransportFeature); + } else if (typeof(TFeature) == typeof(IBadRequestExceptionFeature)) { feature = Unsafe.As<IBadRequestExceptionFeature?, TFeature?>(ref _currentIBadRequestExceptionFeature); @@ -697,6 +712,10 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http { _currentIHttpWebSocketFeature = Unsafe.As<TFeature?, IHttpWebSocketFeature?>(ref feature); } + else if (typeof(TFeature) == typeof(IHttpWebTransportFeature)) + { + _currentIHttpWebTransportFeature = Unsafe.As<TFeature?, IHttpWebTransportFeature?>(ref feature); + } else if (typeof(TFeature) == typeof(IBadRequestExceptionFeature)) { _currentIBadRequestExceptionFeature = Unsafe.As<TFeature?, IBadRequestExceptionFeature?>(ref feature); @@ -833,6 +852,10 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http { yield return new KeyValuePair<Type, object>(typeof(IHttpWebSocketFeature), _currentIHttpWebSocketFeature); } + if (_currentIHttpWebTransportFeature != null) + { + yield return new KeyValuePair<Type, object>(typeof(IHttpWebTransportFeature), _currentIHttpWebTransportFeature); + } if (_currentIBadRequestExceptionFeature != null) { yield return new KeyValuePair<Type, object>(typeof(IBadRequestExceptionFeature), _currentIBadRequestExceptionFeature); diff --git a/src/Servers/Kestrel/Core/src/Internal/Http/HttpProtocol.cs b/src/Servers/Kestrel/Core/src/Internal/Http/HttpProtocol.cs index e48fb710ed226948bb2033a8d1b6b890d840fd7e..742bdc047c127a1232c959b0da3e57bc96aa74eb 100644 --- a/src/Servers/Kestrel/Core/src/Internal/Http/HttpProtocol.cs +++ b/src/Servers/Kestrel/Core/src/Internal/Http/HttpProtocol.cs @@ -362,6 +362,7 @@ internal abstract partial class HttpProtocol : IHttpResponseControl IsUpgraded = false; IsExtendedConnectRequest = false; IsExtendedConnectAccepted = false; + IsWebTransportRequest = false; var remoteEndPoint = RemoteEndPoint; RemoteIpAddress = remoteEndPoint?.Address; diff --git a/src/Servers/Kestrel/Core/src/Internal/Http3/Http3Connection.cs b/src/Servers/Kestrel/Core/src/Internal/Http3/Http3Connection.cs index 8303447f0115d2cb339aa40aac6a4a17b2b0d1f8..b1ecf4dac3fc70654cc7d4e047684d062a5abec7 100644 --- a/src/Servers/Kestrel/Core/src/Internal/Http3/Http3Connection.cs +++ b/src/Servers/Kestrel/Core/src/Internal/Http3/Http3Connection.cs @@ -11,38 +11,44 @@ using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http; using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Infrastructure; +using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.WebTransport; +using Microsoft.AspNetCore.Server.Kestrel.Core.WebTransport; namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http3; internal sealed class Http3Connection : IHttp3StreamLifetimeHandler, IRequestProcessor { - internal static readonly object StreamPersistentStateKey = new object(); + internal static readonly object StreamPersistentStateKey = new(); // Internal for unit testing - internal readonly Dictionary<long, IHttp3Stream> _streams = new Dictionary<long, IHttp3Stream>(); internal IHttp3StreamLifetimeHandler _streamLifetimeHandler; + internal readonly Dictionary<long, IHttp3Stream> _streams = new(); + internal readonly Dictionary<long, Http3PendingStream> _unidentifiedStreams = new(); + + internal readonly MultiplexedConnectionContext _multiplexedContext; + internal readonly Http3PeerSettings _serverSettings = new(); + internal readonly Http3PeerSettings _clientSettings = new(); // The highest opened request stream ID is sent with GOAWAY. The GOAWAY // value will signal to the peer to discard all requests with that value or greater. // When this value is sent, 4 will be added. We want 0 to be sent for no requests, // so start highest opened request stream ID at -4. private const long DefaultHighestOpenedRequestStreamId = -4; - private long _highestOpenedRequestStreamId = DefaultHighestOpenedRequestStreamId; - private readonly object _sync = new object(); - private readonly MultiplexedConnectionContext _multiplexedContext; + private readonly object _sync = new(); private readonly HttpMultiplexedConnectionContext _context; + private readonly object _protocolSelectionLock = new(); + private readonly StreamCloseAwaitable _streamCompletionAwaitable = new(); + private readonly IProtocolErrorCodeFeature _errorCodeFeature; + private readonly Dictionary<long, WebTransportSession>? _webtransportSessions; + + private long _highestOpenedRequestStreamId = DefaultHighestOpenedRequestStreamId; private bool _aborted; - private readonly object _protocolSelectionLock = new object(); private int _gracefulCloseInitiator; private int _stoppedAcceptingStreams; private bool _gracefulCloseStarted; private int _activeRequestCount; - private CancellationTokenSource _acceptStreamsCts = new CancellationTokenSource(); - private readonly Http3PeerSettings _serverSettings = new Http3PeerSettings(); - private readonly Http3PeerSettings _clientSettings = new Http3PeerSettings(); - private readonly StreamCloseAwaitable _streamCompletionAwaitable = new StreamCloseAwaitable(); - private readonly IProtocolErrorCodeFeature _errorCodeFeature; + private CancellationTokenSource _acceptStreamsCts = new(); public Http3Connection(HttpMultiplexedConnectionContext context) { @@ -58,8 +64,13 @@ internal sealed class Http3Connection : IHttp3StreamLifetimeHandler, IRequestPro _serverSettings.MaxRequestHeaderFieldSectionSize = (uint)httpLimits.MaxRequestHeadersTotalSize; _serverSettings.EnableWebTransport = Convert.ToUInt32(context.ServiceContext.ServerOptions.EnableWebTransportAndH3Datagrams); // technically these are 2 different settings so they should have separate values but the Chromium implementation requires - // them to both be 1 to useWebTransport. + // them to both be 1 to use WebTransport. _serverSettings.H3Datagram = Convert.ToUInt32(context.ServiceContext.ServerOptions.EnableWebTransportAndH3Datagrams); + + if (context.ServiceContext.ServerOptions.EnableWebTransportAndH3Datagrams) + { + _webtransportSessions = new(); + } } private void UpdateHighestOpenedRequestStreamId(long streamId) @@ -150,6 +161,21 @@ internal sealed class Http3Connection : IHttp3StreamLifetimeHandler, IRequestPro _aborted = true; } + if (_webtransportSessions is not null) + { + foreach (var session in _webtransportSessions) + { + if (ex.InnerException is not null) + { + session.Value.Abort(new ConnectionAbortedException(ex.Message, ex.InnerException), errorCode); + } + else + { + session.Value.Abort(new ConnectionAbortedException(ex.Message), errorCode); + } + } + } + if (!previousState) { _errorCodeFeature.Error = (long)errorCode; @@ -185,6 +211,24 @@ internal sealed class Http3Connection : IHttp3StreamLifetimeHandler, IRequestPro var ticks = now.Ticks; + lock (_unidentifiedStreams) + { + foreach (var stream in _unidentifiedStreams.Values) + { + if (stream.StreamTimeoutTicks == default) + { + // On expiration overflow, use max value. + var expirationTicks = ticks + _context.ServiceContext.ServerOptions.Limits.RequestHeadersTimeout.Ticks; + stream.StreamTimeoutTicks = expirationTicks >= 0 ? expirationTicks : long.MaxValue; + } + + if (stream.StreamTimeoutTicks < ticks) + { + stream.Abort(new("Stream timed out before its type was determined.")); + } + } + } + lock (_streams) { foreach (var stream in _streams.Values) @@ -288,56 +332,74 @@ internal sealed class Http3Connection : IHttp3StreamLifetimeHandler, IRequestPro Debug.Assert(streamDirectionFeature != null); Debug.Assert(streamIdFeature != null); + var context = CreateHttpStreamContext(streamContext); + + // unidirectional stream if (!streamDirectionFeature.CanWrite) { - // Unidirectional stream - var stream = new Http3ControlStream<TContext>(application, CreateHttpStreamContext(streamContext)); - _streamLifetimeHandler.OnStreamCreated(stream); + if (context.ServiceContext.ServerOptions.EnableWebTransportAndH3Datagrams) + { + var pendingStream = new Http3PendingStream(context, streamIdFeature.StreamId); + + _streamLifetimeHandler.OnUnidentifiedStreamReceived(pendingStream); - ThreadPool.UnsafeQueueUserWorkItem(stream, preferLocal: false); + // TODO: This needs to get dispatched off of the accept loop to avoid blocking other streams. (https://github.com/dotnet/aspnetcore/issues/42789) + var streamType = await pendingStream.ReadNextStreamHeaderAsync(context, streamIdFeature.StreamId, null); + + _unidentifiedStreams.Remove(streamIdFeature.StreamId, out _); + + if (streamType == (long)Http3StreamType.WebTransportUnidirectional) + { + await CreateAndAddWebTransportStream(pendingStream, streamIdFeature.StreamId, WebTransportStreamType.Input); + } + else + { + var controlStream = new Http3ControlStream<TContext>(application, context, streamType); + _streamLifetimeHandler.OnStreamCreated(controlStream); + ThreadPool.UnsafeQueueUserWorkItem(controlStream, preferLocal: false); + } + } + else + { + var controlStream = new Http3ControlStream<TContext>(application, context, null); + _streamLifetimeHandler.OnStreamCreated(controlStream); + ThreadPool.UnsafeQueueUserWorkItem(controlStream, preferLocal: false); + } } + // bidirectional stream else { - // Request stream - - // https://quicwg.org/base-drafts/draft-ietf-quic-http.html#section-5.2-2 - if (_gracefulCloseStarted) + if (context.ServiceContext.ServerOptions.EnableWebTransportAndH3Datagrams) { - // https://quicwg.org/base-drafts/draft-ietf-quic-http.html#section-4.1.2-3 - streamContext.Features.GetRequiredFeature<IProtocolErrorCodeFeature>().Error = (long)Http3ErrorCode.RequestRejected; - streamContext.Abort(new ConnectionAbortedException("HTTP/3 connection is closing and no longer accepts new requests.")); - await streamContext.DisposeAsync(); - - continue; - } + var pendingStream = new Http3PendingStream(context, streamIdFeature.StreamId); - // Request stream IDs are tracked. - UpdateHighestOpenedRequestStreamId(streamIdFeature.StreamId); + _streamLifetimeHandler.OnUnidentifiedStreamReceived(pendingStream); - var persistentStateFeature = streamContext.Features.Get<IPersistentStateFeature>(); - Debug.Assert(persistentStateFeature != null, $"Required {nameof(IPersistentStateFeature)} not on stream context."); + // TODO: This needs to get dispatched off of the accept loop to avoid blocking other streams. (https://github.com/dotnet/aspnetcore/issues/42789) + var streamType = await pendingStream.ReadNextStreamHeaderAsync(context, streamIdFeature.StreamId, Http3StreamType.WebTransportBidirectional); - Http3Stream<TContext> stream; + _unidentifiedStreams.Remove(streamIdFeature.StreamId, out _); - // Check whether there is an existing HTTP/3 stream on the transport stream. - // A stream will only be cached if the transport stream itself is reused. - if (!persistentStateFeature.State.TryGetValue(StreamPersistentStateKey, out var s)) - { - stream = new Http3Stream<TContext>(application, CreateHttpStreamContext(streamContext)); - persistentStateFeature.State.Add(StreamPersistentStateKey, stream); + if (streamType == (long)Http3StreamType.WebTransportBidirectional) + { + await CreateAndAddWebTransportStream(pendingStream, streamIdFeature.StreamId, WebTransportStreamType.Bidirectional); + } + else + { + await CreateHttp3Stream(streamContext, context, application, streamIdFeature.StreamId); + } } else { - stream = (Http3Stream<TContext>)s!; - stream.InitializeWithExistingContext(streamContext.Transport); + await CreateHttp3Stream(streamContext, context, application, streamIdFeature.StreamId); } - - _streamLifetimeHandler.OnStreamCreated(stream); - - KestrelEventSource.Log.RequestQueuedStart(stream, AspNetCore.Http.HttpProtocol.Http3); - ThreadPool.UnsafeQueueUserWorkItem(stream, preferLocal: false); } } + catch (Http3PendingStreamException ex) + { + _unidentifiedStreams.Remove(ex.StreamId, out var stream); + Log.Http3StreamAbort(CoreStrings.FormatUnidentifiedStream(ex.StreamId), Http3ErrorCode.StreamCreationError, new(ex.Message)); + } finally { UpdateConnectionState(); @@ -397,6 +459,22 @@ internal sealed class Http3Connection : IHttp3StreamLifetimeHandler, IRequestPro } } + lock (_unidentifiedStreams) + { + foreach (var stream in _unidentifiedStreams.Values) + { + stream.Abort(CreateConnectionAbortError(error, clientAbort)); + } + } + + if (_webtransportSessions is not null) + { + foreach (var session in _webtransportSessions.Values) + { + session.OnClientConnectionClosed(); + } + } + if (outboundControlStream != null) { // Don't gracefully close the outbound control stream. If the peer detects @@ -434,6 +512,67 @@ internal sealed class Http3Connection : IHttp3StreamLifetimeHandler, IRequestPro } } + private async Task CreateHttp3Stream<TContext>(ConnectionContext streamContext, Http3StreamContext context, IHttpApplication<TContext> application, long streamId) where TContext : notnull + { + // http request stream + // https://quicwg.org/base-drafts/draft-ietf-quic-http.html#section-5.2-2 + if (_gracefulCloseStarted) + { + // https://quicwg.org/base-drafts/draft-ietf-quic-http.html#section-4.1.2-3 + streamContext.Features.GetRequiredFeature<IProtocolErrorCodeFeature>().Error = (long)Http3ErrorCode.RequestRejected; + streamContext.Abort(new ConnectionAbortedException("HTTP/3 connection is closing and no longer accepts new requests.")); + await streamContext.DisposeAsync(); + + return; + } + + // Request stream IDs are tracked. + UpdateHighestOpenedRequestStreamId(streamId); + + var persistentStateFeature = streamContext.Features.Get<IPersistentStateFeature>(); + Debug.Assert(persistentStateFeature != null, $"Required {nameof(IPersistentStateFeature)} not on stream context."); + + Http3Stream stream; + + // Check whether there is an existing HTTP/3 stream on the transport stream. + // A stream will only be cached if the transport stream itself is reused. + if (!persistentStateFeature.State.TryGetValue(StreamPersistentStateKey, out var s)) + { + stream = new Http3Stream<TContext>(application, context); + persistentStateFeature.State.Add(StreamPersistentStateKey, stream); + } + else + { + stream = (Http3Stream<TContext>)s!; + stream.InitializeWithExistingContext(streamContext.Transport); + } + + _streamLifetimeHandler.OnStreamCreated(stream); + KestrelEventSource.Log.RequestQueuedStart(stream, AspNetCore.Http.HttpProtocol.Http3); + ThreadPool.UnsafeQueueUserWorkItem(stream, preferLocal: false); + } + + private async Task CreateAndAddWebTransportStream(Http3PendingStream stream, long streamId, WebTransportStreamType type) + { + Debug.Assert(_context.ServiceContext.ServerOptions.EnableWebTransportAndH3Datagrams); + + // TODO: This needs to get dispatched off of the accept loop to avoid blocking other streams. (https://github.com/dotnet/aspnetcore/issues/42789) + var correspondingSession = await stream.ReadNextStreamHeaderAsync(stream.Context, streamId, null); + + lock (_webtransportSessions!) + { + if (!_webtransportSessions.TryGetValue(correspondingSession, out var session)) + { + stream.Abort(new ConnectionAbortedException(CoreStrings.ReceivedLooseWebTransportStream)); + throw new Http3StreamErrorException(CoreStrings.ReceivedLooseWebTransportStream, Http3ErrorCode.StreamCreationError); + } + + stream.Context.WebTransportSession = session; + var webtransportStream = new WebTransportStream(stream.Context, type); + session.AddStream(webtransportStream); + } + } + private static ConnectionAbortedException CreateConnectionAbortError(Exception? error, bool clientAbort) { if (error is ConnectionAbortedException abortedException) @@ -449,7 +588,7 @@ internal sealed class Http3Connection : IHttp3StreamLifetimeHandler, IRequestPro return new ConnectionAbortedException(CoreStrings.Http3ConnectionFaulted, error!); } - private Http3StreamContext CreateHttpStreamContext(ConnectionContext streamContext) + internal Http3StreamContext CreateHttpStreamContext(ConnectionContext streamContext) { var httpConnectionContext = new Http3StreamContext( _multiplexedContext.ConnectionId, @@ -461,12 +600,12 @@ internal sealed class Http3Connection : IHttp3StreamLifetimeHandler, IRequestPro _context.MemoryPool, streamContext.LocalEndPoint as IPEndPoint, streamContext.RemoteEndPoint as IPEndPoint, - _streamLifetimeHandler, streamContext, - _clientSettings, - _serverSettings); - httpConnectionContext.TimeoutControl = _context.TimeoutControl; - httpConnectionContext.Transport = streamContext.Transport; + this) + { + TimeoutControl = _context.TimeoutControl, + Transport = streamContext.Transport + }; return httpConnectionContext; } @@ -531,24 +670,9 @@ internal sealed class Http3Connection : IHttp3StreamLifetimeHandler, IRequestPro var features = new FeatureCollection(); features.Set<IStreamDirectionFeature>(new DefaultStreamDirectionFeature(canRead: false, canWrite: true)); var streamContext = await _multiplexedContext.ConnectAsync(features); - var httpConnectionContext = new Http3StreamContext( - _multiplexedContext.ConnectionId, - HttpProtocols.Http3, - _context.AltSvcHeader, - _multiplexedContext, - _context.ServiceContext, - streamContext.Features, - _context.MemoryPool, - streamContext.LocalEndPoint as IPEndPoint, - streamContext.RemoteEndPoint as IPEndPoint, - _streamLifetimeHandler, - streamContext, - _clientSettings, - _serverSettings); - httpConnectionContext.TimeoutControl = _context.TimeoutControl; - httpConnectionContext.Transport = streamContext.Transport; + var httpConnectionContext = CreateHttpStreamContext(streamContext); - return new Http3ControlStream<TContext>(application, httpConnectionContext); + return new Http3ControlStream<TContext>(application, httpConnectionContext, 0L); } private async ValueTask<FlushResult> SendGoAwayAsync(long id) @@ -614,6 +738,15 @@ internal sealed class Http3Connection : IHttp3StreamLifetimeHandler, IRequestPro } } + void IHttp3StreamLifetimeHandler.OnUnidentifiedStreamReceived(Http3PendingStream stream) + { + lock (_unidentifiedStreams) + { + // place in a pending stream dictionary so we can track it (and timeout if necessary) as we don't have a proper stream instance yet + _unidentifiedStreams.Add(stream.StreamId, stream); + } + } + void IHttp3StreamLifetimeHandler.OnStreamCreated(IHttp3Stream stream) { lock (_streams) @@ -698,6 +831,21 @@ internal sealed class Http3Connection : IHttp3StreamLifetimeHandler, IRequestPro Abort(new ConnectionAbortedException(CoreStrings.ConnectionAbortedByClient), (Http3ErrorCode)_errorCodeFeature.Error); } + internal WebTransportSession OpenNewWebTransportSession(Http3Stream http3Stream) + { + Debug.Assert(_context.ServiceContext.ServerOptions.EnableWebTransportAndH3Datagrams); + + WebTransportSession session; + lock (_webtransportSessions!) + { + Debug.Assert(!_webtransportSessions.ContainsKey(http3Stream.StreamId)); + + session = new WebTransportSession(this, http3Stream); + _webtransportSessions[http3Stream.StreamId] = session; + } + return session; + } + private static class GracefulCloseInitiator { public const int None = 0; diff --git a/src/Servers/Kestrel/Core/src/Internal/Http3/Http3ControlStream.cs b/src/Servers/Kestrel/Core/src/Internal/Http3/Http3ControlStream.cs index e9115e5282a33c09bb8eae6a48484b6a7445bade..38b083580e06f65c44ca06133e3a36d537a8de08 100644 --- a/src/Servers/Kestrel/Core/src/Internal/Http3/Http3ControlStream.cs +++ b/src/Servers/Kestrel/Core/src/Internal/Http3/Http3ControlStream.cs @@ -26,21 +26,21 @@ internal abstract class Http3ControlStream : IHttp3Stream, IThreadPoolWorkItem private readonly IProtocolErrorCodeFeature _errorCodeFeature; private readonly Http3RawFrame _incomingFrame = new Http3RawFrame(); private volatile int _isClosed; - private int _gracefulCloseInitiator; private long _headerType; + private int _gracefulCloseInitiator; private bool _haveReceivedSettingsFrame; public long StreamId => _streamIdFeature.StreamId; - public Http3ControlStream(Http3StreamContext context) + public Http3ControlStream(Http3StreamContext context, long? headerType) { var httpLimits = context.ServiceContext.ServerOptions.Limits; _context = context; _serverPeerSettings = context.ServerPeerSettings; _streamIdFeature = context.ConnectionFeatures.GetRequiredFeature<IStreamIdFeature>(); _errorCodeFeature = context.ConnectionFeatures.GetRequiredFeature<IProtocolErrorCodeFeature>(); - _headerType = -1; + _headerType = headerType ?? -1; _frameWriter = new Http3FrameWriter( context.StreamContext, @@ -152,7 +152,16 @@ internal abstract class Http3ControlStream : IHttp3Stream, IThreadPoolWorkItem { try { - _headerType = await TryReadStreamHeaderAsync(); + // todo: the _headerType should be read earlier + // and by the Http3PendingStream. However, to + // avoid perf issues with the current implementation + // we can defer the reading until now + // (https://github.com/dotnet/aspnetcore/issues/42789) + if (_headerType == -1) + { + _headerType = await TryReadStreamHeaderAsync(); + } + _context.StreamLifetimeHandler.OnStreamHeaderReceived(this); switch (_headerType) diff --git a/src/Servers/Kestrel/Core/src/Internal/Http3/Http3ControlStreamOfT.cs b/src/Servers/Kestrel/Core/src/Internal/Http3/Http3ControlStreamOfT.cs index eec22a5932da924ae5ba48ff11266333706ed68f..4131b7c21f058e47ff2bc9bedc3361bc1ce0f18a 100644 --- a/src/Servers/Kestrel/Core/src/Internal/Http3/Http3ControlStreamOfT.cs +++ b/src/Servers/Kestrel/Core/src/Internal/Http3/Http3ControlStreamOfT.cs @@ -10,7 +10,7 @@ internal sealed class Http3ControlStream<TContext> : Http3ControlStream, IHostCo { private readonly IHttpApplication<TContext> _application; - public Http3ControlStream(IHttpApplication<TContext> application, Http3StreamContext context) : base(context) + public Http3ControlStream(IHttpApplication<TContext> application, Http3StreamContext context, long? headerType) : base(context, headerType) { _application = application; } diff --git a/src/Servers/Kestrel/Core/src/Internal/Http3/Http3PendingStream.cs b/src/Servers/Kestrel/Core/src/Internal/Http3/Http3PendingStream.cs new file mode 100644 index 0000000000000000000000000000000000000000..adc7d5bb56dd18e84ba61b0c9035c5cede90d66d --- /dev/null +++ b/src/Servers/Kestrel/Core/src/Internal/Http3/Http3PendingStream.cs @@ -0,0 +1,103 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net.Http; +using Microsoft.AspNetCore.Connections; + +namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http3; + +internal sealed class Http3PendingStream +{ + private ConnectionAbortedException? _abortedException; + private bool _isClosed; + + internal readonly Http3StreamContext Context; + internal readonly long StreamId; + internal long StreamTimeoutTicks; + + public Http3PendingStream(Http3StreamContext context, long id) + { + Context = context; + StreamTimeoutTicks = default; + StreamId = id; + } + + public void Abort(ConnectionAbortedException exception) + { + if (_isClosed) + { + return; + } + _isClosed = true; + + _abortedException = exception; + + Context.Transport.Input.CancelPendingRead(); + Context.Transport.Input.Complete(exception); + Context.Transport.Output.Complete(exception); + } + + public async ValueTask<long> ReadNextStreamHeaderAsync(Http3StreamContext context, long streamId, Http3StreamType? advanceOn) + { + var Input = context.Transport.Input; + var advance = false; + SequencePosition consumed = default; + SequencePosition start = default; + try + { + while (!_isClosed) + { + var result = await Input.ReadAsync(); + + if (result.IsCanceled) + { + throw new Exception(); + } + + var readableBuffer = result.Buffer; + consumed = readableBuffer.Start; + start = readableBuffer.Start; + + if (!readableBuffer.IsEmpty) + { + var value = VariableLengthIntegerHelper.GetInteger(readableBuffer, out consumed, out _); + if (value != -1) + { + if (!advanceOn.HasValue || value == (long)advanceOn) + { + advance = true; + } + return value; + } + } + + if (result.IsCompleted) + { + return -1L; + } + } + } + catch (Exception) + { + throw new Http3PendingStreamException(CoreStrings.AttemptedToReadHeaderOnAbortedStream, streamId, _abortedException); + } + finally + { + if (!_isClosed) + { + if (advance) + { + Input.AdvanceTo(consumed); + } + else + { + Input.AdvanceTo(start); + } + } + + StreamTimeoutTicks = default; + } + + return -1L; + } +} diff --git a/src/Servers/Kestrel/Core/src/Internal/Http3/Http3PendingStreamException.cs b/src/Servers/Kestrel/Core/src/Internal/Http3/Http3PendingStreamException.cs new file mode 100644 index 0000000000000000000000000000000000000000..97e138200605ed92ce33952254b02808d78767e9 --- /dev/null +++ b/src/Servers/Kestrel/Core/src/Internal/Http3/Http3PendingStreamException.cs @@ -0,0 +1,15 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http3; + +internal sealed class Http3PendingStreamException : Exception +{ + public Http3PendingStreamException(string message, long streamId, Exception? innerException = null) + : base($"HTTP/3 stream error while trying to identify stream {streamId}: {message}", innerException) + { + StreamId = streamId; + } + + public long StreamId { get; } +} diff --git a/src/Servers/Kestrel/Core/src/Internal/Http3/Http3Stream.cs b/src/Servers/Kestrel/Core/src/Internal/Http3/Http3Stream.cs index 6ab76daa2b43ad7988d5b2c41212cc907a14725e..5f3a7dc23de80c4f0498c06e29d6e949d5531565 100644 --- a/src/Servers/Kestrel/Core/src/Internal/Http3/Http3Stream.cs +++ b/src/Servers/Kestrel/Core/src/Internal/Http3/Http3Stream.cs @@ -15,6 +15,7 @@ using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Internal; using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http; using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Infrastructure; +using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.WebTransport; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Primitives; using HttpCharacters = Microsoft.AspNetCore.Http.HttpCharacters; @@ -45,39 +46,35 @@ internal abstract partial class Http3Stream : HttpProtocol, IHttp3Stream, IHttpS private IProtocolErrorCodeFeature _errorCodeFeature = default!; private IStreamIdFeature _streamIdFeature = default!; private IStreamAbortFeature _streamAbortFeature = default!; - private int _isClosed; - private readonly Http3RawFrame _incomingFrame = new Http3RawFrame(); - protected RequestHeaderParsingState _requestHeaderParsingState; private PseudoHeaderFields _parsedPseudoHeaderFields; + private StreamCompletionFlags _completionState; + private int _isClosed; private int _totalParsedHeaderSize; private bool _isMethodConnect; + private bool _isWebTransportSessionAccepted; private Http3MessageBody? _messageBody; - private readonly ManualResetValueTaskSource<object?> _appCompletedTaskSource = new ManualResetValueTaskSource<object?>(); + private readonly ManualResetValueTaskSource<object?> _appCompletedTaskSource = new(); + private readonly object _completionLock = new(); - private StreamCompletionFlags _completionState; - private readonly object _completionLock = new object(); + protected RequestHeaderParsingState _requestHeaderParsingState; + protected readonly Http3RawFrame _incomingFrame = new(); public bool EndStreamReceived => (_completionState & StreamCompletionFlags.EndStreamReceived) == StreamCompletionFlags.EndStreamReceived; private bool IsAborted => (_completionState & StreamCompletionFlags.Aborted) == StreamCompletionFlags.Aborted; private bool IsCompleted => (_completionState & StreamCompletionFlags.Completed) == StreamCompletionFlags.Completed; public Pipe RequestBodyPipe { get; private set; } = default!; - public long? InputRemaining { get; internal set; } - public QPackDecoder QPackDecoder { get; private set; } = default!; public PipeReader Input => _context.Transport.Input; - public ISystemClock SystemClock => _context.ServiceContext.SystemClock; public KestrelServerLimits Limits => _context.ServiceContext.ServerOptions.Limits; public long StreamId => _streamIdFeature.StreamId; - public long StreamTimeoutTicks { get; set; } public bool IsReceivingHeader => _requestHeaderParsingState <= RequestHeaderParsingState.Headers; // Assigned once headers are received public bool IsDraining => _appCompletedTaskSource.GetStatus() != ValueTaskSourceStatus.Pending; // Draining starts once app is complete - public bool IsRequestStream => true; public void Initialize(Http3StreamContext context) @@ -164,6 +161,8 @@ internal abstract partial class Http3Stream : HttpProtocol, IHttp3Stream, IHttpS abortReason = new ConnectionAbortedException(exception.Message, exception); } + _context.WebTransportSession?.Abort(abortReason, errorCode); + Log.Http3StreamAbort(TraceIdentifier, errorCode, abortReason); // Call _http3Output.Stop() prior to poisoning the request body stream or pipe to @@ -704,6 +703,9 @@ internal abstract partial class Http3Stream : HttpProtocol, IHttp3Stream, IHttpS ApplyCompletionFlag(StreamCompletionFlags.Completed); _context.StreamLifetimeHandler.OnStreamCompleted(this); + // If we have a webtransport session on this stream, end it + _context.WebTransportSession?.OnClientConnectionClosed(); + // TODO this is a hack for .NET 6 pooling. // // Pooling needs to happen after transports have been drained and stream @@ -741,31 +743,30 @@ internal abstract partial class Http3Stream : HttpProtocol, IHttp3Stream, IHttpS } } + _context.WebTransportSession?.OnClientConnectionClosed(); + OnTrailersComplete(); return RequestBodyPipe.Writer.CompleteAsync(); } private Task ProcessHttp3Stream<TContext>(IHttpApplication<TContext> application, in ReadOnlySequence<byte> payload, bool isCompleted) where TContext : notnull { - switch (_incomingFrame.Type) - { - case Http3FrameType.Data: - return ProcessDataFrameAsync(payload); - case Http3FrameType.Headers: - return ProcessHeadersFrameAsync(application, payload, isCompleted); - case Http3FrameType.Settings: - case Http3FrameType.CancelPush: - case Http3FrameType.GoAway: - case Http3FrameType.MaxPushId: - // https://quicwg.org/base-drafts/draft-ietf-quic-http.html#section-7.2.4 - // These frames need to be on a control stream - throw new Http3ConnectionErrorException(CoreStrings.FormatHttp3ErrorUnsupportedFrameOnRequestStream(_incomingFrame.FormattedType), Http3ErrorCode.UnexpectedFrame); - case Http3FrameType.PushPromise: - // The server should never receive push promise - throw new Http3ConnectionErrorException(CoreStrings.FormatHttp3ErrorUnsupportedFrameOnServer(_incomingFrame.FormattedType), Http3ErrorCode.UnexpectedFrame); - default: - return ProcessUnknownFrameAsync(); - } + return _incomingFrame.Type switch + { + Http3FrameType.Data => ProcessDataFrameAsync(payload), + Http3FrameType.Headers => ProcessHeadersFrameAsync(application, payload, isCompleted), + // https://quicwg.org/base-drafts/draft-ietf-quic-http.html#section-7.2.4 + // These frames need to be on a control stream + Http3FrameType.Settings or + Http3FrameType.CancelPush or + Http3FrameType.GoAway or + Http3FrameType.MaxPushId => throw new Http3ConnectionErrorException( + CoreStrings.FormatHttp3ErrorUnsupportedFrameOnRequestStream(_incomingFrame.FormattedType), Http3ErrorCode.UnexpectedFrame), + // The server should never receive push promise + Http3FrameType.PushPromise => throw new Http3ConnectionErrorException( + CoreStrings.FormatHttp3ErrorUnsupportedFrameOnServer(_incomingFrame.FormattedType), Http3ErrorCode.UnexpectedFrame), + _ => ProcessUnknownFrameAsync(), + }; } private static Task ProcessUnknownFrameAsync() @@ -843,6 +844,15 @@ internal abstract partial class Http3Stream : HttpProtocol, IHttp3Stream, IHttpS { throw new Http3StreamErrorException(CoreStrings.FormatHttp3DatagramStatusMismatch(_context.ClientPeerSettings.H3Datagram == 1, _context.ServerPeerSettings.H3Datagram == 1), Http3ErrorCode.SettingsError); } + + if (string.Equals(HttpRequestHeaders.HeaderProtocol, WebTransportSession.WebTransportProtocolValue, StringComparison.Ordinal)) + { + // if the client supports the same version of WebTransport as Kestrel, make this a WebTransport request + if (((AspNetCore.Http.IHeaderDictionary)HttpRequestHeaders).TryGetValue(WebTransportSession.CurrentSuppportedVersion, out var version) && string.Equals(version, WebTransportSession.VersionEnabledIndicator, StringComparison.Ordinal)) + { + IsWebTransportRequest = true; + } + } } else if (!_isMethodConnect && (_parsedPseudoHeaderFields & _mandatoryRequestPseudoHeaderFields) != _mandatoryRequestPseudoHeaderFields) { @@ -901,6 +911,8 @@ internal abstract partial class Http3Stream : HttpProtocol, IHttp3Stream, IHttpS _keepAlive = true; _connectionAborted = false; _userTrailers = null; + _isWebTransportSessionAccepted = false; + _isMethodConnect = false; // Reset Http3 Features _currentIHttpMinRequestBodyDataRateFeature = this; @@ -1168,11 +1180,47 @@ internal abstract partial class Http3Stream : HttpProtocol, IHttp3Stream, IHttpS } } + public override async ValueTask<IWebTransportSession> AcceptAsync(CancellationToken token) + { + if (_isWebTransportSessionAccepted) + { + throw new InvalidOperationException(CoreStrings.AcceptCannotBeCalledMultipleTimes); + } + + if (!_context.ServiceContext.ServerOptions.EnableWebTransportAndH3Datagrams) + { + throw new InvalidOperationException(CoreStrings.WebTransportIsDisabled); + } + + if (!IsWebTransportRequest) + { + throw new InvalidOperationException(CoreStrings.FormatFailedToNegotiateCommonWebTransportVersion(WebTransportSession.CurrentSuppportedVersion)); + } + + _isWebTransportSessionAccepted = true; + + // version negotiation + var version = WebTransportSession.CurrentSuppportedVersion[WebTransportSession.SecPrefix.Length..]; + + _context.WebTransportSession = _context.Connection!.OpenNewWebTransportSession(this); + + // send version negotiation resulting version + ResponseHeaders[WebTransportSession.VersionHeaderPrefix] = version; + await FlushAsync(token); + + return _context.WebTransportSession; + } + /// <summary> /// Used to kick off the request processing loop by derived classes. /// </summary> public abstract void Execute(); + public void Abort() + { + Abort(new(), Http3ErrorCode.RequestCancelled); + } + protected enum RequestHeaderParsingState { Ready, diff --git a/src/Servers/Kestrel/Core/src/Internal/Http3/Http3StreamContext.cs b/src/Servers/Kestrel/Core/src/Internal/Http3/Http3StreamContext.cs index b631d2cd89a0c356f05a98e47513203212385440..1b59bf0763512e001e028cfd40eb818fe9ddeb56 100644 --- a/src/Servers/Kestrel/Core/src/Internal/Http3/Http3StreamContext.cs +++ b/src/Servers/Kestrel/Core/src/Internal/Http3/Http3StreamContext.cs @@ -6,6 +6,7 @@ using System.Net; using Microsoft.AspNetCore.Connections; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Infrastructure; +using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.WebTransport; namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http3; @@ -21,19 +22,20 @@ internal sealed class Http3StreamContext : HttpConnectionContext MemoryPool<byte> memoryPool, IPEndPoint? localEndPoint, IPEndPoint? remoteEndPoint, - IHttp3StreamLifetimeHandler streamLifetimeHandler, ConnectionContext streamContext, - Http3PeerSettings clientPeerSettings, - Http3PeerSettings serverPeerSettings) : base(connectionId, protocols, altSvcHeader, connectionContext, serviceContext, connectionFeatures, memoryPool, localEndPoint, remoteEndPoint) + Http3Connection connection) : base(connectionId, protocols, altSvcHeader, connectionContext, serviceContext, connectionFeatures, memoryPool, localEndPoint, remoteEndPoint) { - StreamLifetimeHandler = streamLifetimeHandler; + StreamLifetimeHandler = connection._streamLifetimeHandler; StreamContext = streamContext; - ClientPeerSettings = clientPeerSettings; - ServerPeerSettings = serverPeerSettings; + ClientPeerSettings = connection._clientSettings; + ServerPeerSettings = connection._serverSettings; + Connection = connection; } public IHttp3StreamLifetimeHandler StreamLifetimeHandler { get; } public ConnectionContext StreamContext { get; } public Http3PeerSettings ClientPeerSettings { get; } public Http3PeerSettings ServerPeerSettings { get; } + public WebTransportSession? WebTransportSession { get; set; } + public Http3Connection Connection { get; } } diff --git a/src/Servers/Kestrel/Core/src/Internal/Http3/Http3StreamOfT.cs b/src/Servers/Kestrel/Core/src/Internal/Http3/Http3StreamOfT.cs index 90f9c1d1fae4442e5f6a4cc6657b37cac31ecb4f..406f30eebb2fcbb9393b566319009c7beddbe569 100644 --- a/src/Servers/Kestrel/Core/src/Internal/Http3/Http3StreamOfT.cs +++ b/src/Servers/Kestrel/Core/src/Internal/Http3/Http3StreamOfT.cs @@ -21,13 +21,13 @@ internal sealed class Http3Stream<TContext> : Http3Stream, IHostContextContainer { KestrelEventSource.Log.RequestQueuedStop(this, AspNetCore.Http.HttpProtocol.Http3); - if (_requestHeaderParsingState == Http3Stream.RequestHeaderParsingState.Ready) + if (_requestHeaderParsingState == RequestHeaderParsingState.Ready) { _ = ProcessRequestAsync(_application); } else { - _ = base.ProcessRequestsAsync(_application); + _ = ProcessRequestsAsync(_application); } } diff --git a/src/Servers/Kestrel/Core/src/Internal/Http3/IHttp3StreamLifetimeHandler.cs b/src/Servers/Kestrel/Core/src/Internal/Http3/IHttp3StreamLifetimeHandler.cs index e960412a409705b268252511ad1dd590abaad4ab..eeb1e311061098a24cf5107c9e870acad37a1706 100644 --- a/src/Servers/Kestrel/Core/src/Internal/Http3/IHttp3StreamLifetimeHandler.cs +++ b/src/Servers/Kestrel/Core/src/Internal/Http3/IHttp3StreamLifetimeHandler.cs @@ -5,6 +5,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http3; internal interface IHttp3StreamLifetimeHandler { + void OnUnidentifiedStreamReceived(Http3PendingStream stream); void OnStreamCreated(IHttp3Stream stream); void OnStreamHeaderReceived(IHttp3Stream stream); void OnStreamCompleted(IHttp3Stream stream); diff --git a/src/Servers/Kestrel/Core/src/Internal/WebTransport/WebTransportSession.cs b/src/Servers/Kestrel/Core/src/Internal/WebTransport/WebTransportSession.cs new file mode 100644 index 0000000000000000000000000000000000000000..6b0d2fdc7e2cb7273ebbed18c49f5431225ee132 --- /dev/null +++ b/src/Servers/Kestrel/Core/src/Internal/WebTransport/WebTransportSession.cs @@ -0,0 +1,192 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Net.Http; +using System.Threading.Channels; +using Microsoft.AspNetCore.Connections; +using Microsoft.AspNetCore.Connections.Features; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http3; +using Microsoft.AspNetCore.Server.Kestrel.Core.WebTransport; + +namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.WebTransport; + +internal sealed class WebTransportSession : IWebTransportSession +{ + private static readonly IStreamDirectionFeature _outputStreamDirectionFeature = new DefaultStreamDirectionFeature(canRead: false, canWrite: true); + + private readonly CancellationTokenRegistration _connectionClosedRegistration; + + // stores all created streams (pending or accepted) + private readonly ConcurrentDictionary<long, WebTransportStream> _openStreams = new(); + // stores all pending streams that have not been accepted yet + private readonly Channel<WebTransportStream> _pendingStreams; + + private readonly Http3Connection _connection; + private readonly Http3Stream _connectStream = default!; + private bool _isClosing; + + private static readonly ReadOnlyMemory<byte> OutputStreamHeader = new(new byte[] { + 0x40 /*quic variable-length integer length*/, + (byte)Http3StreamType.WebTransportUnidirectional, + 0x00 /*body*/}); + + internal const string WebTransportProtocolValue = "webtransport"; + internal const string VersionEnabledIndicator = "1"; + internal const string SecPrefix = "sec-webtransport-http3-"; + internal const string VersionHeaderPrefix = $"{SecPrefix}draft"; + internal const string CurrentSuppportedVersion = $"{VersionHeaderPrefix}02"; + + public long SessionId => _connectStream.StreamId; + + internal WebTransportSession(Http3Connection connection, Http3Stream connectStream) + { + _connection = connection; + _connectStream = connectStream; + _isClosing = false; + // unbounded as limits to number of streams is enforced elsewhere + _pendingStreams = Channel.CreateUnbounded<WebTransportStream>(); + + // listener to abort if this connection is closed + _connectionClosedRegistration = connection._multiplexedContext.ConnectionClosed.Register(static state => + { + var session = (WebTransportSession)state!; + session.OnClientConnectionClosed(); + }, this); + } + + void IWebTransportSession.Abort(int errorCode) + { + Abort(new(), (Http3ErrorCode)errorCode); + } + + internal void OnClientConnectionClosed() + { + if (_isClosing) + { + return; + } + + _isClosing = true; + + _connectionClosedRegistration.Dispose(); + + lock (_openStreams) + { + foreach (var stream in _openStreams) + { + stream.Value.DisposeAsync().AsTask().GetAwaiter().GetResult(); + } + + _openStreams.Clear(); + } + + _pendingStreams.Writer.Complete(); + } + + internal void Abort(ConnectionAbortedException exception, Http3ErrorCode error) + { + if (_isClosing) + { + return; + } + + _isClosing = true; + + _connectionClosedRegistration.Dispose(); + + lock (_openStreams) + { + _connectStream.Abort(exception, error); + foreach (var stream in _openStreams) + { + if (exception.InnerException is not null) + { + stream.Value.Abort(new ConnectionAbortedException(exception.Message, exception.InnerException)); + } + else + { + stream.Value.Abort(new ConnectionAbortedException(exception.Message)); + } + } + _openStreams.Clear(); + } + + _pendingStreams.Writer.Complete(); + } + + public async ValueTask<ConnectionContext?> OpenUnidirectionalStreamAsync(CancellationToken cancellationToken) + { + if (_isClosing) + { + return null; + } + // create the stream + var features = new FeatureCollection(); + features.Set(_outputStreamDirectionFeature); + var connectionContext = await _connection._multiplexedContext.ConnectAsync(features, cancellationToken); + var streamContext = _connection.CreateHttpStreamContext(connectionContext); + var stream = new WebTransportStream(streamContext, WebTransportStreamType.Output); + + var success = _openStreams.TryAdd(stream.StreamId, stream); + Debug.Assert(success); + + // send the stream header + // https://ietf-wg-webtrans.github.io/draft-ietf-webtrans-http3/draft-ietf-webtrans-http3.html#name-unidirectional-streams + await stream.Transport.Output.WriteAsync(OutputStreamHeader, cancellationToken); + + return stream; + } + + internal void AddStream(WebTransportStream stream) + { + if (_isClosing) + { + stream.Abort(); + return; + } + + var addedToOpenStreams = _openStreams.TryAdd(stream.StreamId, stream); + + if (!addedToOpenStreams || !_pendingStreams.Writer.TryWrite(stream)) + { + if (addedToOpenStreams) + { + _openStreams.Remove(stream.StreamId, out _); + } + + stream.Abort(new ConnectionAbortedException(CoreStrings.WebTransportFailedToAddStreamToPendingQueue)); + } + } + + public async ValueTask<ConnectionContext?> AcceptStreamAsync(CancellationToken cancellationToken) + { + if (_isClosing) + { + return null; + } + + try + { + return await _pendingStreams.Reader.ReadAsync(cancellationToken); + } + catch (ChannelClosedException) + { + return null; + } + } + + internal bool TryRemoveStream(long streamId) + { + var success = _openStreams.Remove(streamId, out var stream); + + if (success && stream is not null) + { + stream.DisposeAsync().AsTask().GetAwaiter().GetResult(); + } + + return success; + } +} diff --git a/src/Servers/Kestrel/Core/src/Internal/WebTransport/WebTransportStream.cs b/src/Servers/Kestrel/Core/src/Internal/WebTransport/WebTransportStream.cs new file mode 100644 index 0000000000000000000000000000000000000000..aa4b1cd885b2f9ff49a4f9a70f40943b66503d76 --- /dev/null +++ b/src/Servers/Kestrel/Core/src/Internal/WebTransport/WebTransportStream.cs @@ -0,0 +1,120 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Globalization; +using System.IO.Pipelines; +using System.Net.Http; +using Microsoft.AspNetCore.Connections; +using Microsoft.AspNetCore.Connections.Features; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http3; +using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Infrastructure; +using Microsoft.AspNetCore.Server.Kestrel.Core.WebTransport; + +namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.WebTransport; + +internal sealed class WebTransportStream : ConnectionContext, IStreamDirectionFeature, IStreamIdFeature, IConnectionItemsFeature +{ + private readonly CancellationTokenRegistration _connectionClosedRegistration; + private readonly bool _canWrite; + private readonly bool _canRead; + private readonly DuplexPipe _duplexPipe; + private readonly IFeatureCollection _features; + private readonly KestrelTrace _log; + private readonly long _streamId; + private IDictionary<object, object?>? _items; + private bool _isClosed; + + public override string ConnectionId { get => _streamId.ToString(NumberFormatInfo.InvariantInfo); set => throw new NotSupportedException(); } + + public override IDuplexPipe Transport { get => _duplexPipe; set => throw new NotSupportedException(); } + + public override IFeatureCollection Features => _features; + + public override IDictionary<object, object?> Items + { + get => _items ??= new ConnectionItems(); + set => _items = value; + } + + public long StreamId => _streamId; + + public bool CanRead => _canRead && !_isClosed; + + public bool CanWrite => _canWrite && !_isClosed; + + internal WebTransportStream(Http3StreamContext context, WebTransportStreamType type) + { + _canRead = type != WebTransportStreamType.Output; + _canWrite = type != WebTransportStreamType.Input; + _log = context.ServiceContext.Log; + + var streamIdFeature = context.ConnectionFeatures.GetRequiredFeature<IStreamIdFeature>(); + _streamId = streamIdFeature!.StreamId; + + _features = context.ConnectionFeatures; + _features.Set<IStreamDirectionFeature>(this); + _features.Set<IStreamIdFeature>(this); + _features.Set<IConnectionItemsFeature>(this); + + _duplexPipe = new DuplexPipe(context.Transport.Input, context.Transport.Output); + + // will not trigger if closed only of of the directions of a stream. Stream must be fully + // ended before this will be called. Then it will be considered an abort + _connectionClosedRegistration = context.StreamContext.ConnectionClosed.Register(static state => + { + var localContext = (Http3StreamContext)state!; + // get the stream id here again to minimize allocations that would have been created + // if we pass stuff via a value tuple + var streamId = localContext.ConnectionFeatures.GetRequiredFeature<IStreamIdFeature>().StreamId; + + localContext.WebTransportSession?.TryRemoveStream(streamId); + }, context); + + ConnectionClosed = _connectionClosedRegistration.Token; + } + + public override void Abort(ConnectionAbortedException abortReason) + { + if (_isClosed) + { + return; + } + + _isClosed = true; + + _log.Http3StreamAbort(ConnectionId, Http3ErrorCode.InternalError, abortReason); + + if (_canRead) + { + _duplexPipe.Input.Complete(abortReason); + } + + if (_canWrite) + { + _duplexPipe.Output.Complete(abortReason); + } + } + + public override async ValueTask DisposeAsync() + { + if (_isClosed) + { + return; + } + + _isClosed = true; + + await _connectionClosedRegistration.DisposeAsync(); + + if (_canRead) + { + await _duplexPipe.Input.CompleteAsync(); + } + + if (_canWrite) + { + await _duplexPipe.Output.CompleteAsync(); + } + } +} diff --git a/src/Servers/Kestrel/Core/src/Internal/WebTransport/WebTransportStreamType.cs b/src/Servers/Kestrel/Core/src/Internal/WebTransport/WebTransportStreamType.cs new file mode 100644 index 0000000000000000000000000000000000000000..b471733ab5b12d328112f701214ba7ff1434797e --- /dev/null +++ b/src/Servers/Kestrel/Core/src/Internal/WebTransport/WebTransportStreamType.cs @@ -0,0 +1,23 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Server.Kestrel.Core.WebTransport; + +/// <summary> +/// Represents the different types of WebTransport streams. +/// </summary> +internal enum WebTransportStreamType +{ + /// <summary> + /// Represents a bidirectional WebTransport stream. + /// </summary> + Bidirectional, + /// <summary> + /// Represents a unidirectional inbound WebTransport stream. + /// </summary> + Input, + /// <summary> + /// Represents a unidirectional outbound WebTransport stream. + /// </summary> + Output, +} diff --git a/src/Servers/Kestrel/Core/src/Microsoft.AspNetCore.Server.Kestrel.Core.csproj b/src/Servers/Kestrel/Core/src/Microsoft.AspNetCore.Server.Kestrel.Core.csproj index e73fb110822ce9c1c325e725365d8723223589fd..1e2dda0405331738576fa2b3656b48cd6442f433 100644 --- a/src/Servers/Kestrel/Core/src/Microsoft.AspNetCore.Server.Kestrel.Core.csproj +++ b/src/Servers/Kestrel/Core/src/Microsoft.AspNetCore.Server.Kestrel.Core.csproj @@ -11,6 +11,8 @@ <DefineConstants>$(DefineConstants);KESTREL</DefineConstants> <NoWarn>$(NoWarn);IDE0060</NoWarn><!-- APIs in HTTP3 are work in progress and produce these warnings frequently--> <IsTrimmable>true</IsTrimmable> + <EnablePreviewFeatures>true</EnablePreviewFeatures> + <GenerateRequiresPreviewFeaturesAttribute>false</GenerateRequiresPreviewFeaturesAttribute> </PropertyGroup> <ItemGroup> diff --git a/src/Servers/Kestrel/Core/test/Http1/Http1HttpProtocolFeatureCollectionTests.cs b/src/Servers/Kestrel/Core/test/Http1/Http1HttpProtocolFeatureCollectionTests.cs index 8f46217172388a9b630b96c234162df7dd6d535b..3ffb325b618b22060f1ea5ffe84c1480fa57ce72 100644 --- a/src/Servers/Kestrel/Core/test/Http1/Http1HttpProtocolFeatureCollectionTests.cs +++ b/src/Servers/Kestrel/Core/test/Http1/Http1HttpProtocolFeatureCollectionTests.cs @@ -127,6 +127,7 @@ public class Http1HttpProtocolFeatureCollectionTests _collection[typeof(IHttpExtendedConnectFeature)] = CreateHttp1Connection(); _collection[typeof(IHttpUpgradeFeature)] = CreateHttp1Connection(); _collection[typeof(IPersistentStateFeature)] = CreateHttp1Connection(); + _collection.Set<IHttpWebTransportFeature>(CreateHttp1Connection()); CompareGenericGetterToIndexer(); @@ -154,6 +155,7 @@ public class Http1HttpProtocolFeatureCollectionTests _collection.Set<IHttpExtendedConnectFeature>(CreateHttp1Connection()); _collection.Set<IHttpUpgradeFeature>(CreateHttp1Connection()); _collection.Set<IPersistentStateFeature>(CreateHttp1Connection()); + _collection.Set<IHttpWebTransportFeature>(CreateHttp1Connection()); CompareGenericGetterToIndexer(); diff --git a/src/Servers/Kestrel/Kestrel.slnf b/src/Servers/Kestrel/Kestrel.slnf index 2d90eb4baf0eaae5d7134bfaf1ab856ecdc29930..b40c80aafec68c7f77b4e1222c73f0b5b243f22c 100644 --- a/src/Servers/Kestrel/Kestrel.slnf +++ b/src/Servers/Kestrel/Kestrel.slnf @@ -38,6 +38,8 @@ "src\\Servers\\Kestrel\\samples\\PlaintextApp\\PlaintextApp.csproj", "src\\Servers\\Kestrel\\samples\\SampleApp\\Kestrel.SampleApp.csproj", "src\\Servers\\Kestrel\\samples\\SystemdTestApp\\SystemdTestApp.csproj", + "src\\Servers\\Kestrel\\samples\\WebTransportInteractiveSampleApp\\WebTransportInteractiveSampleApp.csproj", + "src\\Servers\\Kestrel\\samples\\WebTransportSampleApp\\WebTransportSampleApp.csproj", "src\\Servers\\Kestrel\\samples\\http2cat\\http2cat.csproj", "src\\Servers\\Kestrel\\stress\\HttpStress.csproj", "src\\Servers\\Kestrel\\test\\InMemory.FunctionalTests\\InMemory.FunctionalTests.csproj", diff --git a/src/Servers/Kestrel/perf/Microbenchmarks/Http3/Http3ConnectionBenchmarkBase.cs b/src/Servers/Kestrel/perf/Microbenchmarks/Http3/Http3ConnectionBenchmarkBase.cs index 1398b4b185f5a02915501ab0331de15df05c5936..f2abd7a0014d97f4fcc945de9d1fcebc02d1225a 100644 --- a/src/Servers/Kestrel/perf/Microbenchmarks/Http3/Http3ConnectionBenchmarkBase.cs +++ b/src/Servers/Kestrel/perf/Microbenchmarks/Http3/Http3ConnectionBenchmarkBase.cs @@ -70,7 +70,7 @@ public abstract class Http3ConnectionBenchmarkBase { _requestHeadersEnumerator.Initialize(_httpRequestHeaders); - var stream = await _http3.CreateRequestStream(_headerHandler); + var stream = await _http3.CreateRequestStream(_requestHeadersEnumerator, _headerHandler); await stream.SendHeadersAsync(_requestHeadersEnumerator); diff --git a/src/Servers/Kestrel/samples/Http3SampleApp/Http3SampleApp.csproj b/src/Servers/Kestrel/samples/Http3SampleApp/Http3SampleApp.csproj index e65103ddb5a1093d76060bb0c9f22ec85b35df0c..c56f7fb39162b34fb5385d0f851055349e1d6985 100644 --- a/src/Servers/Kestrel/samples/Http3SampleApp/Http3SampleApp.csproj +++ b/src/Servers/Kestrel/samples/Http3SampleApp/Http3SampleApp.csproj @@ -16,9 +16,4 @@ <Reference Include="Microsoft.Extensions.Hosting" /> <Reference Include="Microsoft.AspNetCore.Server.Kestrel.Transport.Quic" /> </ItemGroup> - - <ItemGroup> - <!-- Turn on the WebTransport AppContext switch --> - <RuntimeHostConfigurationOption Include="Microsoft.AspNetCore.Server.Kestrel.Experimental.WebTransportAndH3Datagrams" Value="true" /> - </ItemGroup> </Project> diff --git a/src/Servers/Kestrel/samples/Http3SampleApp/Program.cs b/src/Servers/Kestrel/samples/Http3SampleApp/Program.cs index c65b05bec00ba7954ccfebd270749702c49fad97..d4a810bb386e526ae1520fdc074c2ebd2ea27a05 100644 --- a/src/Servers/Kestrel/samples/Http3SampleApp/Program.cs +++ b/src/Servers/Kestrel/samples/Http3SampleApp/Program.cs @@ -103,14 +103,6 @@ public class Program listenOptions.UseConnectionLogging(); listenOptions.Protocols = HttpProtocols.Http1AndHttp2; }); - - // Port configured for WebTransport - options.Listen(IPAddress.Any, 5007, listenOptions => - { - listenOptions.UseHttps(GenerateManualCertificate()); - listenOptions.UseConnectionLogging(); - listenOptions.Protocols = HttpProtocols.Http1AndHttp2AndHttp3; - }); }) .UseStartup<Startup>(); }); @@ -122,54 +114,4 @@ public class Program host.Run(); } - - // Adapted from: https://github.com/wegylexy/webtransport - // We will need to eventually merge this with existing Kestrel certificate generation - // tracked in issue #41762 - private static X509Certificate2 GenerateManualCertificate() - { - X509Certificate2 cert = null; - var store = new X509Store("KestrelWebTransportCertificates", StoreLocation.CurrentUser); - store.Open(OpenFlags.ReadWrite); - if (store.Certificates.Count > 0) - { - cert = store.Certificates[^1]; - - // rotate key after it expires - if (DateTime.Parse(cert.GetExpirationDateString(), null) < DateTimeOffset.UtcNow) - { - cert = null; - } - } - if (cert == null) - { - // generate a new cert - var now = DateTimeOffset.UtcNow; - SubjectAlternativeNameBuilder sanBuilder = new(); - sanBuilder.AddDnsName("localhost"); - using var ec = ECDsa.Create(ECCurve.NamedCurves.nistP256); - CertificateRequest req = new("CN=localhost", ec, HashAlgorithmName.SHA256); - // Adds purpose - req.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(new OidCollection - { - new("1.3.6.1.5.5.7.3.1") // serverAuth - }, false)); - // Adds usage - req.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, false)); - // Adds subject alternate names - req.CertificateExtensions.Add(sanBuilder.Build()); - // Sign - using var crt = req.CreateSelfSigned(now, now.AddDays(14)); // 14 days is the max duration of a certificate for this - cert = new(crt.Export(X509ContentType.Pfx)); - - // Save - store.Add(cert); - } - store.Close(); - - var hash = SHA256.HashData(cert.RawData); - var certStr = Convert.ToBase64String(hash); - Console.WriteLine($"\n\n\n\n\nCertificate: {certStr}\n\n\n\n"); // <-- you will need to put this output into the JS API call to allo wthe connection - return cert; - } } diff --git a/src/Servers/Kestrel/samples/Http3SampleApp/Startup.cs b/src/Servers/Kestrel/samples/Http3SampleApp/Startup.cs index e8b1fc3224e4c940baf1cd776c1f61e918095ebc..7a8727e8ff7ad175d80e4837759edcfde71c639b 100644 --- a/src/Servers/Kestrel/samples/Http3SampleApp/Startup.cs +++ b/src/Servers/Kestrel/samples/Http3SampleApp/Startup.cs @@ -1,6 +1,9 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.IO; +using Microsoft.AspNetCore.Http.Features; + namespace Http3SampleApp; public class Startup diff --git a/src/Servers/Kestrel/samples/Http3SampleApp/appsettings.Development.json b/src/Servers/Kestrel/samples/Http3SampleApp/appsettings.Development.json index e203e9407e74a6b9662aab8fde5d73ae64665f18..331805c55717ed34effe842a9caa585d7331e7eb 100644 --- a/src/Servers/Kestrel/samples/Http3SampleApp/appsettings.Development.json +++ b/src/Servers/Kestrel/samples/Http3SampleApp/appsettings.Development.json @@ -2,8 +2,8 @@ "Logging": { "LogLevel": { "Default": "Debug", - "System": "Information", - "Microsoft": "Information" + "System": "Debug", + "Microsoft": "Debug" } } } diff --git a/src/Servers/Kestrel/samples/WebTransportInteractiveSampleApp/Program.cs b/src/Servers/Kestrel/samples/WebTransportInteractiveSampleApp/Program.cs new file mode 100644 index 0000000000000000000000000000000000000000..01fb023901fd3007a7110668c5788ec649e88c51 --- /dev/null +++ b/src/Servers/Kestrel/samples/WebTransportInteractiveSampleApp/Program.cs @@ -0,0 +1,201 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +#nullable enable + +using System.Net; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using Microsoft.AspNetCore.Connections; +using Microsoft.AspNetCore.Connections.Features; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Server.Kestrel.Core; + +var builder = WebApplication.CreateBuilder(args); + +// generate a certificate and hash to be shared with the client +var certificate = GenerateManualCertificate(); +var hash = SHA256.HashData(certificate.RawData); +var certStr = Convert.ToBase64String(hash); + +// configure the ports +builder.WebHost.ConfigureKestrel((context, options) => +{ + // website configured port + options.Listen(IPAddress.Any, 5001, listenOptions => + { + listenOptions.UseHttps(); + listenOptions.Protocols = HttpProtocols.Http1AndHttp2; + }); + // webtransport configured port + options.Listen(IPAddress.Any, 5002, listenOptions => + { + listenOptions.UseHttps(certificate); + listenOptions.UseConnectionLogging(); + listenOptions.Protocols = HttpProtocols.Http1AndHttp2AndHttp3; + }); +}); + +var app = builder.Build(); + +// make index.html accessible +app.UseFileServer(); + +app.Use(async (context, next) => +{ + // configure /certificate.js to inject the certificate hash + if (context.Request.Path.Value?.Equals("/certificate.js") ?? false) + { + context.Response.ContentType = "application/javascript"; + await context.Response.WriteAsync($"var CERTIFICATE = '{certStr}';"); + } + + // configure the serverside application + else + { + var feature = context.Features.GetRequiredFeature<IHttpWebTransportFeature>(); + if (!feature.IsWebTransportRequest) + { + await next(context); + } + + var session = await feature.AcceptAsync(CancellationToken.None); + + if (session is null) + { + return; + } + + while (true) + { + ConnectionContext? stream = null; + IStreamDirectionFeature? direction = null; + // wait until we get a stream + stream = await session.AcceptStreamAsync(CancellationToken.None); + if (stream is not null) + { + direction = stream.Features.GetRequiredFeature<IStreamDirectionFeature>(); + if (direction.CanRead && direction.CanWrite) + { + _ = handleBidirectionalStream(session, stream); + } + else + { + _ = handleUnidirectionalStream(session, stream); + } + } + } + } +}); + +await app.RunAsync(); + +static async Task handleUnidirectionalStream(IWebTransportSession session, ConnectionContext stream) +{ + var inputPipe = stream.Transport.Input; + + // read some data from the stream into the memory + var memory = new Memory<byte>(new byte[4096]); + while (!stream.ConnectionClosed.IsCancellationRequested) + { + var length = await inputPipe.AsStream().ReadAsync(memory); + + var message = Encoding.Default.GetString(memory[..length].ToArray()); + + await ApplySpecialCommands(session, message); + + Console.WriteLine("RECEIVED FROM CLIENT:"); + Console.WriteLine(message); + + } +} + +static async Task handleBidirectionalStream(IWebTransportSession session, ConnectionContext stream) +{ + var inputPipe = stream.Transport.Input; + var outputPipe = stream.Transport.Output; + + // read some data from the stream into the memory + var memory = new Memory<byte>(new byte[4096]); + while (!stream.ConnectionClosed.IsCancellationRequested) + { + var length = await inputPipe.AsStream().ReadAsync(memory); + + // slice to only keep the relevant parts of the memory + var outputMemory = memory[..length]; + + // handle special commands + await ApplySpecialCommands(session, Encoding.Default.GetString(outputMemory.ToArray())); + + // do some operations on the contents of the data + outputMemory.Span.Reverse(); + + // write back the data to the stream + await outputPipe.WriteAsync(outputMemory); + + memory.Span.Fill(0); + } +} + +static async Task ApplySpecialCommands(IWebTransportSession session, string message) +{ + switch (message) + { + case "Initiate Stream": + var stream = await session.OpenUnidirectionalStreamAsync(); + if (stream is not null) + { + await stream.Transport.Output.WriteAsync(new("Created a new stream from the client and sent this message then closing the stream."u8.ToArray())); + } + break; + case "Abort": + session.Abort(256 /*No error*/); + break; + default: + break; // in all other cases the string is not a special command + } +} + +// Adapted from: https://github.com/wegylexy/webtransport +// We will need to eventually merge this with existing Kestrel certificate generation +// tracked in issue #41762 +static X509Certificate2 GenerateManualCertificate() +{ + X509Certificate2 cert; + var store = new X509Store("KestrelSampleWebTransportCertificates", StoreLocation.CurrentUser); + store.Open(OpenFlags.ReadWrite); + if (store.Certificates.Count > 0) + { + cert = store.Certificates[^1]; + + // rotate key after it expires + if (DateTime.Parse(cert.GetExpirationDateString(), null) >= DateTimeOffset.UtcNow) + { + store.Close(); + return cert; + } + } + // generate a new cert + var now = DateTimeOffset.UtcNow; + SubjectAlternativeNameBuilder sanBuilder = new(); + sanBuilder.AddDnsName("localhost"); + using var ec = ECDsa.Create(ECCurve.NamedCurves.nistP256); + CertificateRequest req = new("CN=localhost", ec, HashAlgorithmName.SHA256); + // Adds purpose + req.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(new OidCollection + { + new("1.3.6.1.5.5.7.3.1") // serverAuth + }, false)); + // Adds usage + req.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, false)); + // Adds subject alternate names + req.CertificateExtensions.Add(sanBuilder.Build()); + // Sign + using var crt = req.CreateSelfSigned(now, now.AddDays(14)); // 14 days is the max duration of a certificate for this + cert = new(crt.Export(X509ContentType.Pfx)); + + // Save + store.Add(cert); + store.Close(); + return cert; +} diff --git a/src/Servers/Kestrel/samples/WebTransportInteractiveSampleApp/Properties/launchSettings.json b/src/Servers/Kestrel/samples/WebTransportInteractiveSampleApp/Properties/launchSettings.json new file mode 100644 index 0000000000000000000000000000000000000000..634e23b5a41838967791e8a1f0db471e97ed2184 --- /dev/null +++ b/src/Servers/Kestrel/samples/WebTransportInteractiveSampleApp/Properties/launchSettings.json @@ -0,0 +1,13 @@ +{ + "profiles": { + "WebTransportInteractiveSample": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:5001", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Servers/Kestrel/samples/WebTransportInteractiveSampleApp/WebTransportInteractiveSampleApp.csproj b/src/Servers/Kestrel/samples/WebTransportInteractiveSampleApp/WebTransportInteractiveSampleApp.csproj new file mode 100644 index 0000000000000000000000000000000000000000..c6814232ff07ba08cbe78ef50d9777b7713cdad6 --- /dev/null +++ b/src/Servers/Kestrel/samples/WebTransportInteractiveSampleApp/WebTransportInteractiveSampleApp.csproj @@ -0,0 +1,30 @@ +<Project Sdk="Microsoft.NET.Sdk.Web"> + + <PropertyGroup> + <TargetFramework>$(DefaultNetCoreTargetFramework)</TargetFramework> + <AspNetCoreHostingModel>OutOfProcess</AspNetCoreHostingModel> + <!-- Turn on preview features so we can use Http3 --> + <EnablePreviewFeatures>True</EnablePreviewFeatures> + </PropertyGroup> + + <ItemGroup> + <Reference Include="Microsoft.AspNetCore" /> + <Reference Include="Microsoft.AspNetCore.Connections.Abstractions" /> + <Reference Include="Microsoft.AspNetCore.Diagnostics" /> + <Reference Include="Microsoft.AspNetCore.Server.Kestrel" /> + <Reference Include="Microsoft.AspNetCore.StaticFiles" /> + <Reference Include="Microsoft.Extensions.Logging.Console" /> + </ItemGroup> + + <ItemGroup> + <Content Update="appsettings.json"> + <CopyToOutputDirectory>Always</CopyToOutputDirectory> + </Content> + </ItemGroup> + + <ItemGroup> + <!-- Turn on the WebTransport AppContext switch --> + <RuntimeHostConfigurationOption Include="Microsoft.AspNetCore.Server.Kestrel.Experimental.WebTransportAndH3Datagrams" Value="true" /> + </ItemGroup> + +</Project> diff --git a/src/Servers/Kestrel/samples/WebTransportInteractiveSampleApp/appsettings.Development.json b/src/Servers/Kestrel/samples/WebTransportInteractiveSampleApp/appsettings.Development.json new file mode 100644 index 0000000000000000000000000000000000000000..703489018ae938ca4c838456388987ccd29a1c73 --- /dev/null +++ b/src/Servers/Kestrel/samples/WebTransportInteractiveSampleApp/appsettings.Development.json @@ -0,0 +1,10 @@ +{ + "DetailedErrors": true, + "Logging": { + "LogLevel": { + "Default": "Debug", + "Microsoft": "Debug", + "Microsoft.AspNetCore": "Debug" + } + } +} diff --git a/src/Servers/Kestrel/samples/WebTransportInteractiveSampleApp/appsettings.json b/src/Servers/Kestrel/samples/WebTransportInteractiveSampleApp/appsettings.json new file mode 100644 index 0000000000000000000000000000000000000000..c3dfa9d8926cacfc13d591573fbefd0958d0a070 --- /dev/null +++ b/src/Servers/Kestrel/samples/WebTransportInteractiveSampleApp/appsettings.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/Servers/Kestrel/samples/WebTransportInteractiveSampleApp/wwwroot/index.html b/src/Servers/Kestrel/samples/WebTransportInteractiveSampleApp/wwwroot/index.html new file mode 100644 index 0000000000000000000000000000000000000000..152707c3252f3f1105f2a30f618bab61e0afb9a1 --- /dev/null +++ b/src/Servers/Kestrel/samples/WebTransportInteractiveSampleApp/wwwroot/index.html @@ -0,0 +1,366 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8" /> + <title>WebTransport Test Page</title> +</head> +<body> + <script src="certificate.js"></script> + + <div id="panel"> + <h1 id="title">WebTransport Test Page</h1> + <h3 id="stateLabel">Ready to connect...</h3> + <div> + <label for="connectionUrl">WebTransport Server URL:</label> + <input id="connectionUrl" value="https://127.0.0.1:5002" disabled /> + <p>Due to the need to synchronize certificates, you cannot modify the url at this time.</p> + <button id="connectButton" type="submit" onclick="connect()">Connect</button> + </div> + + <div id="communicationPanel" hidden> + <div> + <button id="closeStream" type="submit" onclick="closeActiveStream()">Close active stream</button> + <button id="closeConnection" type="submit" onclick="closeConnection()">Close connection</button> + <button id="createBidirectionalStream" type="submit" onclick="createStream('bidirectional')">Create bidirectional stream</button> + <button id="createUnidirectionalStream" type="submit" onclick="createStream('output unidirectional')">Create unidirectional stream</button> + </div> + <h2>Open Streams</h2> + <div id="streamsList"> + <p id="noStreams">Empty</p> + </div> + + <div> + <h2>Send Message</h2> + <textarea id="messageInput" name="text" rows="12" cols="50"></textarea> + <button id="sendMessageButton" type="submit" onclick="sendMessage()">Send</button> + </div> + </div> + </div> + + <div id="communicationLogPanel"> + <h2 style="text-align: center;">Communication Log</h2> + <table style="overflow: auto; max-height: 1000px;"> + <thead> + <tr> + <td>Timestamp</td> + <td>From</td> + <td>To</td> + <td>Data</td> + </tr> + </thead> + <tbody id="commsLog"> + </tbody> + </table> + </div> + + <script> + let connectionUrl = document.getElementById("connectionUrl"); + let messageInput = document.getElementById("messageInput"); + let stateLabel = document.getElementById("stateLabel"); + let commsLog = document.getElementById("commsLog"); + let streamsList = document.getElementById("streamsList"); + let communicationPanel = document.getElementById("communicationPanel"); + let noStreamsText = document.getElementById("noStreams"); + + let session; + let connected = false; + let openStreams = []; + + async function connect() { + if (connected) { + alert("Already connected!"); + return; + } + + communicationPanel.hidden = true; + stateLabel.innerHTML = "Ready to connect..."; + + session = new WebTransport(connectionUrl.value, { + serverCertificateHashes: [ + { + algorithm: "sha-256", + value: Uint8Array.from(atob(CERTIFICATE), c => c.charCodeAt(0)) + } + ] + }); + stateLabel.innerHTML = "Connecting..."; + + await session.ready; + + startListeningForIncomingStreams(); + + setConnection(true); + } + + async function closeConnection() { + if (!connected) { + alert("Not connected!"); + return; + } + + for (let i = 0; i < openStreams.length; i++) { + await closeStream(i); + } + + await session.close(); + + setConnection(false); + + openStreams = []; + updateOpenStreamsList(); + } + + function setConnection(state) { + connected = state; + communicationPanel.hidden = !state; + + let msg = state ? "Connected!" : "Disconnected!"; + stateLabel.innerHTML = msg; + addToCommsLog("Client", "Server", msg); + } + + function updateOpenStreamsList() { + streamsList.innerHTML = ""; + for (let i = 0; i < openStreams.length; i++) { + streamsList.innerHTML += '<div>' + + `<input type="radio" name = "streams" value = "${i}" id = "${i}" >` + + `<label for="${i}">${i} - ${openStreams[i].type}</label>` + + '</div >'; + } + + noStreamsText.hidden = openStreams.length > 0; + } + + async function closeActiveStream() { + let streamIndex = getActiveStreamIndex(); + await closeStream(streamIndex); + } + + async function closeStream(index) { + let stream = openStreams[index].stream; + if (!stream) { + return; + } + + // close the writable part of a stream if it exists + if (stream.writable) { + await stream.writable.close(); + } + + // close the readable part of a stream if it exists + if (stream.cancelReaderAndClose) { + await stream.cancelReaderAndClose(); + } + + // close the stream if it can be closed manually + if (stream.close) { + await stream.close(); + } + + // remove from the list of open streams + openStreams.splice(index, 1); + + updateOpenStreamsList(); + } + + async function startListeningForIncomingStreams() { + try { + let streamReader = session.incomingUnidirectionalStreams.getReader(); + let stream = await streamReader.read(); + if (stream.value && confirm("New incoming stream. Would you like to accept it?")) { + startListeningForIncomingData(stream.value, openStreams.length, "input unidirectional"); + // we don't add to the stream list here as the only stream type that we can receive is + // input. As we can't send data over these streams, there is no point in showing them to the user + } + } catch { + alert("Failed to accept incoming stream"); + } + } + + async function startListeningForIncomingData(stream, streamId, type) { + let reader = isBidirectional(type) ? stream.readable.getReader() : stream.getReader(); + + // define a function that we can use to abort the reading on this stream + var closed = false; + stream.cancelReaderAndClose = () => { + console.log(reader); + reader.cancel(); + reader.releaseLock(); + closed = true; + } + + // read loop for the stream + try { + while (true) { + let data = await reader.read(); + let msgOut = ""; + data.value.forEach(x => msgOut += String.fromCharCode(x)); + addToCommsLog("Server", "Client", `RECEIVED FROM STREAM ${streamId}: ${msgOut}`); + } + } catch { + alert(`Stream ${streamId} ${closed ? "was closed" : "failed to read"}. Ending reading from it.`); + } + } + + async function createStream(type) { + if (!connected) { + return; + } + let stream; + switch (type) { + case 'output unidirectional': + stream = await session.createUnidirectionalStream(); + break; + case 'bidirectional': + stream = await session.createBidirectionalStream(); + startListeningForIncomingData(stream, openStreams.length, "bidirectional"); + break; + default: + alert("Unknown stream type"); + return; + } + + addStream(stream, type); + } + + function addStream(stream, type) { + openStreams.push({ stream: stream, type: type }); + + updateOpenStreamsList(); + + addToCommsLog("Client", "Server", `CREATING ${type} STREAM WITH ID ${openStreams.length}`); + } + + async function sendMessage() { + if (!connected) { + return; + } + + let activeStreamIndex = getActiveStreamIndex(); + + if (activeStreamIndex == -1) { + alert((openStreams.length > 0) ? "Please select a stream first" : "Please create a stream first"); + } + + let activeStreamObj = openStreams[activeStreamIndex]; + let activeStream = activeStreamObj.stream; + let activeStreamType = activeStreamObj.type; + + let writer = isBidirectional(activeStreamType) ? activeStream.writable.getWriter() : activeStream.getWriter(); + + let msg = messageInput.value.split("").map(x => (x).charCodeAt(0)); + await writer.write(new Uint8Array(msg)); + + writer.releaseLock(); + + addToCommsLog("Client", "Server", `SENDING OVER STREAM ${activeStreamIndex}: ${messageInput.value}`); + } + + function isBidirectional(type) { + return type === "bidirectional"; + } + + function getActiveStream() { + let index = getActiveStreamIndex(); + return (index === -1) ? null : openStreams[index].stream; + } + + function getActiveStreamIndex() { + let allStreams = document.getElementsByName("streams"); + + for (let i = 0; i < allStreams.length; i++) { + if (allStreams[i].checked) { + return i; + } + } + return -1; + } + + function addToCommsLog(from, to, data) { + commsLog.innerHTML += '<tr>' + + `<td>${getTimestamp()}</td>` + + `<td>${from}</td>` + + `<td>${to}</td>` + + `<td>${data}</td>` + '</tr>'; + } + + function getTimestamp() { + let now = new Date(); + return `${now.getHours()}:${now.getMinutes()}:${now.getSeconds()}.${now.getMilliseconds()}`; + } + </script> +</body> +</html> +<style> + html, + body { + background-color: #459eda; + padding: 0; + margin: 0; + height: 100%; + } + + body { + display: flex; + flex-direction: row; + } + + #panel { + background-color: white; + padding: 12px; + margin: 0; + border-top-right-radius: 12px; + border-bottom-right-radius: 12px; + border-right: #0d6cad 5px solid; + max-width: 400px; + min-width: 200px; + flex: 1; + min-height: 1200px; + overflow: auto; + } + + #panel > * { + text-align: center; + } + + #communicationLogPanel { + padding: 24px; + flex: 1; + } + + #communicationLogPanel > * { + color: white; + width: 100%; + } + + #connectButton { + background-color: #2e64a7; + } + + #messageInput { + max-width: 100%; + } + + #streamsList { + max-height: 400px; + overflow: auto; + } + + input { + padding: 6px; + margin-bottom: 8px; + margin-right: 0; + } + + button { + background-color: #459eda; + border-radius: 5px; + border: 0; + text-align: center; + color: white; + padding: 8px; + margin: 4px; + width: 100%; + } +</style> diff --git a/src/Servers/Kestrel/samples/WebTransportSampleApp/Program.cs b/src/Servers/Kestrel/samples/WebTransportSampleApp/Program.cs new file mode 100644 index 0000000000000000000000000000000000000000..556fd7972a22ce7d7f5befe8ff56cb6fc84a4ac2 --- /dev/null +++ b/src/Servers/Kestrel/samples/WebTransportSampleApp/Program.cs @@ -0,0 +1,92 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Microsoft.AspNetCore.Connections; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Server.Kestrel.Core; + +var builder = WebApplication.CreateBuilder(args); +builder.WebHost.ConfigureKestrel((context, options) => +{ + // Port configured for WebTransport + options.Listen(IPAddress.Any, 5007, listenOptions => + { + listenOptions.UseHttps(GenerateManualCertificate()); + listenOptions.UseConnectionLogging(); + listenOptions.Protocols = HttpProtocols.Http1AndHttp2AndHttp3; + }); +}); +var host = builder.Build(); + +host.Run(async (context) => +{ + var feature = context.Features.GetRequiredFeature<IHttpWebTransportFeature>(); + if (!feature.IsWebTransportRequest) + { + return; + } + var session = await feature.AcceptAsync(CancellationToken.None); + + //// ACCEPT AN INCOMING STREAM + var stream = await session.AcceptStreamAsync(CancellationToken.None); + + //// READ FROM A STREAM: + var memory = new Memory<byte>(new byte[4096]); + var test = await stream.Transport.Input.AsStream().ReadAsync(memory, CancellationToken.None); + Console.WriteLine(System.Text.Encoding.Default.GetString(memory.Span)); +}); + +await host.RunAsync(); + +// Adapted from: https://github.com/wegylexy/webtransport +// We will need to eventually merge this with existing Kestrel certificate generation +// tracked in issue #41762 +static X509Certificate2 GenerateManualCertificate() +{ + X509Certificate2 cert = null; + var store = new X509Store("KestrelSampleWebTransportCertificates", StoreLocation.CurrentUser); + store.Open(OpenFlags.ReadWrite); + if (store.Certificates.Count > 0) + { + cert = store.Certificates[^1]; + + // rotate key after it expires + if (DateTime.Parse(cert.GetExpirationDateString(), null) < DateTimeOffset.UtcNow) + { + cert = null; + } + } + if (cert == null) + { + // generate a new cert + var now = DateTimeOffset.UtcNow; + SubjectAlternativeNameBuilder sanBuilder = new(); + sanBuilder.AddDnsName("localhost"); + using var ec = ECDsa.Create(ECCurve.NamedCurves.nistP256); + CertificateRequest req = new("CN=localhost", ec, HashAlgorithmName.SHA256); + // Adds purpose + req.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(new OidCollection + { + new("1.3.6.1.5.5.7.3.1") // serverAuth + }, false)); + // Adds usage + req.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, false)); + // Adds subject alternate names + req.CertificateExtensions.Add(sanBuilder.Build()); + // Sign + using var crt = req.CreateSelfSigned(now, now.AddDays(14)); // 14 days is the max duration of a certificate for this + cert = new(crt.Export(X509ContentType.Pfx)); + + // Save + store.Add(cert); + } + store.Close(); + + var hash = SHA256.HashData(cert.RawData); + var certStr = Convert.ToBase64String(hash); + Console.WriteLine($"\n\n\n\n\nCertificate: {certStr}\n\n\n\n"); // <-- you will need to put this output into the JS API call to allow the connection + return cert; +} diff --git a/src/Servers/Kestrel/samples/WebTransportSampleApp/Properties/launchSettings.json b/src/Servers/Kestrel/samples/WebTransportSampleApp/Properties/launchSettings.json new file mode 100644 index 0000000000000000000000000000000000000000..5f58400fd96259467f72447ff9b6bc446de8431f --- /dev/null +++ b/src/Servers/Kestrel/samples/WebTransportSampleApp/Properties/launchSettings.json @@ -0,0 +1,11 @@ +{ + "profiles": { + "WebTransportSampleApp": { + "commandName": "Project", + "launchBrowser": false, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Servers/Kestrel/samples/WebTransportSampleApp/WebTransportSampleApp.csproj b/src/Servers/Kestrel/samples/WebTransportSampleApp/WebTransportSampleApp.csproj new file mode 100644 index 0000000000000000000000000000000000000000..243c338db39fc8e27e981bd6ff593a464e601ce3 --- /dev/null +++ b/src/Servers/Kestrel/samples/WebTransportSampleApp/WebTransportSampleApp.csproj @@ -0,0 +1,21 @@ +<Project Sdk="Microsoft.NET.Sdk.Web"> + + <PropertyGroup> + <TargetFramework>$(DefaultNetCoreTargetFramework)</TargetFramework> + <!-- Turn on preview features so we can use Http3 --> + <EnablePreviewFeatures>True</EnablePreviewFeatures> + </PropertyGroup> + + <ItemGroup> + <Reference Include="Microsoft.AspNetCore" /> + <Reference Include="Microsoft.AspNetCore.Server.Kestrel" /> + <Reference Include="Microsoft.Extensions.Logging.Console" /> + <Reference Include="Microsoft.Extensions.Hosting" /> + <Reference Include="Microsoft.AspNetCore.Server.Kestrel.Transport.Quic" /> + </ItemGroup> + + <ItemGroup> + <!-- Turn on the WebTransport AppContext switch --> + <RuntimeHostConfigurationOption Include="Microsoft.AspNetCore.Server.Kestrel.Experimental.WebTransportAndH3Datagrams" Value="true" /> + </ItemGroup> +</Project> diff --git a/src/Servers/Kestrel/samples/WebTransportSampleApp/appsettings.Development.json b/src/Servers/Kestrel/samples/WebTransportSampleApp/appsettings.Development.json new file mode 100644 index 0000000000000000000000000000000000000000..703489018ae938ca4c838456388987ccd29a1c73 --- /dev/null +++ b/src/Servers/Kestrel/samples/WebTransportSampleApp/appsettings.Development.json @@ -0,0 +1,10 @@ +{ + "DetailedErrors": true, + "Logging": { + "LogLevel": { + "Default": "Debug", + "Microsoft": "Debug", + "Microsoft.AspNetCore": "Debug" + } + } +} diff --git a/src/Servers/Kestrel/samples/WebTransportSampleApp/appsettings.json b/src/Servers/Kestrel/samples/WebTransportSampleApp/appsettings.json new file mode 100644 index 0000000000000000000000000000000000000000..c3dfa9d8926cacfc13d591573fbefd0958d0a070 --- /dev/null +++ b/src/Servers/Kestrel/samples/WebTransportSampleApp/appsettings.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/Servers/Kestrel/shared/test/Http3/Http3InMemory.cs b/src/Servers/Kestrel/shared/test/Http3/Http3InMemory.cs index 38300b3aa1fbf59c0af16f7ca1fc7039e995187d..9923e06cf29d4980324c8f20b006251d7596281a 100644 --- a/src/Servers/Kestrel/shared/test/Http3/Http3InMemory.cs +++ b/src/Servers/Kestrel/shared/test/Http3/Http3InMemory.cs @@ -17,10 +17,12 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Internal; using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.AspNetCore.Server.Kestrel.Core.Features; using Microsoft.AspNetCore.Server.Kestrel.Core.Internal; using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http; using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http3; using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Infrastructure; +using Microsoft.AspNetCore.Server.Kestrel.Core.WebTransport; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Primitives; using static System.IO.Pipelines.DuplexPipe; @@ -87,7 +89,7 @@ internal class Http3InMemory private long _currentStreamId; internal Http3Connection Connection { get; private set; } - internal Http3ControlStream OutboundControlStream { get; private set; } + internal Http3ControlStream OutboundControlStream { get; set; } internal ChannelReader<KeyValuePair<Http3SettingType, long>> ServerReceivedSettingsReader => _serverReceivedSettings.Reader; @@ -259,13 +261,13 @@ internal class Http3InMemory } } - internal async ValueTask<Http3RequestStream> InitializeConnectionAndStreamsAsync(RequestDelegate application) + internal async ValueTask<Http3RequestStream> InitializeConnectionAndStreamsAsync(RequestDelegate application, IEnumerable<KeyValuePair<string, string>> headers, bool endStream = false) { await InitializeConnectionAsync(application); OutboundControlStream = await CreateControlStream(); - return await CreateRequestStream(); + return await CreateRequestStream(headers, endStream: endStream); } private class LifetimeHandlerInterceptor : IHttp3StreamLifetimeHandler @@ -279,7 +281,7 @@ internal class Http3InMemory _http3TestBase = http3TestBase; } - public bool OnInboundControlStream(Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http3.Http3ControlStream stream) + public bool OnInboundControlStream(Server.Kestrel.Core.Internal.Http3.Http3ControlStream stream) { return _inner.OnInboundControlStream(stream); } @@ -293,12 +295,12 @@ internal class Http3InMemory Debug.Assert(success); } - public bool OnInboundDecoderStream(Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http3.Http3ControlStream stream) + public bool OnInboundDecoderStream(Server.Kestrel.Core.Internal.Http3.Http3ControlStream stream) { return _inner.OnInboundDecoderStream(stream); } - public bool OnInboundEncoderStream(Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http3.Http3ControlStream stream) + public bool OnInboundEncoderStream(Server.Kestrel.Core.Internal.Http3.Http3ControlStream stream) { return _inner.OnInboundEncoderStream(stream); } @@ -337,6 +339,16 @@ internal class Http3InMemory testStream.OnHeaderReceivedTcs.TrySetResult(); } } + + public void OnUnidentifiedStreamReceived(Http3PendingStream stream) + { + _inner.OnUnidentifiedStreamReceived(stream); + + if (_http3TestBase._runningStreams.TryGetValue(stream.StreamId, out var testStream)) + { + testStream.OnUnidentifiedStreamCreatedTcs.TrySetResult(); + } + } } protected void ConnectionClosed() @@ -400,7 +412,46 @@ internal class Http3InMemory return stream; } - internal ValueTask<Http3RequestStream> CreateRequestStream(Http3RequestHeaderHandler headerHandler = null) + internal async ValueTask<Http3RequestStream> CreateRequestStream(IEnumerable<KeyValuePair<string, string>> headers, Http3RequestHeaderHandler headerHandler = null, bool endStream = false, TaskCompletionSource tsc = null) + { + var stream = CreateRequestStreamCore(headerHandler); + + if (tsc is not null) + { + stream.StartStreamDisposeTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + } + + if (headers is not null) + { + await stream.SendHeadersAsync(headers, endStream); + } + + _runningStreams[stream.StreamId] = stream; + + MultiplexedConnectionContext.ToServerAcceptQueue.Writer.TryWrite(stream.StreamContext); + + return stream; + } + + internal async ValueTask<Http3RequestStream> CreateRequestStream(Http3HeadersEnumerator headers, Http3RequestHeaderHandler headerHandler = null, bool endStream = false, TaskCompletionSource tsc = null) + { + var stream = CreateRequestStreamCore(headerHandler); + + if (tsc is not null) + { + stream.StartStreamDisposeTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + } + + await stream.SendHeadersAsync(headers, endStream); + + _runningStreams[stream.StreamId] = stream; + + MultiplexedConnectionContext.ToServerAcceptQueue.Writer.TryWrite(stream.StreamContext); + + return stream; + } + + private Http3RequestStream CreateRequestStreamCore(Http3RequestHeaderHandler headerHandler) { var requestStreamId = GetStreamId(0x00); if (!_streamContextPool.TryDequeue(out var testStreamContext)) @@ -413,16 +464,13 @@ internal class Http3InMemory } testStreamContext.Initialize(requestStreamId); - var stream = new Http3RequestStream(this, Connection, testStreamContext, headerHandler ?? new Http3RequestHeaderHandler()); - _runningStreams[stream.StreamId] = stream; - - MultiplexedConnectionContext.ToServerAcceptQueue.Writer.TryWrite(stream.StreamContext); - return new ValueTask<Http3RequestStream>(stream); + return new Http3RequestStream(this, Connection, testStreamContext, headerHandler ?? new Http3RequestHeaderHandler()); } } internal class Http3StreamBase { + internal TaskCompletionSource OnUnidentifiedStreamCreatedTcs { get; } = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); internal TaskCompletionSource OnStreamCreatedTcs { get; } = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); internal TaskCompletionSource OnStreamCompletedTcs { get; } = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); internal TaskCompletionSource OnHeaderReceivedTcs { get; } = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -438,6 +486,7 @@ internal class Http3StreamBase set => StreamContext.Error = value; } + public Task OnUnidentifiedStreamCreatedTask => OnUnidentifiedStreamCreatedTcs.Task; public Task OnStreamCreatedTask => OnStreamCreatedTcs.Task; public Task OnStreamCompletedTask => OnStreamCompletedTcs.Task; public Task OnHeaderReceivedTask => OnHeaderReceivedTcs.Task; @@ -575,17 +624,27 @@ internal class Http3StreamBase internal async Task WaitForStreamErrorAsync(Http3ErrorCode protocolError, Action<string> matchExpectedErrorMessage = null, string expectedErrorMessage = null) { - var result = await ReadApplicationInputAsync(); - if (!result.IsCompleted) + try + { + var result = await ReadApplicationInputAsync(); + if (!result.IsCompleted) + { + throw new InvalidOperationException("Stream not ended."); + } + } + catch (ConnectionAbortedException) { - throw new InvalidOperationException("Stream not ended."); + // no-op, this just means that the stream was aborted prior to the read ending. This is probably + // intentional, so go onto invoking the comparisons } - if ((Http3ErrorCode)Error != protocolError) + finally { - throw new InvalidOperationException($"Expected error code {protocolError}, got {(Http3ErrorCode)Error}."); + if (protocolError != Http3ErrorCode.NoError && (Http3ErrorCode)Error != protocolError) + { + throw new InvalidOperationException($"Expected error code {protocolError}, got {(Http3ErrorCode)Error}."); + } + matchExpectedErrorMessage?.Invoke(expectedErrorMessage); } - - matchExpectedErrorMessage?.Invoke(expectedErrorMessage); } } diff --git a/src/Servers/Kestrel/shared/test/TestContextFactory.cs b/src/Servers/Kestrel/shared/test/TestContextFactory.cs index 3e3ed65b8ebb8b6be530c3180ba294daac9dd62c..c6da71957b14ecc665b31d1a1729d34ec7f4190d 100644 --- a/src/Servers/Kestrel/shared/test/TestContextFactory.cs +++ b/src/Servers/Kestrel/shared/test/TestContextFactory.cs @@ -92,8 +92,10 @@ internal static class TestContextFactory connectionFeatures ?? new FeatureCollection(), memoryPool ?? PinnedBlockMemoryPoolFactory.Create(), localEndPoint, - remoteEndPoint); - http3ConnectionContext.TimeoutControl = timeoutControl; + remoteEndPoint) + { + TimeoutControl = timeoutControl + }; return http3ConnectionContext; } @@ -177,7 +179,21 @@ internal static class TestContextFactory ITimeoutControl timeoutControl = null, IHttp3StreamLifetimeHandler streamLifetimeHandler = null) { - var context = new Http3StreamContext + var http3ConnectionContext = CreateHttp3ConnectionContext( + null, + serviceContext, + connectionFeatures, + memoryPool, + localEndPoint, + remoteEndPoint, + timeoutControl); + + var http3Conection = new Http3Connection(http3ConnectionContext) + { + _streamLifetimeHandler = streamLifetimeHandler + }; + + return new Http3StreamContext ( connectionId: connectionId ?? "TestConnectionId", protocols: HttpProtocols.Http3, @@ -188,15 +204,13 @@ internal static class TestContextFactory memoryPool: memoryPool ?? MemoryPool<byte>.Shared, localEndPoint: localEndPoint, remoteEndPoint: remoteEndPoint, - streamLifetimeHandler: streamLifetimeHandler, streamContext: new DefaultConnectionContext(), - clientPeerSettings: new Http3PeerSettings(), - serverPeerSettings: null - ); - context.TimeoutControl = timeoutControl; - context.Transport = transport; - - return context; + connection: http3Conection + ) + { + TimeoutControl = timeoutControl, + Transport = transport, + }; } private class TestHttp2StreamLifetimeHandler : IHttp2StreamLifetimeHandler diff --git a/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http3/Http3ConnectionTests.cs b/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http3/Http3ConnectionTests.cs index 7f81bea7e8eae5178ccd874d525eaca70162e6d8..47deea25161ef613e09556e43bdfb2fc6eefd7cd 100644 --- a/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http3/Http3ConnectionTests.cs +++ b/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http3/Http3ConnectionTests.cs @@ -54,17 +54,14 @@ public class Http3ConnectionTests : Http3TestBase await Http3Api.CreateControlStream(); await Http3Api.GetInboundControlStream(); - var requestStream = await Http3Api.CreateRequestStream(); - - var headers = new[] + var requestStream = await Http3Api.CreateRequestStream(new[] { - new KeyValuePair<string, string>(PseudoHeaderNames.Method, "Custom"), - new KeyValuePair<string, string>(PseudoHeaderNames.Path, "/"), - new KeyValuePair<string, string>(PseudoHeaderNames.Scheme, "http"), - new KeyValuePair<string, string>(PseudoHeaderNames.Authority, "localhost:80"), - }; + new KeyValuePair<string, string>(PseudoHeaderNames.Method, "Custom"), + new KeyValuePair<string, string>(PseudoHeaderNames.Path, "/"), + new KeyValuePair<string, string>(PseudoHeaderNames.Scheme, "http"), + new KeyValuePair<string, string>(PseudoHeaderNames.Authority, "localhost:80"), + }); - await requestStream.SendHeadersAsync(headers); await requestStream.SendDataAsync(Encoding.ASCII.GetBytes("Hello world"), endStream: true); Assert.False(requestStream.Disposed); @@ -95,18 +92,14 @@ public class Http3ConnectionTests : Http3TestBase await Http3Api.CreateControlStream(); await Http3Api.GetInboundControlStream(); - var requestStream = await Http3Api.CreateRequestStream(); - - var expectContinueRequestHeaders = new[] + var requestStream = await Http3Api.CreateRequestStream(new[] { - new KeyValuePair<string, string>(PseudoHeaderNames.Method, "POST"), - new KeyValuePair<string, string>(PseudoHeaderNames.Path, "/"), - new KeyValuePair<string, string>(PseudoHeaderNames.Authority, "127.0.0.1"), - new KeyValuePair<string, string>(PseudoHeaderNames.Scheme, "http"), - new KeyValuePair<string, string>(HeaderNames.Expect, "100-continue"), - }; - - await requestStream.SendHeadersAsync(expectContinueRequestHeaders); + new KeyValuePair<string, string>(PseudoHeaderNames.Method, "POST"), + new KeyValuePair<string, string>(PseudoHeaderNames.Path, "/"), + new KeyValuePair<string, string>(PseudoHeaderNames.Authority, "127.0.0.1"), + new KeyValuePair<string, string>(PseudoHeaderNames.Scheme, "http"), + new KeyValuePair<string, string>(HeaderNames.Expect, "100-continue"), + }); var frame = await requestStream.ReceiveFrameAsync(); Assert.Equal(Http3FrameType.Headers, frame.Type); @@ -161,9 +154,7 @@ public class Http3ConnectionTests : Http3TestBase await Http3Api.CreateControlStream(); await Http3Api.GetInboundControlStream(); - var requestStream = await Http3Api.CreateRequestStream(); - - await requestStream.SendHeadersAsync(requestHeaders, endStream: true); + var requestStream = await Http3Api.CreateRequestStream(requestHeaders, endStream: true); var responseHeaders = await requestStream.ExpectHeadersAsync(); await requestStream.ExpectReceiveEndOfStream(); @@ -186,8 +177,7 @@ public class Http3ConnectionTests : Http3TestBase for (var i = 0; i < connectionRequests; i++) { - var request = await Http3Api.CreateRequestStream(); - await request.SendHeadersAsync(Headers); + var request = await Http3Api.CreateRequestStream(Headers); await request.EndStreamAsync(); await request.ExpectReceiveEndOfStream(); @@ -210,8 +200,7 @@ public class Http3ConnectionTests : Http3TestBase var inboundControlStream = await Http3Api.GetInboundControlStream(); await inboundControlStream.ExpectSettingsAsync(); - var activeRequest = await Http3Api.CreateRequestStream(); - await activeRequest.SendHeadersAsync(Headers); + var activeRequest = await Http3Api.CreateRequestStream(Headers); // Trigger server shutdown. Http3Api.CloseServerGracefully(); @@ -219,7 +208,7 @@ public class Http3ConnectionTests : Http3TestBase await Http3Api.WaitForGoAwayAsync(false, VariableLengthIntegerHelper.EightByteLimit); // Request made while shutting down is rejected. - var rejectedRequest = await Http3Api.CreateRequestStream(); + var rejectedRequest = await Http3Api.CreateRequestStream(Headers); await rejectedRequest.WaitForStreamErrorAsync(Http3ErrorCode.RequestRejected); // End active request. @@ -551,8 +540,7 @@ public class Http3ConnectionTests : Http3TestBase for (int i = 0; i < 3; i++) { - var requestStream = await Http3Api.CreateRequestStream(); - await requestStream.SendHeadersAsync(requestHeaders, endStream: true); + var requestStream = await Http3Api.CreateRequestStream(requestHeaders, endStream: true); var responseHeaders = await requestStream.ExpectHeadersAsync(); var data = await requestStream.ExpectTrailersAsync(); @@ -570,11 +558,9 @@ public class Http3ConnectionTests : Http3TestBase private async Task<ConnectionContext> MakeRequestAsync(int index, KeyValuePair<string, string>[] headers, bool sendData, bool waitForServerDispose) { - var requestStream = await Http3Api.CreateRequestStream(); + var requestStream = await Http3Api.CreateRequestStream(headers, endStream: !sendData); var streamContext = requestStream.StreamContext; - await requestStream.SendHeadersAsync(headers, endStream: !sendData); - if (sendData) { await requestStream.SendDataAsync(Encoding.ASCII.GetBytes($"Hello world {index}")); diff --git a/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http3/Http3StreamTests.cs b/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http3/Http3StreamTests.cs index 241483700c45387a6acb1d3820a1cdefe80f4a20..3d61bbbcc8933c0a721e20c71f52438ed0fb78c0 100644 --- a/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http3/Http3StreamTests.cs +++ b/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http3/Http3StreamTests.cs @@ -35,9 +35,7 @@ public class Http3StreamTests : Http3TestBase new KeyValuePair<string, string>(PseudoHeaderNames.Authority, "localhost:80"), }; - var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(_echoApplication); - - await requestStream.SendHeadersAsync(headers); + var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(_echoApplication, headers); await requestStream.SendDataAsync(Encoding.ASCII.GetBytes("Hello world"), endStream: true); var responseHeaders = await requestStream.ExpectHeadersAsync(); @@ -62,9 +60,7 @@ public class Http3StreamTests : Http3TestBase { context.Response.StatusCode = 401; return Task.CompletedTask; - }); - - await requestStream.SendHeadersAsync(headers, endStream: true); + }, headers, endStream: true); var responseHeaders = await requestStream.ExpectHeadersAsync(); Assert.Equal("401", responseHeaders[PseudoHeaderNames.Status]); @@ -83,8 +79,7 @@ public class Http3StreamTests : Http3TestBase new KeyValuePair<string, string>(PseudoHeaderNames.Authority, "localhost:80"), }; - var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(_echoApplication); - await requestStream.SendHeadersAsync(headers); + var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(_echoApplication, headers); await requestStream.WaitForStreamErrorAsync( Http3ErrorCode.ProtocolError, AssertExpectedErrorMessages, @@ -102,8 +97,7 @@ public class Http3StreamTests : Http3TestBase new KeyValuePair<string, string>(PseudoHeaderNames.Authority, "localhost:80"), }; - var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(_echoApplication); - await requestStream.SendHeadersAsync(headers); + var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(_echoApplication, headers); await requestStream.WaitForStreamErrorAsync( Http3ErrorCode.ProtocolError, AssertExpectedErrorMessages, @@ -121,8 +115,7 @@ public class Http3StreamTests : Http3TestBase new KeyValuePair<string, string>(PseudoHeaderNames.Authority, "localhost:80"), }; - var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(_echoMethod); - await requestStream.SendHeadersAsync(headers, endStream: true); + var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(_echoMethod, headers, endStream: true); var responseHeaders = await requestStream.ExpectHeadersAsync(); @@ -145,9 +138,7 @@ public class Http3StreamTests : Http3TestBase new KeyValuePair<string, string>("test", new string('a', 20000)) }; - var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(_echoApplication); - - await requestStream.SendHeadersAsync(headers); + var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(_echoApplication, headers); await requestStream.WaitForStreamErrorAsync( Http3ErrorCode.InternalError, @@ -158,12 +149,10 @@ public class Http3StreamTests : Http3TestBase [Fact] public async Task ConnectMethod_Accepted() { - var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(_echoMethod); - // :path and :scheme are not allowed, :authority is optional var headers = new[] { new KeyValuePair<string, string>(PseudoHeaderNames.Method, "CONNECT") }; - await requestStream.SendHeadersAsync(headers, endStream: true); + var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(_echoMethod, headers, endStream: true); var responseHeaders = await requestStream.ExpectHeadersAsync(); @@ -177,12 +166,11 @@ public class Http3StreamTests : Http3TestBase [Fact] public async Task OptionsStar_LeftOutOfPath() { - var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(_echoPath); var headers = new[] { new KeyValuePair<string, string>(PseudoHeaderNames.Method, "OPTIONS"), new KeyValuePair<string, string>(PseudoHeaderNames.Scheme, "http"), new KeyValuePair<string, string>(PseudoHeaderNames.Path, "*")}; - await requestStream.SendHeadersAsync(headers, endStream: true); + var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(_echoPath, headers, endStream: true); var responseHeaders = await requestStream.ExpectHeadersAsync(); @@ -197,13 +185,11 @@ public class Http3StreamTests : Http3TestBase [Fact] public async Task OptionsSlash_Accepted() { - var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(_echoPath); - var headers = new[] { new KeyValuePair<string, string>(PseudoHeaderNames.Method, "OPTIONS"), new KeyValuePair<string, string>(PseudoHeaderNames.Scheme, "http"), new KeyValuePair<string, string>(PseudoHeaderNames.Path, "/")}; - await requestStream.SendHeadersAsync(headers, endStream: true); + var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(_echoPath, headers, endStream: true); var responseHeaders = await requestStream.ExpectHeadersAsync(); @@ -218,20 +204,18 @@ public class Http3StreamTests : Http3TestBase [Fact] public async Task PathAndQuery_Separated() { + // :path and :scheme are not allowed, :authority is optional + var headers = new[] { new KeyValuePair<string, string>(PseudoHeaderNames.Method, "GET"), + new KeyValuePair<string, string>(PseudoHeaderNames.Scheme, "http"), + new KeyValuePair<string, string>(PseudoHeaderNames.Path, "/a/path?a&que%35ry")}; + var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(context => { context.Response.Headers["path"] = context.Request.Path.Value; context.Response.Headers["query"] = context.Request.QueryString.Value; context.Response.Headers["rawtarget"] = context.Features.Get<IHttpRequestFeature>().RawTarget; return Task.CompletedTask; - }); - - // :path and :scheme are not allowed, :authority is optional - var headers = new[] { new KeyValuePair<string, string>(PseudoHeaderNames.Method, "GET"), - new KeyValuePair<string, string>(PseudoHeaderNames.Scheme, "http"), - new KeyValuePair<string, string>(PseudoHeaderNames.Path, "/a/path?a&que%35ry")}; - - await requestStream.SendHeadersAsync(headers, endStream: true); + }, headers, endStream: true); var responseHeaders = await requestStream.ExpectHeadersAsync(); @@ -255,19 +239,17 @@ public class Http3StreamTests : Http3TestBase [InlineData("/a/b/c/.%2E/d", "/a/b/d")] // Decode before navigation processing public async Task Path_DecodedAndNormalized(string input, string expected) { - var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(context => - { - Assert.Equal(expected, context.Request.Path.Value); - Assert.Equal(input, context.Features.Get<IHttpRequestFeature>().RawTarget); - return Task.CompletedTask; - }); - // :path and :scheme are not allowed, :authority is optional var headers = new[] { new KeyValuePair<string, string>(PseudoHeaderNames.Method, "GET"), new KeyValuePair<string, string>(PseudoHeaderNames.Scheme, "http"), new KeyValuePair<string, string>(PseudoHeaderNames.Path, input)}; - await requestStream.SendHeadersAsync(headers, endStream: true); + var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(context => + { + Assert.Equal(expected, context.Request.Path.Value); + Assert.Equal(input, context.Features.Get<IHttpRequestFeature>().RawTarget); + return Task.CompletedTask; + }, headers, endStream: true); var responseHeaders = await requestStream.ExpectHeadersAsync(); @@ -282,13 +264,11 @@ public class Http3StreamTests : Http3TestBase [InlineData(":scheme", "http")] public async Task ConnectMethod_WithSchemeOrPath_Reset(string headerName, string value) { - var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(_noopApplication); - // :path and :scheme are not allowed, :authority is optional var headers = new[] { new KeyValuePair<string, string>(PseudoHeaderNames.Method, "CONNECT"), new KeyValuePair<string, string>(headerName, value) }; - await requestStream.SendHeadersAsync(headers, endStream: true); + var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(_noopApplication, headers, endStream: true); await requestStream.WaitForStreamErrorAsync( Http3ErrorCode.ProtocolError, @@ -301,13 +281,11 @@ public class Http3StreamTests : Http3TestBase [InlineData("ftp")] public async Task SchemeMismatch_Reset(string scheme) { - var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(_noopApplication); - var headers = new[] { new KeyValuePair<string, string>(PseudoHeaderNames.Method, "GET"), new KeyValuePair<string, string>(PseudoHeaderNames.Path, "/"), new KeyValuePair<string, string>(PseudoHeaderNames.Scheme, scheme) }; // Not the expected "http" - await requestStream.SendHeadersAsync(headers, endStream: true); + var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(_noopApplication, headers, endStream: true); await requestStream.WaitForStreamErrorAsync( Http3ErrorCode.ProtocolError, @@ -322,17 +300,15 @@ public class Http3StreamTests : Http3TestBase { _serviceContext.ServerOptions.AllowAlternateSchemes = true; - var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(context => - { - Assert.Equal(scheme, context.Request.Scheme); - return Task.CompletedTask; - }); - var headers = new[] { new KeyValuePair<string, string>(PseudoHeaderNames.Method, "GET"), new KeyValuePair<string, string>(PseudoHeaderNames.Path, "/"), new KeyValuePair<string, string>(PseudoHeaderNames.Scheme, scheme) }; // Not the expected "http" - await requestStream.SendHeadersAsync(headers, endStream: true); + var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(context => + { + Assert.Equal(scheme, context.Request.Scheme); + return Task.CompletedTask; + }, headers, endStream: true); var responseHeaders = await requestStream.ExpectHeadersAsync(); @@ -349,13 +325,11 @@ public class Http3StreamTests : Http3TestBase { _serviceContext.ServerOptions.AllowAlternateSchemes = true; - var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(_noopApplication); - var headers = new[] { new KeyValuePair<string, string>(PseudoHeaderNames.Method, "GET"), new KeyValuePair<string, string>(PseudoHeaderNames.Path, "/"), new KeyValuePair<string, string>(PseudoHeaderNames.Scheme, scheme) }; // Not the expected "http" - await requestStream.SendHeadersAsync(headers, endStream: true); + var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(_noopApplication, headers, endStream: true); await requestStream.WaitForStreamErrorAsync( Http3ErrorCode.ProtocolError, @@ -373,9 +347,7 @@ public class Http3StreamTests : Http3TestBase new KeyValuePair<string, string>(PseudoHeaderNames.Scheme, "http"), }; - var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(_noopApplication); - - await requestStream.SendHeadersAsync(headers, endStream: true); + var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(_noopApplication, headers, endStream: true); var responseHeaders = await requestStream.ExpectHeadersAsync(); @@ -395,9 +367,7 @@ public class Http3StreamTests : Http3TestBase new KeyValuePair<string, string>(PseudoHeaderNames.Scheme, "http"), new KeyValuePair<string, string>(PseudoHeaderNames.Authority, ""), }; - var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(_noopApplication); - - await requestStream.SendHeadersAsync(headers, endStream: true); + var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(_noopApplication, headers, endStream: true); var responseHeaders = await requestStream.ExpectHeadersAsync(); @@ -418,8 +388,7 @@ public class Http3StreamTests : Http3TestBase new KeyValuePair<string, string>("Host", "abc"), }; - var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(_echoHost); - await requestStream.SendHeadersAsync(headers, endStream: true); + var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(_echoHost, headers, endStream: true); var responseHeaders = await requestStream.ExpectHeadersAsync(); @@ -442,8 +411,7 @@ public class Http3StreamTests : Http3TestBase new KeyValuePair<string, string>("Host", "abc"), }; - var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(_echoHost); - await requestStream.SendHeadersAsync(headers, endStream: true); + var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(_echoHost, headers, endStream: true); var responseHeaders = await requestStream.ExpectHeadersAsync(); @@ -466,8 +434,7 @@ public class Http3StreamTests : Http3TestBase new KeyValuePair<string, string>("Host", "abc"), }; - var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(_echoHost); - await requestStream.SendHeadersAsync(headers, endStream: true); + var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(_echoHost, headers, endStream: true); var responseHeaders = await requestStream.ExpectHeadersAsync(); @@ -490,8 +457,7 @@ public class Http3StreamTests : Http3TestBase new KeyValuePair<string, string>("Host", "a=bc"), }; - var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(_echoHost); - await requestStream.SendHeadersAsync(headers, endStream: true); + var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(_echoHost, headers, endStream: true); var responseHeaders = await requestStream.ExpectHeadersAsync(); @@ -513,8 +479,7 @@ public class Http3StreamTests : Http3TestBase new KeyValuePair<string, string>(PseudoHeaderNames.Authority, "local=host:80"), }; - var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(_noopApplication); - await requestStream.SendHeadersAsync(headers, endStream: true); + var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(_noopApplication, headers, endStream: true); await requestStream.WaitForStreamErrorAsync( Http3ErrorCode.ProtocolError, @@ -534,8 +499,7 @@ public class Http3StreamTests : Http3TestBase new KeyValuePair<string, string>("Host", "abc"), }; - var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(_noopApplication); - await requestStream.SendHeadersAsync(headers, endStream: true); + var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(_noopApplication, headers, endStream: true); await requestStream.WaitForStreamErrorAsync( Http3ErrorCode.ProtocolError, @@ -555,8 +519,7 @@ public class Http3StreamTests : Http3TestBase new KeyValuePair<string, string>("Host", "host2"), }; - var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(_noopApplication); - await requestStream.SendHeadersAsync(headers, endStream: true); + var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(_noopApplication, headers, endStream: true); await requestStream.WaitForStreamErrorAsync( Http3ErrorCode.ProtocolError, @@ -577,8 +540,7 @@ public class Http3StreamTests : Http3TestBase new KeyValuePair<string, string>(PseudoHeaderNames.Scheme, "http"), new KeyValuePair<string, string>(PseudoHeaderNames.Authority, "localhost" + new string('a', 1024 * 3) + ":80"), }; - var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(_noopApplication); - await requestStream.SendHeadersAsync(headers, endStream: true); + var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(_noopApplication, headers, endStream: true); await requestStream.WaitForStreamErrorAsync( Http3ErrorCode.RequestRejected, @@ -604,9 +566,7 @@ public class Http3StreamTests : Http3TestBase Assert.Equal(12, read); read = await context.Request.Body.ReadAsync(buffer, 0, buffer.Length); Assert.Equal(0, read); - }); - - await requestStream.SendHeadersAsync(headers, endStream: false); + }, headers, endStream: false); await requestStream.SendDataAsync(new byte[12], endStream: true); var responseHeaders = await requestStream.ExpectHeadersAsync(); @@ -639,9 +599,7 @@ public class Http3StreamTests : Http3TestBase total += read; } Assert.Equal(12, total); - }); - - await requestStream.SendHeadersAsync(headers, endStream: false); + }, headers, endStream: false); await requestStream.SendDataAsync(new byte[1], endStream: false); await requestStream.SendDataAsync(new byte[3], endStream: false); @@ -676,9 +634,7 @@ public class Http3StreamTests : Http3TestBase Assert.Equal(12, readResult.Buffer.Length); context.Request.BodyReader.AdvanceTo(readResult.Buffer.End); - }); - - await requestStream.SendHeadersAsync(headers, endStream: false); + }, headers, endStream: false); await requestStream.SendDataAsync(new byte[1], endStream: false); await requestStream.SendDataAsync(new byte[3], endStream: false); @@ -714,9 +670,7 @@ public class Http3StreamTests : Http3TestBase response.Headers.Add(HeaderNames.ProxyConnection, "keep-alive"); await response.WriteAsync("Hello world"); - }); - - await requestStream.SendHeadersAsync(headers, endStream: true); + }, headers, endStream: true); var responseHeaders = await requestStream.ExpectHeadersAsync(); Assert.Equal(2, responseHeaders.Count); @@ -745,9 +699,7 @@ public class Http3StreamTests : Http3TestBase // is never called by the server. requestDelegateCalled = true; return Task.CompletedTask; - }); - - await requestStream.SendHeadersAsync(headers, endStream: true); + }, headers, endStream: true); await requestStream.WaitForStreamErrorAsync( Http3ErrorCode.ProtocolError, @@ -782,9 +734,7 @@ public class Http3StreamTests : Http3TestBase await Task.Delay(50); await context.Response.BodyWriter.WriteAsync(new byte[] { data[i] }); } - }); - - await requestStream.SendHeadersAsync(headers, endStream: true); + }, headers, endStream: true); await requestStream.ExpectHeadersAsync(); headersTcs.SetResult(); @@ -830,8 +780,7 @@ public class Http3StreamTests : Http3TestBase { appTcs.SetException(ex); } - }); - await requestStream.SendHeadersAsync(requestHeaders, endStream: true); + }, requestHeaders, endStream: true); await requestStream.ExpectReceiveEndOfStream(); await appTcs.Task; @@ -859,8 +808,7 @@ public class Http3StreamTests : Http3TestBase var secondPayload = Encoding.ASCII.GetBytes(" world"); var goodResult = await context.Response.BodyWriter.WriteAsync(secondPayload); Assert.False(goodResult.IsCanceled); - }); - await requestStream.SendHeadersAsync(requestHeaders, endStream:true); + }, requestHeaders, endStream: true); await requestStream.ExpectHeadersAsync(); var response = await requestStream.ExpectDataAsync(); @@ -891,9 +839,7 @@ public class Http3StreamTests : Http3TestBase trailersFeature.Trailers.Add("Trailer2", "Value2"); return Task.CompletedTask; - }); - - await requestStream.SendHeadersAsync(headers, endStream: true); + }, headers, endStream: true); var responseHeaders = await requestStream.ExpectHeadersAsync(); @@ -924,9 +870,7 @@ public class Http3StreamTests : Http3TestBase Assert.Throws<InvalidOperationException>(() => context.Response.Headers.Append("CustomName", "Custom ä½ å¥½ Value")); Assert.Throws<InvalidOperationException>(() => context.Response.Headers.Append("CustomName", "Custom \r Value")); await context.Response.WriteAsync("Hello World"); - }); - - await requestStream.SendHeadersAsync(headers, endStream: true); + }, headers, endStream: true); var responseHeaders = await requestStream.ExpectHeadersAsync(); var responseData = await requestStream.ExpectDataAsync(); @@ -960,9 +904,7 @@ public class Http3StreamTests : Http3TestBase context.Response.ContentType = "Custom ä½ å¥½ Type"; context.Response.Headers.Append("CustomName", "Custom ä½ å¥½ Value"); await context.Response.WriteAsync("Hello World"); - }); - - await requestStream.SendHeadersAsync(headers, endStream: true); + }, headers, endStream: true); var responseHeaders = await requestStream.ExpectHeadersAsync(); var responseData = await requestStream.ExpectDataAsync(); @@ -994,9 +936,7 @@ public class Http3StreamTests : Http3TestBase { context.Response.Headers.Append("CustomName", "Custom ä½ å¥½ Value"); await context.Response.WriteAsync("Hello World"); - }); - - await requestStream.SendHeadersAsync(headers, endStream: true); + }, headers, endStream: true); await requestStream.WaitForStreamErrorAsync( Http3ErrorCode.InternalError, @@ -1023,9 +963,7 @@ public class Http3StreamTests : Http3TestBase trailersFeature.Trailers.Add("Trailer2", "Value2"); await context.Response.WriteAsync("Hello world"); - }); - - await requestStream.SendHeadersAsync(headers, endStream: true); + }, headers, endStream: true); var responseHeaders = await requestStream.ExpectHeadersAsync(); var responseData = await requestStream.ExpectDataAsync(); @@ -1057,9 +995,7 @@ public class Http3StreamTests : Http3TestBase trailersFeature.Trailers.Add("Trailer2", "Value2"); throw new NotImplementedException("Test Exception"); - }); - - await requestStream.SendHeadersAsync(headers, endStream: true); + }, headers, endStream: true); var responseHeaders = await requestStream.ExpectHeadersAsync(); @@ -1086,9 +1022,7 @@ public class Http3StreamTests : Http3TestBase // ETag is one of the few special cased trailers. Accept is not. Assert.Throws<InvalidOperationException>(() => context.Features.Get<IHttpResponseTrailersFeature>().Trailers.ETag = "Custom ä½ å¥½ Tag"); Assert.Throws<InvalidOperationException>(() => context.Features.Get<IHttpResponseTrailersFeature>().Trailers.Accept = "Custom ä½ å¥½ Tag"); - }); - - await requestStream.SendHeadersAsync(headers, endStream: true); + }, headers, endStream: true); var responseHeaders = await requestStream.ExpectHeadersAsync(); var responseData = await requestStream.ExpectDataAsync(); @@ -1119,9 +1053,7 @@ public class Http3StreamTests : Http3TestBase // ETag is one of the few special cased trailers. Accept is not. context.Features.Get<IHttpResponseTrailersFeature>().Trailers.ETag = "Custom ä½ å¥½ Tag"; context.Features.Get<IHttpResponseTrailersFeature>().Trailers.Accept = "Custom ä½ å¥½ Accept"; - }); - - await requestStream.SendHeadersAsync(headers, endStream: true); + }, headers, endStream: true); var responseHeaders = await requestStream.ExpectHeadersAsync(); var responseData = await requestStream.ExpectDataAsync(); @@ -1155,9 +1087,7 @@ public class Http3StreamTests : Http3TestBase { await context.Response.WriteAsync("Hello World"); context.Response.AppendTrailer("CustomName", "Custom ä½ å¥½ Value"); - }); - - await requestStream.SendHeadersAsync(headers, endStream: true); + }, headers, endStream: true); var responseHeaders = await requestStream.ExpectHeadersAsync(); var responseData = await requestStream.ExpectDataAsync(); @@ -1187,9 +1117,7 @@ public class Http3StreamTests : Http3TestBase resetFeature.Reset((int)Http3ErrorCode.RequestCancelled); return Task.CompletedTask; - }); - - await requestStream.SendHeadersAsync(headers, endStream: true); + }, headers, endStream: true); await requestStream.WaitForStreamErrorAsync( Http3ErrorCode.RequestCancelled, @@ -1229,9 +1157,7 @@ public class Http3StreamTests : Http3TestBase { appTcs.SetException(ex); } - }); - - await requestStream.SendHeadersAsync(headers, endStream: true); + }, headers, endStream: true); var decodedHeaders = await requestStream.ExpectHeadersAsync(); @@ -1280,9 +1206,7 @@ public class Http3StreamTests : Http3TestBase { appTcs.SetException(ex); } - }); - - await requestStream.SendHeadersAsync(headers, endStream: true); + }, headers, endStream: true); var decodedHeaders = await requestStream.ExpectHeadersAsync(); var decodedTrailers = await requestStream.ExpectHeadersAsync(); @@ -1334,9 +1258,7 @@ public class Http3StreamTests : Http3TestBase { appTcs.SetException(ex); } - }); - - await requestStream.SendHeadersAsync(headers, endStream: true); + }, headers, endStream: true); var decodedHeaders = await requestStream.ExpectHeadersAsync(); @@ -1385,9 +1307,7 @@ public class Http3StreamTests : Http3TestBase { appTcs.SetException(ex); } - }); - - await requestStream.SendHeadersAsync(headers, endStream: true); + }, headers, endStream: true); var decodedHeaders = await requestStream.ExpectHeadersAsync(); @@ -1439,9 +1359,7 @@ public class Http3StreamTests : Http3TestBase { appTcs.SetException(ex); } - }); - - await requestStream.SendHeadersAsync(headers, endStream: true); + }, headers, endStream: true); var decodedHeaders = await requestStream.ExpectHeadersAsync(); @@ -1493,9 +1411,7 @@ public class Http3StreamTests : Http3TestBase { appTcs.SetException(ex); } - }); - - await requestStream.SendHeadersAsync(headers, endStream: true); + }, headers, endStream: true); var decodedHeaders = await requestStream.ExpectHeadersAsync(); @@ -1537,9 +1453,7 @@ public class Http3StreamTests : Http3TestBase } Assert.True(false); - }); - - await requestStream.SendHeadersAsync(headers, endStream: true); + }, headers, endStream: true); var decodedHeaders = await requestStream.ExpectHeadersAsync(); @@ -1594,9 +1508,7 @@ public class Http3StreamTests : Http3TestBase { appTcs.SetException(ex); } - }); - - await requestStream.SendHeadersAsync(headers, endStream: true); + }, headers, endStream: true); var decodedHeaders = await requestStream.ExpectHeadersAsync(); Assert.Equal(2, decodedHeaders.Count); @@ -1651,9 +1563,7 @@ public class Http3StreamTests : Http3TestBase { appTcs.SetException(ex); } - }); - - await requestStream.SendHeadersAsync(headers, endStream: true); + }, headers, endStream: true); var decodedHeaders = await requestStream.ExpectHeadersAsync(); Assert.Equal(2, decodedHeaders.Count); @@ -1710,9 +1620,7 @@ public class Http3StreamTests : Http3TestBase { appTcs.SetException(ex); } - }); - - await requestStream.SendHeadersAsync(headers, endStream: true); + }, headers, endStream: true); var decodedHeaders = await requestStream.ExpectHeadersAsync(); Assert.Equal(3, decodedHeaders.Count); @@ -1770,9 +1678,7 @@ public class Http3StreamTests : Http3TestBase { appTcs.SetException(ex); } - }); - - await requestStream.SendHeadersAsync(headers, endStream: true); + }, headers, endStream: true); var decodedHeaders = await requestStream.ExpectHeadersAsync(); Assert.Equal(3, decodedHeaders.Count); @@ -1831,9 +1737,7 @@ public class Http3StreamTests : Http3TestBase { appTcs.SetException(ex); } - }); - - await requestStream.SendHeadersAsync(headers, endStream: true); + }, headers, endStream: true); var decodedHeaders = await requestStream.ExpectHeadersAsync(); Assert.Equal(2, decodedHeaders.Count); @@ -1897,9 +1801,7 @@ public class Http3StreamTests : Http3TestBase { appTcs.SetException(ex); } - }); - - await requestStream.SendHeadersAsync(headers, endStream: false); + }, headers, endStream: false); var decodedHeaders = await requestStream.ExpectHeadersAsync(); Assert.Equal(2, decodedHeaders.Count); @@ -1960,9 +1862,7 @@ public class Http3StreamTests : Http3TestBase { appTcs.SetException(ex); } - }); - - await requestStream.SendHeadersAsync(headers, endStream: true); + }, headers, endStream: true); var decodedHeaders = await requestStream.ExpectHeadersAsync(); Assert.Equal(2, decodedHeaders.Count); @@ -2030,9 +1930,7 @@ public class Http3StreamTests : Http3TestBase { appTcs.SetException(ex); } - }); - - await requestStream.SendHeadersAsync(headers, endStream: false); + }, headers, endStream: false); var decodedHeaders = await requestStream.ExpectHeadersAsync(); Assert.Equal(2, decodedHeaders.Count); @@ -2053,10 +1951,16 @@ public class Http3StreamTests : Http3TestBase await appTcs.Task; } - [Fact] - public async Task DataBeforeHeaders_UnexpectedFrameError() + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task DataBeforeHeaders_UnexpectedFrameError(bool pendingStreamsEnabled) { - var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(_noopApplication); + Http3Api._serviceContext.ServerOptions.EnableWebTransportAndH3Datagrams = pendingStreamsEnabled; + + var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(_noopApplication, null); + + await (pendingStreamsEnabled ? requestStream.OnUnidentifiedStreamCreatedTask : requestStream.OnStreamCreatedTask); await requestStream.SendDataAsync(Encoding.UTF8.GetBytes("This is invalid.")); @@ -2085,9 +1989,7 @@ public class Http3StreamTests : Http3TestBase await c.Request.Body.DrainAsync(default); testValue = c.Request.GetTrailer("TestName"); - }); - - await requestStream.SendHeadersAsync(headers, endStream: false); + }, headers, endStream: false); await requestStream.SendDataAsync(Encoding.UTF8.GetBytes("Hello world")); await requestStream.SendHeadersAsync(trailers, endStream: true); @@ -2118,9 +2020,7 @@ public class Http3StreamTests : Http3TestBase await c.Response.Body.FlushAsync(); await tcs.Task; - }); - - await requestStream.SendHeadersAsync(headers, endStream: false); + }, headers, endStream: false); await requestStream.ExpectHeadersAsync(); @@ -2166,9 +2066,7 @@ public class Http3StreamTests : Http3TestBase readTrailersTcs.TrySetException(ex); throw; } - }); - - await requestStream.SendHeadersAsync(headers, endStream: false); + }, headers, endStream: false); await requestStream.SendDataAsync(Encoding.UTF8.GetBytes("Hello world")); await requestStream.SendHeadersAsync(trailers, endStream: false); @@ -2181,13 +2079,21 @@ public class Http3StreamTests : Http3TestBase } [Theory] - [InlineData(nameof(Http3FrameType.MaxPushId))] - [InlineData(nameof(Http3FrameType.Settings))] - [InlineData(nameof(Http3FrameType.CancelPush))] - [InlineData(nameof(Http3FrameType.GoAway))] - public async Task UnexpectedRequestFrame(string frameType) + [InlineData(nameof(Http3FrameType.MaxPushId), true)] + [InlineData(nameof(Http3FrameType.Settings), true)] + [InlineData(nameof(Http3FrameType.CancelPush), true)] + [InlineData(nameof(Http3FrameType.GoAway), true)] + [InlineData(nameof(Http3FrameType.MaxPushId), false)] + [InlineData(nameof(Http3FrameType.Settings), false)] + [InlineData(nameof(Http3FrameType.CancelPush), false)] + [InlineData(nameof(Http3FrameType.GoAway), false)] + public async Task UnexpectedRequestFrame(string frameType, bool pendingStreamsEnabled) { - var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(_echoApplication); + Http3Api._serviceContext.ServerOptions.EnableWebTransportAndH3Datagrams = pendingStreamsEnabled; + + var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(_echoApplication, null); + + await (pendingStreamsEnabled ? requestStream.OnUnidentifiedStreamCreatedTask : requestStream.OnStreamCreatedTask); var f = Enum.Parse<Http3FrameType>(frameType); await requestStream.SendFrameAsync(f, Memory<byte>.Empty); @@ -2207,7 +2113,16 @@ public class Http3StreamTests : Http3TestBase [InlineData(nameof(Http3FrameType.PushPromise))] public async Task UnexpectedServerFrame(string frameType) { - var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(_echoApplication); + var headers = new[] + { + new KeyValuePair<string, string>(PseudoHeaderNames.Method, "GET"), + new KeyValuePair<string, string>(PseudoHeaderNames.Path, "/"), + new KeyValuePair<string, string>(PseudoHeaderNames.Scheme, "http"), + }; + + var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(_echoApplication, headers); + + await requestStream.OnStreamCreatedTask; var f = Enum.Parse<Http3FrameType>(frameType); await requestStream.SendFrameAsync(f, Memory<byte>.Empty); @@ -2220,7 +2135,7 @@ public class Http3StreamTests : Http3TestBase [Fact] public async Task RequestIncomplete() { - var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(_echoApplication); + var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(_echoApplication, null); await requestStream.EndStreamAsync(); @@ -2350,9 +2265,7 @@ public class Http3StreamTests : Http3TestBase [MemberData(nameof(ConnectMissingPseudoHeaderFieldData))] public async Task HEADERS_Received_HeaderBlockDoesNotContainMandatoryPseudoHeaderField_MethodIsCONNECT_NoError(IEnumerable<KeyValuePair<string, string>> headers) { - var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(_noopApplication); - - await requestStream.SendHeadersAsync(headers, endStream: true); + var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(_noopApplication, headers, endStream: true); await requestStream.ExpectHeadersAsync(); @@ -2368,8 +2281,7 @@ public class Http3StreamTests : Http3TestBase private async Task HEADERS_Received_InvalidHeaderFields_StreamError(IEnumerable<KeyValuePair<string, string>> headers, string expectedErrorMessage, Http3ErrorCode? errorCode = null) { - var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(_noopApplication); - await requestStream.SendHeadersAsync(headers, endStream: true); + var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(_noopApplication, headers, endStream: true); await requestStream.WaitForStreamErrorAsync( errorCode ?? Http3ErrorCode.MessageError, @@ -2381,9 +2293,7 @@ public class Http3StreamTests : Http3TestBase [MemberData(nameof(MissingPseudoHeaderFieldData))] public async Task HEADERS_Received_HeaderBlockDoesNotContainMandatoryPseudoHeaderField_StreamError(IEnumerable<KeyValuePair<string, string>> headers) { - var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(_noopApplication); - - await requestStream.SendHeadersAsync(headers, endStream: true); + var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(_noopApplication, headers, endStream: true); await requestStream.WaitForStreamErrorAsync( Http3ErrorCode.MessageError, expectedErrorMessage: CoreStrings.HttpErrorMissingMandatoryPseudoHeaderFields); @@ -2483,9 +2393,7 @@ public class Http3StreamTests : Http3TestBase new KeyValuePair<string, string>("te", "trailers") }; - var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(_noopApplication); - - await requestStream.SendHeadersAsync(headers, endStream: true); + var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(_noopApplication, headers, endStream: true); await requestStream.ExpectHeadersAsync(); @@ -2510,9 +2418,7 @@ public class Http3StreamTests : Http3TestBase Assert.Equal(12, read); read = await context.Request.Body.ReadAsync(buffer, 0, buffer.Length); Assert.Equal(0, read); - }); - - await requestStream.SendHeadersAsync(headers, endStream: false); + }, headers, endStream: false); await requestStream.SendDataAsync(new byte[12], endStream: true); var receivedHeaders = await requestStream.ExpectHeadersAsync(); @@ -2549,9 +2455,7 @@ public class Http3StreamTests : Http3TestBase while (await context.Request.Body.ReadAsync(buffer, 0, buffer.Length) > 0) { } }); ExceptionDispatchInfo.Capture(exception).Throw(); - }); - - await requestStream.SendHeadersAsync(headers, endStream: false); + }, headers, endStream: false); var receivedHeaders = await requestStream.ExpectHeadersAsync(); @@ -2587,9 +2491,7 @@ public class Http3StreamTests : Http3TestBase Assert.Equal(12, read); read = await context.Request.Body.ReadAsync(buffer, 0, buffer.Length); Assert.Equal(0, read); - }); - - await requestStream.SendHeadersAsync(headers, endStream: false); + }, headers, endStream: false); await requestStream.SendDataAsync(new byte[12], endStream: true); var receivedHeaders = await requestStream.ExpectHeadersAsync(); @@ -2625,9 +2527,7 @@ public class Http3StreamTests : Http3TestBase while (await context.Request.Body.ReadAsync(buffer, 0, buffer.Length) > 0) { } }); ExceptionDispatchInfo.Capture(exception).Throw(); - }); - - await requestStream.SendHeadersAsync(headers, endStream: false); + }, headers, endStream: false); await requestStream.SendDataAsync(new byte[6], endStream: false); await requestStream.SendDataAsync(new byte[6], endStream: false); @@ -2681,9 +2581,7 @@ public class Http3StreamTests : Http3TestBase }); Assert.True(context.Features.Get<IHttpMaxRequestBodySizeFeature>().IsReadOnly); ExceptionDispatchInfo.Capture(exception).Throw(); - }); - - await requestStream.SendHeadersAsync(headers, endStream: false); + }, headers, endStream: false); await requestStream.SendDataAsync(new byte[6], endStream: false); await requestStream.SendDataAsync(new byte[6], endStream: false); await requestStream.SendDataAsync(new byte[6], endStream: false); @@ -2732,9 +2630,7 @@ public class Http3StreamTests : Http3TestBase Assert.True(context.Features.Get<IHttpMaxRequestBodySizeFeature>().IsReadOnly); read = await context.Request.Body.ReadAsync(buffer, 0, buffer.Length); Assert.Equal(0, read); - }); - - await requestStream.SendHeadersAsync(headers, endStream: false); + }, headers, endStream: false); await requestStream.SendDataAsync(new byte[12], endStream: true); var receivedHeaders = await requestStream.ExpectHeadersAsync(); @@ -2782,14 +2678,13 @@ public class Http3StreamTests : Http3TestBase CoreStrings.FormatHttp3ControlStreamErrorUnsupportedType(typeId)).DefaultTimeout(); // Connection is still alive and available for requests - var requestStream = await Http3Api.CreateRequestStream().DefaultTimeout(); - await requestStream.SendHeadersAsync(new[] + var requestStream = await Http3Api.CreateRequestStream(new[] { - new KeyValuePair<string, string>(PseudoHeaderNames.Path, "/"), - new KeyValuePair<string, string>(PseudoHeaderNames.Scheme, "http"), - new KeyValuePair<string, string>(PseudoHeaderNames.Method, "GET"), - new KeyValuePair<string, string>(PseudoHeaderNames.Authority, "localhost:80"), - }, endStream: true); + new KeyValuePair<string, string>(PseudoHeaderNames.Path, "/"), + new KeyValuePair<string, string>(PseudoHeaderNames.Scheme, "http"), + new KeyValuePair<string, string>(PseudoHeaderNames.Method, "GET"), + new KeyValuePair<string, string>(PseudoHeaderNames.Authority, "localhost:80"), + }, endStream: true).DefaultTimeout(); await requestStream.ExpectHeadersAsync().DefaultTimeout(); await requestStream.ExpectReceiveEndOfStream().DefaultTimeout(); @@ -2815,14 +2710,13 @@ public class Http3StreamTests : Http3TestBase Assert.Equal(Core.Internal.Http3.Http3SettingType.MaxFieldSectionSize, maxFieldSetting.Key); Assert.Equal(100, maxFieldSetting.Value); - var requestStream = await Http3Api.CreateRequestStream().DefaultTimeout(); - await requestStream.SendHeadersAsync(new[] + var requestStream = await Http3Api.CreateRequestStream(new[] { - new KeyValuePair<string, string>(PseudoHeaderNames.Path, "/"), - new KeyValuePair<string, string>(PseudoHeaderNames.Scheme, "http"), - new KeyValuePair<string, string>(PseudoHeaderNames.Method, "GET"), - new KeyValuePair<string, string>(PseudoHeaderNames.Authority, "localhost:80"), - }, endStream: true); + new KeyValuePair<string, string>(PseudoHeaderNames.Path, "/"), + new KeyValuePair<string, string>(PseudoHeaderNames.Scheme, "http"), + new KeyValuePair<string, string>(PseudoHeaderNames.Method, "GET"), + new KeyValuePair<string, string>(PseudoHeaderNames.Authority, "localhost:80"), + }, endStream: true).DefaultTimeout(); await requestStream.WaitForStreamErrorAsync( Http3ErrorCode.InternalError, @@ -2857,6 +2751,11 @@ public class Http3StreamTests : Http3TestBase { appTcs.SetException(ex); } + }, new[] + { + new KeyValuePair<string, string>(PseudoHeaderNames.Method, "POST"), + new KeyValuePair<string, string>(PseudoHeaderNames.Path, "/"), + new KeyValuePair<string, string>(PseudoHeaderNames.Scheme, "http"), }); var sourceData = new byte[1024]; @@ -2865,13 +2764,6 @@ public class Http3StreamTests : Http3TestBase sourceData[i] = (byte)(i % byte.MaxValue); } - await requestStream.SendHeadersAsync(new[] - { - new KeyValuePair<string, string>(PseudoHeaderNames.Method, "POST"), - new KeyValuePair<string, string>(PseudoHeaderNames.Path, "/"), - new KeyValuePair<string, string>(PseudoHeaderNames.Scheme, "http"), - }); - await requestStream.SendDataAsync(sourceData); var decodedHeaders = await requestStream.ExpectHeadersAsync(); Assert.Equal(2, decodedHeaders.Count); @@ -2919,9 +2811,7 @@ public class Http3StreamTests : Http3TestBase } return Task.CompletedTask; - }); - - await requestStream.SendHeadersAsync(headers); + }, headers); var responseHeaders = await requestStream.ExpectHeadersAsync(); Assert.Equal("200", responseHeaders[PseudoHeaderNames.Status]); @@ -2961,9 +2851,7 @@ public class Http3StreamTests : Http3TestBase } return Task.CompletedTask; - }); - - await requestStream.SendHeadersAsync(headers); + }, headers); var responseHeaders = await requestStream.ExpectHeadersAsync(); Assert.Equal("200", responseHeaders[PseudoHeaderNames.Status]); @@ -2991,9 +2879,7 @@ public class Http3StreamTests : Http3TestBase var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(c => { return Task.CompletedTask; - }); - - await requestStream.SendHeadersAsync(headers); + }, headers); var responseHeaders = await requestStream.ExpectHeadersAsync(expectEnd: true); Assert.Equal("200", responseHeaders[PseudoHeaderNames.Status]); @@ -3021,6 +2907,6 @@ public class Http3StreamTests : Http3TestBase Assert.True(memory.Length >= sizeHint); await context.Response.CompleteAsync(); context.Response.BodyWriter.Advance(memory.Length); - }); + }, headers); } } diff --git a/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http3/Http3TimeoutTests.cs b/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http3/Http3TimeoutTests.cs index c1b53304e5943dc61be683b64d3e0b196698fc9f..4e94029c43149fddde5ff91cad51d2fecd6db0c8 100644 --- a/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http3/Http3TimeoutTests.cs +++ b/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http3/Http3TimeoutTests.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Linq; using System.Net.Http; +using System.Reflection.PortableExecutable; using System.Threading.Tasks; using Microsoft.AspNetCore.Connections; using Microsoft.AspNetCore.Http; @@ -15,27 +16,29 @@ using Microsoft.AspNetCore.Testing; using Microsoft.Extensions.Logging; using Moq; using Xunit; +using Xunit.Sdk; namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests; public class Http3TimeoutTests : Http3TestBase { + [Fact] public async Task HEADERS_IncompleteFrameReceivedWithinRequestHeadersTimeout_StreamError() { var now = _serviceContext.MockSystemClock.UtcNow; var limits = _serviceContext.ServerOptions.Limits; - var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(_noopApplication).DefaultTimeout(); + var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(_noopApplication, null).DefaultTimeout(); var controlStream = await Http3Api.GetInboundControlStream().DefaultTimeout(); await controlStream.ExpectSettingsAsync().DefaultTimeout(); - await requestStream.OnStreamCreatedTask.DefaultTimeout(); + await requestStream.SendHeadersPartialAsync().DefaultTimeout(); - var serverRequestStream = Http3Api.Connection._streams[requestStream.StreamId]; + await requestStream.OnStreamCreatedTask; - await requestStream.SendHeadersPartialAsync().DefaultTimeout(); + var serverRequestStream = Http3Api.Connection._streams[requestStream.StreamId]; Http3Api.TriggerTick(now); Http3Api.TriggerTick(now + limits.RequestHeadersTimeout); @@ -50,9 +53,13 @@ public class Http3TimeoutTests : Http3TestBase CoreStrings.BadRequest_RequestHeadersTimeout); } - [Fact] - public async Task HEADERS_HeaderFrameReceivedWithinRequestHeadersTimeout_Success() + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task HEADERS_HeaderFrameReceivedWithinRequestHeadersTimeout_Success(bool pendingStreamsEnabled) { + Http3Api._serviceContext.ServerOptions.EnableWebTransportAndH3Datagrams = pendingStreamsEnabled; + var now = _serviceContext.MockSystemClock.UtcNow; var limits = _serviceContext.ServerOptions.Limits; var headers = new[] @@ -63,14 +70,25 @@ public class Http3TimeoutTests : Http3TestBase new KeyValuePair<string, string>(PseudoHeaderNames.Authority, "localhost:80"), }; - var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(_noopApplication).DefaultTimeout(); + var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(_noopApplication, null).DefaultTimeout(); var controlStream = await Http3Api.GetInboundControlStream().DefaultTimeout(); await controlStream.ExpectSettingsAsync().DefaultTimeout(); - await requestStream.OnStreamCreatedTask.DefaultTimeout(); + dynamic serverRequestStream; - var serverRequestStream = Http3Api.Connection._streams[requestStream.StreamId]; + if (pendingStreamsEnabled) + { + await requestStream.OnUnidentifiedStreamCreatedTask.DefaultTimeout(); + + serverRequestStream = Http3Api.Connection._unidentifiedStreams[requestStream.StreamId]; + } + else + { + await requestStream.OnStreamCreatedTask.DefaultTimeout(); + + serverRequestStream = Http3Api.Connection._streams[requestStream.StreamId]; + } Http3Api.TriggerTick(now); Http3Api.TriggerTick(now + limits.RequestHeadersTimeout); @@ -90,9 +108,37 @@ public class Http3TimeoutTests : Http3TestBase await requestStream.ExpectReceiveEndOfStream(); } + [Fact] + public async Task ControlStream_HeaderNotReceivedWithinRequestHeadersTimeout_StreamError_PendingStreamsEnabled() + { + Http3Api._serviceContext.ServerOptions.EnableWebTransportAndH3Datagrams = true; + + var now = _serviceContext.MockSystemClock.UtcNow; + var limits = _serviceContext.ServerOptions.Limits; + + await Http3Api.InitializeConnectionAsync(_noopApplication).DefaultTimeout(); + + var controlStream = await Http3Api.GetInboundControlStream().DefaultTimeout(); + await controlStream.ExpectSettingsAsync().DefaultTimeout(); + + var outboundControlStream = await Http3Api.CreateControlStream(id: null); + + await outboundControlStream.OnUnidentifiedStreamCreatedTask.DefaultTimeout(); + var serverInboundControlStream = Http3Api.Connection._unidentifiedStreams[outboundControlStream.StreamId]; + + Http3Api.TriggerTick(now); + Http3Api.TriggerTick(now + limits.RequestHeadersTimeout); + + Assert.Equal((now + limits.RequestHeadersTimeout).Ticks, serverInboundControlStream.StreamTimeoutTicks); + + Http3Api.TriggerTick(now + limits.RequestHeadersTimeout + TimeSpan.FromTicks(1)); + } + [Fact] public async Task ControlStream_HeaderNotReceivedWithinRequestHeadersTimeout_StreamError() { + Http3Api._serviceContext.ServerOptions.EnableWebTransportAndH3Datagrams = false; + var now = _serviceContext.MockSystemClock.UtcNow; var limits = _serviceContext.ServerOptions.Limits; var headers = new[] @@ -132,48 +178,33 @@ public class Http3TimeoutTests : Http3TestBase { var now = _serviceContext.MockSystemClock.UtcNow; var limits = _serviceContext.ServerOptions.Limits; - var headers = new[] - { - new KeyValuePair<string, string>(PseudoHeaderNames.Method, "Custom"), - new KeyValuePair<string, string>(PseudoHeaderNames.Path, "/"), - new KeyValuePair<string, string>(PseudoHeaderNames.Scheme, "http"), - new KeyValuePair<string, string>(PseudoHeaderNames.Authority, "localhost:80"), - }; await Http3Api.InitializeConnectionAsync(_noopApplication).DefaultTimeout(); var controlStream = await Http3Api.GetInboundControlStream().DefaultTimeout(); await controlStream.ExpectSettingsAsync().DefaultTimeout(); - var outboundControlStream = await Http3Api.CreateControlStream(id: null); - - await outboundControlStream.OnStreamCreatedTask.DefaultTimeout(); - Http3Api.TriggerTick(now); - Http3Api.TriggerTick(now + limits.RequestHeadersTimeout); + Http3Api.TriggerTick(now + limits.RequestHeadersTimeout + TimeSpan.FromTicks(1)); - await outboundControlStream.WriteStreamIdAsync(id: 0); + var outboundControlStream = await Http3Api.CreateControlStream(id: 0); - await outboundControlStream.OnHeaderReceivedTask.DefaultTimeout(); + await outboundControlStream.OnStreamCreatedTask.DefaultTimeout(); Http3Api.TriggerTick(now + limits.RequestHeadersTimeout + TimeSpan.FromTicks(1)); } - [Fact] - public async Task ControlStream_RequestHeadersTimeoutMaxValue_ExpirationIsMaxValue() + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task ControlStream_RequestHeadersTimeoutMaxValue_ExpirationIsMaxValue(bool pendingStreamEnabled) { + Http3Api._serviceContext.ServerOptions.EnableWebTransportAndH3Datagrams = pendingStreamEnabled; + var now = _serviceContext.MockSystemClock.UtcNow; var limits = _serviceContext.ServerOptions.Limits; limits.RequestHeadersTimeout = TimeSpan.MaxValue; - var headers = new[] - { - new KeyValuePair<string, string>(PseudoHeaderNames.Method, "Custom"), - new KeyValuePair<string, string>(PseudoHeaderNames.Path, "/"), - new KeyValuePair<string, string>(PseudoHeaderNames.Scheme, "http"), - new KeyValuePair<string, string>(PseudoHeaderNames.Authority, "localhost:80"), - }; - await Http3Api.InitializeConnectionAsync(_noopApplication).DefaultTimeout(); var controlStream = await Http3Api.GetInboundControlStream().DefaultTimeout(); @@ -181,9 +212,17 @@ public class Http3TimeoutTests : Http3TestBase var outboundControlStream = await Http3Api.CreateControlStream(id: null); - await outboundControlStream.OnStreamCreatedTask.DefaultTimeout(); - - var serverInboundControlStream = Http3Api.Connection._streams[outboundControlStream.StreamId]; + dynamic serverInboundControlStream; + if (pendingStreamEnabled) + { + await outboundControlStream.OnUnidentifiedStreamCreatedTask.DefaultTimeout(); + serverInboundControlStream = Http3Api.Connection._unidentifiedStreams[outboundControlStream.StreamId]; + } + else + { + await outboundControlStream.OnStreamCreatedTask.DefaultTimeout(); + serverInboundControlStream = Http3Api.Connection._streams[outboundControlStream.StreamId]; + } Http3Api.TriggerTick(now); @@ -201,13 +240,13 @@ public class Http3TimeoutTests : Http3TestBase Http3Api._timeoutControl.Initialize(mockSystemClock.UtcNow.Ticks); - var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(_readRateApplication); + await Http3Api.InitializeConnectionAsync(_readRateApplication); var inboundControlStream = await Http3Api.GetInboundControlStream(); await inboundControlStream.ExpectSettingsAsync(); // _helloWorldBytes is 12 bytes, and 12 bytes / 240 bytes/sec = .05 secs which is far below the grace period. - await requestStream.SendHeadersAsync(ReadRateRequestHeaders(_helloWorldBytes.Length), endStream: false); + var requestStream = await Http3Api.CreateRequestStream(ReadRateRequestHeaders(_helloWorldBytes.Length), endStream: false); await requestStream.SendDataAsync(_helloWorldBytes, endStream: false); await requestStream.ExpectHeadersAsync(); @@ -247,17 +286,13 @@ public class Http3TimeoutTests : Http3TestBase var inboundControlStream = await Http3Api.GetInboundControlStream(); await inboundControlStream.ExpectSettingsAsync(); - var requestStream = await Http3Api.CreateRequestStream(); - - requestStream.StartStreamDisposeTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - - await requestStream.SendHeadersAsync(new[] + var requestStream = await Http3Api.CreateRequestStream(new[] { new KeyValuePair<string, string>(PseudoHeaderNames.Path, "/"), new KeyValuePair<string, string>(PseudoHeaderNames.Scheme, "http"), new KeyValuePair<string, string>(PseudoHeaderNames.Method, "GET"), new KeyValuePair<string, string>(PseudoHeaderNames.Authority, "localhost:80"), - }, endStream: true); + }, null, true, new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously)); await requestStream.OnDisposingTask.DefaultTimeout(); @@ -318,9 +353,7 @@ public class Http3TimeoutTests : Http3TestBase Http3Api._timeoutControl.Initialize(mockSystemClock.UtcNow.Ticks); var app = new EchoAppWithNotification(); - var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(app.RunApp); - - await requestStream.SendHeadersAsync(_browserRequestHeaders, endStream: false); + var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(app.RunApp, _browserRequestHeaders, endStream: false); await requestStream.SendDataAsync(_helloWorldBytes, endStream: true); await requestStream.ExpectHeadersAsync(); @@ -362,9 +395,7 @@ public class Http3TimeoutTests : Http3TestBase Http3Api._timeoutControl.Initialize(mockSystemClock.UtcNow.Ticks); var app = new EchoAppWithNotification(); - var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(app.RunApp); - - await requestStream.SendHeadersAsync(_browserRequestHeaders, endStream: false); + var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(app.RunApp, _browserRequestHeaders, endStream: false); await requestStream.SendDataAsync(_maxData, endStream: true); await requestStream.ExpectHeadersAsync(); @@ -403,13 +434,13 @@ public class Http3TimeoutTests : Http3TestBase Http3Api._timeoutControl.Initialize(mockSystemClock.UtcNow.Ticks); - var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(_readRateApplication); + await Http3Api.InitializeConnectionAsync(_readRateApplication); var inboundControlStream = await Http3Api.GetInboundControlStream(); await inboundControlStream.ExpectSettingsAsync(); // _maxData is 16 KiB, and 16 KiB / 240 bytes/sec ~= 68 secs which is far above the grace period. - await requestStream.SendHeadersAsync(ReadRateRequestHeaders(_maxData.Length), endStream: false); + var requestStream = await Http3Api.CreateRequestStream(ReadRateRequestHeaders(_maxData.Length), endStream: false); await requestStream.SendDataAsync(_maxData, endStream: false); await requestStream.ExpectHeadersAsync(); @@ -454,18 +485,14 @@ public class Http3TimeoutTests : Http3TestBase var inboundControlStream = await Http3Api.GetInboundControlStream(); await inboundControlStream.ExpectSettingsAsync(); - var requestStream1 = await Http3Api.CreateRequestStream(); - // _maxData is 16 KiB, and 16 KiB / 240 bytes/sec ~= 68 secs which is far above the grace period. - await requestStream1.SendHeadersAsync(ReadRateRequestHeaders(_maxData.Length), endStream: false); + var requestStream1 = await Http3Api.CreateRequestStream(ReadRateRequestHeaders(_maxData.Length), endStream: false); await requestStream1.SendDataAsync(_maxData, endStream: false); await requestStream1.ExpectHeadersAsync(); await requestStream1.ExpectDataAsync(); - var requestStream2 = await Http3Api.CreateRequestStream(); - - await requestStream2.SendHeadersAsync(ReadRateRequestHeaders(_maxData.Length), endStream: false); + var requestStream2 = await Http3Api.CreateRequestStream(ReadRateRequestHeaders(_maxData.Length), endStream: false); await requestStream2.SendDataAsync(_maxData, endStream: false); await requestStream2.ExpectHeadersAsync(); @@ -514,10 +541,9 @@ public class Http3TimeoutTests : Http3TestBase await inboundControlStream.ExpectSettingsAsync(); Logger.LogInformation("Sending first request"); - var requestStream1 = await Http3Api.CreateRequestStream(); // _maxData is 16 KiB, and 16 KiB / 240 bytes/sec ~= 68 secs which is far above the grace period. - await requestStream1.SendHeadersAsync(ReadRateRequestHeaders(_maxData.Length), endStream: false); + var requestStream1 = await Http3Api.CreateRequestStream(ReadRateRequestHeaders(_maxData.Length), endStream: false); await requestStream1.SendDataAsync(_maxData, endStream: true); await requestStream1.ExpectHeadersAsync(); @@ -526,9 +552,7 @@ public class Http3TimeoutTests : Http3TestBase await requestStream1.ExpectReceiveEndOfStream(); Logger.LogInformation("Sending second request"); - var requestStream2 = await Http3Api.CreateRequestStream(); - - await requestStream2.SendHeadersAsync(ReadRateRequestHeaders(_maxData.Length), endStream: false); + var requestStream2 = await Http3Api.CreateRequestStream(ReadRateRequestHeaders(_maxData.Length), endStream: false); await requestStream2.SendDataAsync(_maxData, endStream: false); await requestStream2.ExpectHeadersAsync(); @@ -567,7 +591,7 @@ public class Http3TimeoutTests : Http3TestBase Http3Api._timeoutControl.Initialize(mockSystemClock.UtcNow.Ticks); - var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(context => + await Http3Api.InitializeConnectionAsync(context => { // Completely disable rate limiting for this stream. context.Features.Get<IHttpMinRequestBodyDataRateFeature>().MinDataRate = null; @@ -577,8 +601,10 @@ public class Http3TimeoutTests : Http3TestBase var inboundControlStream = await Http3Api.GetInboundControlStream(); await inboundControlStream.ExpectSettingsAsync(); + Http3Api.OutboundControlStream = await Http3Api.CreateControlStream(); + // _helloWorldBytes is 12 bytes, and 12 bytes / 240 bytes/sec = .05 secs which is far below the grace period. - await requestStream.SendHeadersAsync(ReadRateRequestHeaders(_helloWorldBytes.Length), endStream: false); + var requestStream = await Http3Api.CreateRequestStream(ReadRateRequestHeaders(_helloWorldBytes.Length), endStream: false); await requestStream.SendDataAsync(_helloWorldBytes, endStream: false); await requestStream.ExpectHeadersAsync(); diff --git a/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http3/WebTransportTests.cs b/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http3/WebTransport/WebTransportHandshakeTests.cs similarity index 79% rename from src/Servers/Kestrel/test/InMemory.FunctionalTests/Http3/WebTransportTests.cs rename to src/Servers/Kestrel/test/InMemory.FunctionalTests/Http3/WebTransport/WebTransportHandshakeTests.cs index 6224d44e719e705413c3a69e5f40a2301b1e31f7..5fd8ccc9b083d6d5416dfb80dd5861e82ee65f3d 100644 --- a/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http3/WebTransportTests.cs +++ b/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http3/WebTransport/WebTransportHandshakeTests.cs @@ -3,21 +3,50 @@ using System.Net; using System.Net.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http3; +using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.WebTransport; +using Microsoft.AspNetCore.Server.Kestrel.Core.Tests; using Microsoft.AspNetCore.Testing; using Microsoft.Net.Http.Headers; using Http3SettingType = Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http3.Http3SettingType; namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests; -public class WebTransportTests : Http3TestBase +public class WebTransportHandshakeTests : Http3TestBase { [Fact] public async Task WebTransportHandshake_ClientToServerPasses() { _serviceContext.ServerOptions.EnableWebTransportAndH3Datagrams = true; - await Http3Api.InitializeConnectionAsync(_noopApplication); + var appCompletedTcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously); + + await Http3Api.InitializeConnectionAsync(async context => + { + var success = true; + + var webTransportFeature = context.Features.GetRequiredFeature<IHttpWebTransportFeature>(); + + success &= webTransportFeature.IsWebTransportRequest; + +#pragma warning disable CA2252 // This API requires opting into preview features + try + { + var session = await webTransportFeature.AcceptAsync(CancellationToken.None).DefaultTimeout(); // todo session is null here + + success &= session is not null; + + appCompletedTcs.SetResult(success); + } + catch (TimeoutException) + { + appCompletedTcs.SetResult(false); + } +#pragma warning restore CA2252 + + }); var controlStream = await Http3Api.CreateControlStream(); var controlStream2 = await Http3Api.GetInboundControlStream(); @@ -34,23 +63,23 @@ public class WebTransportTests : Http3TestBase Assert.Equal(1, response1[(long)Http3SettingType.EnableWebTransport]); - var requestStream = await Http3Api.CreateRequestStream(); - var headersConnectFrame = new[] + var requestStream = await Http3Api.CreateRequestStream(new[] { new KeyValuePair<string, string>(PseudoHeaderNames.Method, "CONNECT"), new KeyValuePair<string, string>(PseudoHeaderNames.Protocol, "webtransport"), new KeyValuePair<string, string>(PseudoHeaderNames.Scheme, "http"), new KeyValuePair<string, string>(PseudoHeaderNames.Path, "/"), new KeyValuePair<string, string>(PseudoHeaderNames.Authority, "server.example.com"), - new KeyValuePair<string, string>(HeaderNames.Origin, "server.example.com") - }; + new KeyValuePair<string, string>(HeaderNames.Origin, "server.example.com"), + new KeyValuePair<string, string>(WebTransportSession.CurrentSuppportedVersion, "1") + }); - await requestStream.SendHeadersAsync(headersConnectFrame); var response2 = await requestStream.ExpectHeadersAsync(); Assert.Equal((int)HttpStatusCode.OK, Convert.ToInt32(response2[PseudoHeaderNames.Status], null)); await requestStream.OnDisposedTask.DefaultTimeout(); + Assert.True(await appCompletedTcs.Task); } [Theory] @@ -79,7 +108,7 @@ public class WebTransportTests : Http3TestBase nameof(PseudoHeaderNames.Scheme), "http", nameof(PseudoHeaderNames.Path), "/", nameof(HeaderNames.Origin), "server.example.com")] // no authority - public async Task WebTransportHandshake_IncorrectHeadersRejects(long error, string targetErrorMessage, params string[] headers) // todo replace the "" with CoreStrings.... then push (maybe also update the waitforstreamerror function) and resolve stephen's comment + public async Task WebTransportHandshake_IncorrectHeadersRejects(long error, string targetErrorMessage, params string[] headers) { _serviceContext.ServerOptions.EnableWebTransportAndH3Datagrams = true; @@ -100,14 +129,13 @@ public class WebTransportTests : Http3TestBase Assert.Equal(1, response1[(long)Http3SettingType.EnableWebTransport]); - var requestStream = await Http3Api.CreateRequestStream(); - var headersConnectFrame = new List<KeyValuePair<string, string>>(); for (var i = 0; i < headers.Length; i += 2) { headersConnectFrame.Add(new KeyValuePair<string, string>(GetHeaderFromName(headers[i]), headers[i + 1])); } - await requestStream.SendHeadersAsync(headersConnectFrame); + + var requestStream = await Http3Api.CreateRequestStream(headersConnectFrame); await requestStream.WaitForStreamErrorAsync((Http3ErrorCode)error, AssertExpectedErrorMessages, GetCoreStringFromName(targetErrorMessage)); } diff --git a/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http3/WebTransport/WebTransportSessionTests.cs b/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http3/WebTransport/WebTransportSessionTests.cs new file mode 100644 index 0000000000000000000000000000000000000000..310939aa063e9b94d4b1dd2e4653600502674001 --- /dev/null +++ b/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http3/WebTransport/WebTransportSessionTests.cs @@ -0,0 +1,101 @@ +// 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.Connections.Features; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Server.Kestrel.Core.WebTransport; + +namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests; + +public class WebTransportSessionTests : Http3TestBase +{ + [Fact] + public async Task WebTransportSession_CanOpenNewStream() + { + Http3Api._serviceContext.ServerOptions.EnableWebTransportAndH3Datagrams = true; + + var exitTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var session = await WebTransportTestUtilities.GenerateSession(Http3Api, exitTcs); + + var stream = await session.OpenUnidirectionalStreamAsync(CancellationToken.None); + + //verify that we opened an output stream + Assert.NotNull(stream); + var streamDirectionFeature = stream.Features.GetRequiredFeature<IStreamDirectionFeature>(); + Assert.True(streamDirectionFeature.CanWrite); + Assert.False(streamDirectionFeature.CanRead); + + // end the application + exitTcs.SetResult(); + } + + [Fact] + public async Task WebTransportSession_AcceptNewStreamsInOrderOfArrival() + { + Http3Api._serviceContext.ServerOptions.EnableWebTransportAndH3Datagrams = true; // TODO add more sync code as now it is flaky + + var exitTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var session = await WebTransportTestUtilities.GenerateSession(Http3Api, exitTcs); + + // pretend that we received 2 new stream requests from a client + session.AddStream(WebTransportTestUtilities.CreateStream(WebTransportStreamType.Bidirectional)); + session.AddStream(WebTransportTestUtilities.CreateStream(WebTransportStreamType.Input)); + + var stream = await session.AcceptStreamAsync(CancellationToken.None); + + // verify that we accepted a bidirectional stream + Assert.NotNull(stream); + var streamDirectionFeature = stream.Features.GetRequiredFeature<IStreamDirectionFeature>(); + Assert.True(streamDirectionFeature.CanWrite); + Assert.True(streamDirectionFeature.CanRead); + + var stream2 = await session.AcceptStreamAsync(CancellationToken.None); + + // verify that we accepted a unidirectional stream + Assert.NotNull(stream2); + var streamDirectionFeature2 = stream2.Features.GetRequiredFeature<IStreamDirectionFeature>(); + Assert.False(streamDirectionFeature2.CanWrite); + Assert.True(streamDirectionFeature2.CanRead); + + exitTcs.SetResult(); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + public async Task WebTransportSession_ClosesProperly(int method) + { + Http3Api._serviceContext.ServerOptions.EnableWebTransportAndH3Datagrams = true; + + var exitTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var session = await WebTransportTestUtilities.GenerateSession(Http3Api, exitTcs); + + switch (method) + { + case 0: // manual abort + session.Abort(new(), System.Net.Http.Http3ErrorCode.InternalError); + break; + case 1: // manual graceful close + session.OnClientConnectionClosed(); + break; + case 2: // automatic graceful close due to application and connection ending + exitTcs.SetResult(); + break; + case 3: // automatic abort due to host stream aborting + Http3Api.Connection._streams[session.SessionId].Abort(new(), System.Net.Http.Http3ErrorCode.InternalError); + break; + } + + // check that all future method calls which are not related to closing throw + Assert.Null(await session.AcceptStreamAsync(CancellationToken.None)); + Assert.Null(await session.OpenUnidirectionalStreamAsync(CancellationToken.None)); + + // doublec check that no exceptions are thrown + var _ = WebTransportTestUtilities.CreateStream(WebTransportStreamType.Bidirectional); + + exitTcs.TrySetResult(); + } +} diff --git a/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http3/WebTransport/WebTransportStreamTests.cs b/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http3/WebTransport/WebTransportStreamTests.cs new file mode 100644 index 0000000000000000000000000000000000000000..a3da524f66a5f1bafb7c142f8e13e80d1ab45fea --- /dev/null +++ b/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http3/WebTransport/WebTransportStreamTests.cs @@ -0,0 +1,52 @@ +// 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.Connections.Features; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Server.Kestrel.Core.WebTransport; + +namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests; + +public class WebTransportStreamTests : Http3TestBase +{ + private static readonly byte[] RandomBytes = new byte[5] { 0x61, 0x62, 0x63, 0x64, 0x65 }; + + [Theory] + [InlineData(WebTransportStreamType.Bidirectional, true, true)] + [InlineData(WebTransportStreamType.Input, true, false)] + [InlineData(WebTransportStreamType.Output, false, true)] + internal async Task WebTransportStream_StreamTypesAreDefinedCorrectly(WebTransportStreamType type, bool canRead, bool canWrite) + { + var memory = new Memory<byte>(new byte[5]); + var stream = WebTransportTestUtilities.CreateStream(type, memory); + + var streamDirectionFeature = stream.Features.GetRequiredFeature<IStreamDirectionFeature>(); + Assert.Equal(canRead, streamDirectionFeature.CanRead); + Assert.Equal(canWrite, streamDirectionFeature.CanWrite); + + await stream.DisposeAsync(); + + // test that you can't write or read from a stream after disposing + Assert.False(streamDirectionFeature.CanRead); + Assert.False(streamDirectionFeature.CanWrite); + } + + [Fact] + internal async Task WebTransportStream_WritingFlushingReadingWorks() + { + var memory = new Memory<byte>(new byte[5]); + + var stream = WebTransportTestUtilities.CreateStream(WebTransportStreamType.Bidirectional, memory); + + var input = new ReadOnlyMemory<byte>(RandomBytes); + await stream.Transport.Output.WriteAsync(input, CancellationToken.None); + + await stream.Transport.Output.FlushAsync(); + + var memoryOut = new Memory<byte>(new byte[5]); + var length = await stream.Transport.Input.AsStream().ReadAsync(memoryOut, CancellationToken.None); + + Assert.Equal(5, length); + Assert.Equal(input.ToArray(), memoryOut.ToArray()); + } +} diff --git a/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http3/WebTransport/WebTransportTestUtilities.cs b/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http3/WebTransport/WebTransportTestUtilities.cs new file mode 100644 index 0000000000000000000000000000000000000000..32503364283b1004ad3d87b6620e2c106799edc2 --- /dev/null +++ b/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http3/WebTransport/WebTransportTestUtilities.cs @@ -0,0 +1,199 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers; +using System.IO.Pipelines; +using Microsoft.AspNetCore.Connections.Features; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Server.Kestrel.Core.Features; +using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http; +using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http3; +using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.WebTransport; +using Microsoft.AspNetCore.Server.Kestrel.Core.WebTransport; +using Microsoft.AspNetCore.Testing; +using Microsoft.Net.Http.Headers; +using Moq; + +namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests; + +internal class WebTransportTestUtilities +{ + private static int streamCounter; + + public static async ValueTask<WebTransportSession> GenerateSession(Http3InMemory inMemory, TaskCompletionSource exitSessionTcs) + { + var appCompletedTcs = new TaskCompletionSource<IWebTransportSession>(TaskCreationOptions.RunContinuationsAsynchronously); + + await inMemory.InitializeConnectionAsync(async context => + { + var webTransportFeature = context.Features.GetRequiredFeature<IHttpWebTransportFeature>(); + +#pragma warning disable CA2252 // This API requires opting into preview features + try + { + var session = await webTransportFeature.AcceptAsync(CancellationToken.None).DefaultTimeout(); + appCompletedTcs.SetResult(session); + } + catch (TimeoutException exception) + { + appCompletedTcs.SetException(exception); + } +#pragma warning restore CA2252 + + // wait for the test to tell us to kill the application + await exitSessionTcs.Task; + }); + var controlStream = await inMemory.CreateControlStream(); + var controlStream2 = await inMemory.GetInboundControlStream(); + + var settings = new Http3PeerSettings() + { + EnableWebTransport = 1, + H3Datagram = 1, + }; + + await controlStream.SendSettingsAsync(settings.GetNonProtocolDefaults()); + var response1 = await controlStream2.ExpectSettingsAsync(); + + await inMemory.ServerReceivedSettingsReader.ReadAsync().DefaultTimeout(); + + var requestStream = await inMemory.CreateRequestStream(new[] + { + new KeyValuePair<string, string>(PseudoHeaderNames.Method, "CONNECT"), + new KeyValuePair<string, string>(PseudoHeaderNames.Protocol, "webtransport"), + new KeyValuePair<string, string>(PseudoHeaderNames.Scheme, "http"), + new KeyValuePair<string, string>(PseudoHeaderNames.Path, "/"), + new KeyValuePair<string, string>(PseudoHeaderNames.Authority, "server.example.com"), + new KeyValuePair<string, string>(HeaderNames.Origin, "server.example.com"), + new KeyValuePair<string, string>(WebTransportSession.CurrentSuppportedVersion, "1") + }); + + return (WebTransportSession)await appCompletedTcs.Task; + } + + public static WebTransportStream CreateStream(WebTransportStreamType type, Memory<byte>? memory = null) + { + var features = new FeatureCollection(); + features.Set<IStreamIdFeature>(new StreamId(streamCounter++)); + features.Set<IStreamDirectionFeature>(new DefaultStreamDirectionFeature(type != WebTransportStreamType.Output, type != WebTransportStreamType.Input)); + features.Set(Mock.Of<IConnectionItemsFeature>()); + features.Set(Mock.Of<IProtocolErrorCodeFeature>()); + + var writer = new HttpResponsePipeWriter(new StreamWriterControl(memory)); + writer.StartAcceptingWrites(); + var transport = new DuplexPipe(new StreamReader(memory), writer); + return new WebTransportStream(TestContextFactory.CreateHttp3StreamContext("id", null, new TestServiceContext(), features, null, null, null, transport), type); + } + + class StreamId : IStreamIdFeature + { + private readonly int _id; + long IStreamIdFeature.StreamId => _id; + + public StreamId(int id = 1) + { + _id = id; + } + } + + class StreamWriterControl : IHttpResponseControl + { + readonly Memory<byte>? _sharedMemory; + + public StreamWriterControl(Memory<byte>? sharedMemory = null) + { + _sharedMemory = sharedMemory; + } + + public void Advance(int bytes) + { + // no-op + } + + public void CancelPendingFlush() + { + // no-op + } + + public ValueTask<FlushResult> FlushPipeAsync(CancellationToken cancellationToken) + { + // no-op + return new ValueTask<FlushResult>(); + } + + public Memory<byte> GetMemory(int sizeHint = 0) + { + if (_sharedMemory is null) + { + throw new NullReferenceException(); + } + return (Memory<byte>)_sharedMemory; + } + + public Span<byte> GetSpan(int sizeHint = 0) + { + return GetMemory(sizeHint).Span; + } + + public ValueTask<FlushResult> ProduceContinueAsync() + { + // no-op + return new ValueTask<FlushResult>(); + } + + public Task CompleteAsync(Exception exception) + { + // no-op + return Task.CompletedTask; + } + + public ValueTask<FlushResult> WritePipeAsync(ReadOnlyMemory<byte> source, CancellationToken cancellationToken) + { + source.CopyTo(GetMemory()); + return new ValueTask<FlushResult>(); + } + } + + class StreamReader : PipeReader + { + readonly Memory<byte>? _sharedMemory; + + public StreamReader(Memory<byte>? sharedMemory = null) + { + _sharedMemory = sharedMemory; + } + + public override void AdvanceTo(SequencePosition consumed) + { + // no-op + } + + public override void AdvanceTo(SequencePosition consumed, SequencePosition examined) + { + // no-op + } + + public override void CancelPendingRead() + { + throw new NotImplementedException(); + } + + public override void Complete(Exception exception = null) + { + // no-op + } + + public override ValueTask<ReadResult> ReadAsync(CancellationToken cancellationToken = default) + { + // just return the whole memory as a readResult + return new ValueTask<ReadResult>(new ReadResult( + new ReadOnlySequence<byte>((ReadOnlyMemory<byte>)_sharedMemory), false, true)); + } + + public override bool TryRead(out ReadResult result) + { + result = new ReadResult(new ReadOnlySequence<byte>((ReadOnlyMemory<byte>)_sharedMemory), false, true); + return true; + } + } +} diff --git a/src/Servers/Kestrel/tools/CodeGenerator/HttpProtocolFeatureCollection.cs b/src/Servers/Kestrel/tools/CodeGenerator/HttpProtocolFeatureCollection.cs index 840ebe58f0cde401e220150d464c10493eba17d9..3880971c7560706098e3e9cccfbbe2a9b17dd7b7 100644 --- a/src/Servers/Kestrel/tools/CodeGenerator/HttpProtocolFeatureCollection.cs +++ b/src/Servers/Kestrel/tools/CodeGenerator/HttpProtocolFeatureCollection.cs @@ -39,6 +39,7 @@ public class HttpProtocolFeatureCollection "IHttpExtendedConnectFeature", "IHttpUpgradeFeature", "IHttpWebSocketFeature", + "IHttpWebTransportFeature", "IBadRequestExceptionFeature" }; var maybeFeatures = new[] @@ -79,6 +80,7 @@ public class HttpProtocolFeatureCollection "IHttpBodyControlFeature", "IHttpMaxRequestBodySizeFeature", "IHttpRequestBodyDetectionFeature", + "IHttpWebTransportFeature", "IBadRequestExceptionFeature" }; diff --git a/src/Shared/runtime/Http3/Http3StreamType.cs b/src/Shared/runtime/Http3/Http3StreamType.cs index b386125463c1f4f6ebb26bff236d6640d1716405..af711edd2e3f17d46e68a6f32992ca8926f1c363 100644 --- a/src/Shared/runtime/Http3/Http3StreamType.cs +++ b/src/Shared/runtime/Http3/Http3StreamType.cs @@ -26,6 +26,14 @@ namespace System.Net.Http /// <summary> /// https://tools.ietf.org/html/draft-ietf-quic-qpack-11#section-4.2 /// </summary> - QPackDecoder = 0x03 + QPackDecoder = 0x03, + /// <summary> + /// https://ietf-wg-webtrans.github.io/draft-ietf-webtrans-http3/draft-ietf-webtrans-http3.html#name-unidirectional-streams + /// </summary> + WebTransportUnidirectional = 0x54, + /// <summary> + /// https://ietf-wg-webtrans.github.io/draft-ietf-webtrans-http3/draft-ietf-webtrans-http3.html#name-bidirectional-streams + /// </summary> + WebTransportBidirectional = 0x41 } }