diff --git a/Engine/Internal/Protocol/ClientHandler.cs b/Engine/Internal/Protocol/ClientHandler.cs index 797be9821..7a6ead22c 100644 --- a/Engine/Internal/Protocol/ClientHandler.cs +++ b/Engine/Internal/Protocol/ClientHandler.cs @@ -2,6 +2,7 @@ using System.IO.Pipelines; using System.Net.Sockets; using System.Runtime.CompilerServices; +using System.Text; using GenHTTP.Api.Protocol; using GenHTTP.Engine.Internal.Context; using GenHTTP.Engine.Shared.Types; @@ -28,7 +29,7 @@ internal sealed class ClientHandler(ClientContext context) private static readonly TimeSpan KeepAliveTimeout = TimeSpan.FromSeconds(60); - private static readonly ReadOnlyMemory KeepAliveValue = "Keep-Alive"u8.ToArray(); + private static readonly ReadOnlyMemory KeepAliveValue = "keep-alive"u8.ToArray(); private static readonly ParserLimits Limits = ParserLimits.Default; @@ -203,7 +204,12 @@ internal async ValueTask HandleRequestAsync(Request request) var connectionHeader = header.Headers.GetEntry(KnownHeaders.Connection); - var keepAliveRequested = connectionHeader?.Bytes.Span.SequenceEqual(KeepAliveValue.Span) ?? (header.Protocol == HttpProtocol.Http11); + // Connection options are case-insensitive tokens (RFC 9110 7.6.1). Matching them exactly + // read a browser, which sends "keep-alive" in lower case, as asking to close - so every + // browser request got a new connection, and a new TLS handshake with it. + var keepAliveRequested = connectionHeader is { } connection + ? Ascii.EqualsIgnoreCase(connection.Bytes.Span, KeepAliveValue.Span) + : header.Protocol == HttpProtocol.Http11; var response = await context.Server.Handler.HandleAsync(request) ?? throw new InvalidOperationException("The root request handler did not return a response"); diff --git a/Engine/InternalH3Experimental/AltSvc.cs b/Engine/InternalH3Experimental/AltSvc.cs new file mode 100644 index 000000000..c3dad386f --- /dev/null +++ b/Engine/InternalH3Experimental/AltSvc.cs @@ -0,0 +1,97 @@ +using GenHTTP.Api.Content; +using GenHTTP.Api.Infrastructure; +using GenHTTP.Api.Protocol; + +namespace GenHTTP.Engine.InternalH3Experimental; + +/// +/// Advertises an HTTP/3 endpoint from a server that speaks HTTP/1.1 or HTTP/2. +/// +/// +/// Browsers never start on HTTP/3. They connect over TCP, and only try QUIC once a response has +/// told them where to find it (RFC 7838). Without this header the HTTP/3 endpoint is reachable by +/// clients told to use it explicitly, and by nobody else. +/// +/// Two things stop it working, both silently: the advertisement is only honoured when it +/// arrives over TLS, and the certificate on the HTTP/3 port must be valid for the ORIGIN's host +/// name. A wrong port produces no error at all, the browser simply keeps using HTTP/1.1. +/// +public sealed class AltSvcConcern : IConcern +{ + private readonly ByteString _value; + + public IHandler Content { get; } + + public AltSvcConcern(IHandler content, ushort port, uint maxAge) + { + Content = content; + _value = new ByteString($"h3=\":{port}\"; ma={maxAge}"); + } + + public ValueTask PrepareAsync(IServer server) => Content.PrepareAsync(server); + + public async ValueTask HandleAsync(IRequest request) + { + IResponse? response = await Content.HandleAsync(request); + + // Pointless on a connection that is already HTTP/3, and ignored by clients anyway. + if (response is not null && request.Header.Protocol != HttpProtocol.Http3) + { + response.Rebuild().Header(AltSvcName, _value); + } + + return response; + } + + private static readonly ByteString AltSvcName = new("alt-svc"); +} + +/// +/// Builder for . +/// +public sealed class AltSvcConcernBuilder : IConcernBuilder +{ + private ushort _port = 443; + + private uint _maxAge = 86400; + + /// + /// The UDP port the HTTP/3 endpoint listens on. Must match what that server bound, or clients + /// silently never upgrade. + /// + public AltSvcConcernBuilder Port(ushort port) + { + _port = port; + return this; + } + + /// How long a client may cache the advertisement, in seconds. + public AltSvcConcernBuilder MaxAge(uint seconds) + { + _maxAge = seconds; + return this; + } + + public IConcern Build(IHandler content) => new AltSvcConcern(content, _port, _maxAge); +} + +/// +/// Advertises an HTTP/3 endpoint to clients arriving over TCP. +/// +public static class AltSvc +{ + + /// + /// Adds an Alt-Svc header pointing at an HTTP/3 endpoint on the given UDP port. + /// + /// + /// + /// var h1 = GenHTTP.Engine.Internal.Host.Create() + /// .Handler(app) + /// .Add(AltSvc.To(443)) + /// .Bind(IPAddress.Any, 443, certificate); + /// + /// + public static AltSvcConcernBuilder To(ushort port) => new AltSvcConcernBuilder().Port(port); + +} diff --git a/Engine/InternalH3Experimental/GenHTTP.Engine.InternalH3Experimental.csproj b/Engine/InternalH3Experimental/GenHTTP.Engine.InternalH3Experimental.csproj new file mode 100644 index 000000000..fd15da2a6 --- /dev/null +++ b/Engine/InternalH3Experimental/GenHTTP.Engine.InternalH3Experimental.csproj @@ -0,0 +1,29 @@ + + + + + EXPERIMENTAL HTTP/3 engine for GenHTTP: QUIC via System.Net.Quic (MsQuic), HTTP/3 via Glyph3. Runs alongside another engine that serves HTTP/1.1 and advertises this one with Alt-Svc. + GenHTTP HTTP HTTP3 H3 QUIC Webserver Server Library C# Engine Experimental + README.md + + + $(NoWarn);CA1416 + + + + + + + + + + + + + + + + + + diff --git a/Engine/InternalH3Experimental/Host.cs b/Engine/InternalH3Experimental/Host.cs new file mode 100644 index 000000000..f970b3f9d --- /dev/null +++ b/Engine/InternalH3Experimental/Host.cs @@ -0,0 +1,36 @@ +using GenHTTP.Api.Infrastructure; + +using GenHTTP.Engine.InternalH3Experimental.Infrastructure; + +namespace GenHTTP.Engine.InternalH3Experimental; + +/// +/// Entry point to host an application over HTTP/3. +/// +/// +/// EXPERIMENTAL. QUIC comes from System.Net.Quic, which needs libmsquic present: Windows ships it +/// with the .NET runtime, Linux and macOS install it separately. HTTP/3 comes from Glyph3. +/// +/// Browsers do not reach HTTP/3 directly. They connect over HTTP/1.1 or HTTP/2 first and +/// only try QUIC once a server advertises it, so this engine is meant to run beside one that +/// serves TCP. See . +/// +public static class Host +{ + + /// + /// Provides a new server host serving HTTP/3 over QUIC. + /// + /// + /// Bytes of QPACK dynamic table advertised to clients, and the ceiling on what this server will + /// use for its own responses. 0 (the default) switches the mechanism off: headers are encoded + /// with the static table and literals only. + /// + /// A nonzero value lets a client compress headers it repeats - cookies and user-agent, mostly - + /// to about two bytes each. Most clients decline: curl, and .NET's own HTTP/3 client, advertise + /// no table at all. Browsers are the ones that may use it. + /// + public static IServerHost Create(int qpackDynamicTableCapacity = 0) + => new H3ServerHost(qpackDynamicTableCapacity); + +} diff --git a/Engine/InternalH3Experimental/Infrastructure/H3Server.cs b/Engine/InternalH3Experimental/Infrastructure/H3Server.cs new file mode 100644 index 000000000..bcb443351 --- /dev/null +++ b/Engine/InternalH3Experimental/Infrastructure/H3Server.cs @@ -0,0 +1,93 @@ +using System.Diagnostics; +using System.Reflection; + +using GenHTTP.Api.Content; +using GenHTTP.Api.Infrastructure; + +using GenHTTP.Engine.Shared.Infrastructure; +using GenHTTP.Engine.Shared.Types; + +using Microsoft.Extensions.Logging; + +namespace GenHTTP.Engine.InternalH3Experimental.Infrastructure; + +internal sealed class H3Server : IServer +{ + private readonly QuicEndPointCollection _endPoints; + + private readonly PropertyBag _properties = new(); + + private readonly ILogger _logger; + + public string Version { get; } + + public bool Running => !_disposed; + + public bool Development => Configuration.DevelopmentMode; + + public IHandler Handler { get; } + + public IPropertyBag Properties => _properties; + + public ILoggerFactory Logging => Configuration.Logging; + + public IEndPointCollection EndPoints => _endPoints; + + internal ServerConfiguration Configuration { get; } + + internal int QpackCapacity { get; } + + internal H3Server(ServerConfiguration configuration, IHandler handler, int qpackCapacity) + { + QpackCapacity = qpackCapacity; + + Version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "(n/a)"; + + Configuration = configuration; + + Handler = handler; + + _logger = configuration.Logging.CreateLogger(); + + _endPoints = new QuicEndPointCollection(this, configuration.EndPoints); + } + + public async ValueTask StartAsync() + { + await PrepareHandlerAsync(Handler); + + await _endPoints.StartAsync(); + } + + private async ValueTask PrepareHandlerAsync(IHandler handler) + { + try + { + var start = Stopwatch.GetTimestamp(); + + await handler.PrepareAsync(this); + + var elapsed = Stopwatch.GetElapsedTime(start); + + _logger.LogInformation("Prepared handlers in {ElapsedMs:0.##} ms", elapsed.TotalMilliseconds); + } + catch (Exception e) + { + _logger.LogCritical(e, "Failed to prepare the handler chain"); + } + } + + private bool _disposed; + + public ValueTask DisposeAsync() + { + if (!_disposed) + { + _endPoints.Dispose(); + + _disposed = true; + } + + return new(); + } +} diff --git a/Engine/InternalH3Experimental/Infrastructure/H3ServerHost.cs b/Engine/InternalH3Experimental/Infrastructure/H3ServerHost.cs new file mode 100644 index 000000000..45b557103 --- /dev/null +++ b/Engine/InternalH3Experimental/Infrastructure/H3ServerHost.cs @@ -0,0 +1,18 @@ +using GenHTTP.Api.Content; +using GenHTTP.Api.Infrastructure; + +using GenHTTP.Engine.Shared.Hosting; +using GenHTTP.Engine.Shared.Infrastructure; + +namespace GenHTTP.Engine.InternalH3Experimental.Infrastructure; + +internal sealed class H3ServerHost : ServerHost +{ + private readonly int _qpackCapacity; + + internal H3ServerHost(int qpackCapacity) => _qpackCapacity = qpackCapacity; + + protected override IServer Build(ServerConfiguration config, IHandler handler) + => new H3Server(config, handler, _qpackCapacity); + +} diff --git a/Engine/InternalH3Experimental/Infrastructure/QuicEndPoint.cs b/Engine/InternalH3Experimental/Infrastructure/QuicEndPoint.cs new file mode 100644 index 000000000..4328a6a5a --- /dev/null +++ b/Engine/InternalH3Experimental/Infrastructure/QuicEndPoint.cs @@ -0,0 +1,141 @@ +using System.Net; +using System.Net.Quic; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; + +using GenHTTP.Api.Infrastructure; + +using GenHTTP.Engine.InternalH3Experimental.Protocol; +using GenHTTP.Engine.Shared.Infrastructure; + +using Microsoft.Extensions.Logging; + +namespace GenHTTP.Engine.InternalH3Experimental.Infrastructure; + +/// +/// A QUIC listener serving HTTP/3. +/// +/// +/// Does not derive from the Internal engine's EndPoint, which creates a TCP socket in its base +/// class and hands each connection over as a Stream. QUIC has neither. +/// +internal sealed class QuicEndPoint : IEndPoint +{ + private readonly IServer _server; + + private readonly ILogger _logger; + + private readonly EndPointConfiguration _configuration; + + private readonly CancellationTokenSource _shutdown = new(); + + private QuicListener? _listener; + + private Task? _accepting; + + internal QuicEndPoint(IServer server, EndPointConfiguration configuration) + { + _server = server; + _configuration = configuration; + _logger = server.Logging.CreateLogger(); + + Address = configuration.Address; + Port = configuration.Port; + DualStack = configuration.DualStack; + } + + public IPAddress? Address { get; } + + public ushort Port { get; } + + public bool DualStack { get; } + + // QUIC has no cleartext mode: TLS 1.3 is inside the transport. + public bool Secure => true; + + internal async ValueTask StartAsync() + { + if (!QuicListener.IsSupported) + { + throw new NotSupportedException( + "QUIC is not available on this system. libmsquic must be present and TLS 1.3 supported. " + + "See https://learn.microsoft.com/dotnet/fundamentals/networking/quic/quic-overview"); + } + + if (_configuration.Security is null) + { + throw new InvalidOperationException("An HTTP/3 endpoint requires a certificate; bind it with one."); + } + + X509Certificate2 certificate = _configuration.Security.CertificateProvider.Provide(null) + ?? throw new InvalidOperationException("The certificate provider did not supply a certificate for the HTTP/3 endpoint."); + + IPAddress address = Address ?? (DualStack ? IPAddress.IPv6Any : IPAddress.Any); + + _listener = await QuicListener.ListenAsync(new QuicListenerOptions + { + ListenEndPoint = new IPEndPoint(address, Port), + ApplicationProtocols = [SslApplicationProtocol.Http3], + ConnectionOptionsCallback = (_, _, _) => ValueTask.FromResult(new QuicServerConnectionOptions + { + DefaultStreamErrorCode = 0x010c, // H3_REQUEST_CANCELLED + DefaultCloseErrorCode = 0x0100, // H3_NO_ERROR + ServerAuthenticationOptions = new SslServerAuthenticationOptions + { + ApplicationProtocols = [SslApplicationProtocol.Http3], + ServerCertificate = certificate, + }, + }), + }); + + _logger.LogInformation("Listening on {Address}:{Port} (HTTP/3 over QUIC)", address, Port); + + _accepting = Task.Run(AcceptAsync); + } + + private async Task AcceptAsync() + { + try + { + while (!_shutdown.IsCancellationRequested) + { + QuicConnection connection = await _listener!.AcceptConnectionAsync(_shutdown.Token); + + _ = ServeAsync(connection); + } + } + catch (Exception e) + { + if (!_shutdown.IsCancellationRequested) + { + _logger.LogError(e, "Failed to accept incoming connection"); + } + } + } + + private async Task ServeAsync(QuicConnection connection) + { + try + { + await H3Connection.ServeAsync(connection, _server, this, _logger, + (_server as H3Server)?.QpackCapacity ?? 0, _shutdown.Token); + } + catch (Exception e) + { + _logger.LogDebug(e, "Connection ended"); + } + } + + public void Dispose() + { + _shutdown.Cancel(); + + if (_listener is not null) + { + _listener.DisposeAsync().AsTask().GetAwaiter().GetResult(); + _listener = null; + } + + _shutdown.Dispose(); + } +} diff --git a/Engine/InternalH3Experimental/Infrastructure/QuicEndPointCollection.cs b/Engine/InternalH3Experimental/Infrastructure/QuicEndPointCollection.cs new file mode 100644 index 000000000..dfd61400e --- /dev/null +++ b/Engine/InternalH3Experimental/Infrastructure/QuicEndPointCollection.cs @@ -0,0 +1,41 @@ +using System.Collections; + +using GenHTTP.Api.Infrastructure; + +using GenHTTP.Engine.Shared.Infrastructure; + +namespace GenHTTP.Engine.InternalH3Experimental.Infrastructure; + +internal sealed class QuicEndPointCollection : IEndPointCollection, IDisposable +{ + private readonly List _endPoints; + + internal QuicEndPointCollection(IServer server, IEnumerable configuration) + { + _endPoints = configuration.Select(c => new QuicEndPoint(server, c)).ToList(); + } + + internal async ValueTask StartAsync() + { + foreach (QuicEndPoint endPoint in _endPoints) + { + await endPoint.StartAsync(); + } + } + + public IEndPoint this[int index] => _endPoints[index]; + + public int Count => _endPoints.Count; + + public IEnumerator GetEnumerator() => _endPoints.Cast().GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + public void Dispose() + { + foreach (QuicEndPoint endPoint in _endPoints) + { + endPoint.Dispose(); + } + } +} diff --git a/Engine/InternalH3Experimental/Protocol/H3Connection.cs b/Engine/InternalH3Experimental/Protocol/H3Connection.cs new file mode 100644 index 000000000..653f53c08 --- /dev/null +++ b/Engine/InternalH3Experimental/Protocol/H3Connection.cs @@ -0,0 +1,437 @@ +using System.Buffers; +using System.Collections.Concurrent; +using System.Net; +using System.Net.Quic; +using System.Threading.Channels; + +using GenHTTP.Api.Infrastructure; +using GenHTTP.Api.Protocol; + +using Glyph3; + +using Microsoft.Extensions.Logging; + +namespace GenHTTP.Engine.InternalH3Experimental.Protocol; + +/// +/// One HTTP/3 connection: MsQuic underneath, Glyph3 on top, GenHTTP's handler chain in the middle. +/// +/// +/// Glyph3 is a single state machine, so every call into it is funnelled through one channel and one +/// consumer - the pump. Handlers start on that thread and, if they suspend, resume back onto it +/// through PumpContext, so several requests can be in flight without the parser seeing two threads. +/// +internal sealed class H3Connection : IHttp3Transport, IAsyncDisposable +{ + private readonly QuicConnection _quic; + + private readonly IServer _server; + + private readonly IEndPoint _endPoint; + + private readonly ILogger _logger; + + private readonly ConcurrentDictionary _streams = new(); + + private readonly Queue _spareUniStreams = new(); + + private readonly Channel _ingress = + Channel.CreateUnbounded(new UnboundedChannelOptions { SingleReader = true }); + + private readonly Channel _egress = + Channel.CreateUnbounded(new UnboundedChannelOptions { SingleReader = true }); + + private Http3Connection? _h3; + + private const int ServerUniStreams = 1; + + // Stream bytes, a stream ending, or a continuation that must run on the pump thread. + private readonly record struct Inbound(long StreamId, byte[]? Buffer, int Length, bool Fin, bool Closed, Action? Resume); + + private readonly record struct Outbound(long StreamId, byte[] Buffer, int Length, bool Fin); + + private readonly int _qpackCapacity; + + private H3Connection(QuicConnection quic, IServer server, IEndPoint endPoint, ILogger logger, int qpackCapacity) + { + _quic = quic; + _server = server; + _endPoint = endPoint; + _logger = logger; + _qpackCapacity = qpackCapacity; + } + + internal static async Task ServeAsync(QuicConnection quic, IServer server, IEndPoint endPoint, ILogger logger, + int qpackCapacity, CancellationToken cancellationToken) + { + await using var connection = new H3Connection(quic, server, endPoint, logger, qpackCapacity); + await connection.RunAsync(cancellationToken); + } + + private async Task RunAsync(CancellationToken cancellationToken) + { + // Opened before Glyph3 exists, because OpenUniStream answers synchronously while + // OpenOutboundStreamAsync does not. + // + // One is enough at capacity 0: Glyph3 asks for a single unidirectional stream, for control + // and SETTINGS, and with no dynamic table there is nothing to insert and nothing to + // acknowledge. With a capacity it also wants the QPACK decoder stream, plus an encoder + // stream for the case where the client advertises a table of its own. The encoder one goes + // unused if the client then advertises 0, which every non-browser client does. + int uniStreams = _qpackCapacity > 0 ? ServerUniStreams + 2 : ServerUniStreams; + + for (int i = 0; i < uniStreams; i++) + { + QuicStream uni = await _quic.OpenOutboundStreamAsync(QuicStreamType.Unidirectional, cancellationToken); + _streams[uni.Id] = uni; + _spareUniStreams.Enqueue(uni); + } + + _h3 = new Http3Connection(this, DispatchAsync, new Http3Options + { + QpackDynamicTableCapacity = _qpackCapacity, + }); + + Task accepting = AcceptStreamsAsync(cancellationToken); + Task writing = WriteLoopAsync(cancellationToken); + + try + { + await PumpAsync(cancellationToken); + } + finally + { + ReportQpack(); + + _h3.Close(); + _egress.Writer.TryComplete(); + _ingress.Writer.TryComplete(); + await Task.WhenAny(Task.WhenAll(accepting, writing), Task.Delay(1000, CancellationToken.None)); + } + } + + /// + /// Reports what QPACK actually did on this connection, which is otherwise invisible. + /// + /// + /// A capacity of 0 is worth distinguishing from SETTINGS that never arrived, since both leave + /// the counters at 0 while meaning entirely different things. In practice only browsers + /// advertise a table at all: everything else sends 0, which makes the dynamic table inert no + /// matter what capacity this engine was configured with. + /// + private void ReportQpack() + { + if (_h3 is null || !_logger.IsEnabled(LogLevel.Debug)) + { + return; + } + + _logger.LogDebug( + "HTTP/3 connection closed: peer QPACK table {Capacity}, dynamic inserts in {Inbound} / out {Outbound}", + _h3.PeerSettingsReceived ? $"{_h3.PeerDynamicTableCapacity} B" : "never advertised", + _h3.InboundDynamicInserts, + _h3.OutboundDynamicInserts); + } + + // Glyph3 calls this on the pump thread and awaits the result before submitting the response. + private ValueTask DispatchAsync(Http3Request request) + { + // Start the handler INLINE. Measured against the alternative: dispatching it to the pool + // with Task.Run collapsed throughput to 75k req/s from 291k, because the pump then waits on + // a pool that the offloaded work is itself competing for. A chain that completes synchronously - which the common case + // does - then costs no thread hop at all, and Glyph3 submits the response without ever + // leaving the pump. Only a handler that actually suspends pays for a continuation, and it + // comes back through PumpContext. + Task pending = HandleAsync(request); + + return new ValueTask(pending); + } + + private async Task HandleAsync(Http3Request source) + { + try + { + // Glyph3 dispatches at end-of-headers, so the body is still arriving. Assemble it + // before the handler runs, which is the shape GenHTTP's IRequestBody expects. + ReadOnlyMemory body = await ReadBodyAsync(source); + + var request = new H3Request(_server, _endPoint, source, body, RemoteAddress()); + + IResponse response = await _server.Handler.HandleAsync(request) + ?? throw new InvalidOperationException("The root request handler did not return a response"); + + bool head = request.Header.Method == RequestMethod.Head; + + return await H3ResponseWriter.BuildAsync(response, head); + } + catch (Exception e) + { + _logger.LogError(e, "Failed to handle request"); + return new Http3Response { Status = 500 }; + } + } + + private async Task PumpAsync(CancellationToken cancellationToken) + { + var context = new PumpContext(_ingress.Writer); + + Enter(context); + _h3!.Start(); + Leave(); + + // ConfigureAwait(false) matters: with the context installed the pump would post its OWN + // continuation to the queue only it drains, and wait forever for itself. + while (await _ingress.Reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false)) + { + Enter(context); + + while (_ingress.Reader.TryRead(out Inbound item)) + { + if (item.Resume is { } resume) + { + resume(); + } + else if (item.Closed) + { + _h3.OnStreamClosed(item.StreamId); + } + else + { + _h3.Feed(item.StreamId, item.Buffer.AsSpan(0, item.Length), item.Fin); + + if (item.Buffer is not null) + { + ArrayPool.Shared.Return(item.Buffer); + } + } + } + + _h3.Flush(); + + Leave(); + + if (_h3.IsFaulted) + { + return; + } + } + } + + // Installed only around calls into Glyph3, so anything it awaits while dispatching resumes on + // the pump rather than the thread pool. + private static void Enter(SynchronizationContext context) => SynchronizationContext.SetSynchronizationContext(context); + + private static void Leave() => SynchronizationContext.SetSynchronizationContext(null); + + private static async ValueTask> ReadBodyAsync(Http3Request source) + { + if (source.BodyReader is not { } reader) + { + return source.Body; + } + + ArrayBufferWriter? assembled = null; + + while (true) + { + ReadOnlyMemory chunk = await reader.ReadAsync(); + + if (chunk.IsEmpty) + { + break; + } + + assembled ??= new ArrayBufferWriter(chunk.Length); + assembled.Write(chunk.Span); + } + + return assembled?.WrittenMemory ?? ReadOnlyMemory.Empty; + } + + private IPAddress? RemoteAddress() + => _quic.RemoteEndPoint is IPEndPoint endpoint ? endpoint.Address : null; + + private async Task AcceptStreamsAsync(CancellationToken cancellationToken) + { + try + { + while (!cancellationToken.IsCancellationRequested) + { + QuicStream stream = await _quic.AcceptInboundStreamAsync(cancellationToken); + _streams[stream.Id] = stream; + _ = ReadStreamAsync(stream, cancellationToken); + } + } + catch (Exception) + { + // The connection closed, which is how an accept loop ends. + } + } + + private async Task ReadStreamAsync(QuicStream stream, CancellationToken cancellationToken) + { + try + { + while (true) + { + byte[] buffer = ArrayPool.Shared.Rent(16 * 1024); + int read = await stream.ReadAsync(buffer, cancellationToken); + + if (read == 0) + { + ArrayPool.Shared.Return(buffer); + await _ingress.Writer.WriteAsync(new Inbound(stream.Id, null, 0, true, false, null), cancellationToken); + return; + } + + await _ingress.Writer.WriteAsync(new Inbound(stream.Id, buffer, read, false, false, null), cancellationToken); + } + } + catch (Exception) + { + _ingress.Writer.TryWrite(new Inbound(stream.Id, null, 0, false, true, null)); + } + } + + /// + /// Issues every write it can, then awaits them together. + /// + /// + /// Awaiting each write before starting the next leaves MsQuic with one stream's data pending at + /// a time, so it cannot fill a datagram from several streams at once - the coalescing that makes + /// UDP segmentation offload worth anything. Issuing first and draining after lets it see the + /// whole batch. Ordering within a stream still holds: a second write to a stream already in + /// flight drains first. + /// + private async Task WriteLoopAsync(CancellationToken cancellationToken) + { + var inflight = new List(); + var busy = new HashSet(); + + try + { + while (await _egress.Reader.WaitToReadAsync(cancellationToken)) + { + while (_egress.Reader.TryRead(out Outbound item)) + { + if (!_streams.TryGetValue(item.StreamId, out QuicStream? stream)) + { + ArrayPool.Shared.Return(item.Buffer); + continue; + } + + if (item.Length == 0) + { + if (item.Fin) + { + stream.CompleteWrites(); + } + + ArrayPool.Shared.Return(item.Buffer); + Release(item.StreamId, item.Fin); + continue; + } + + if (!busy.Add(item.StreamId)) + { + await DrainAsync(inflight, busy); + busy.Add(item.StreamId); + } + + ValueTask write = stream.WriteAsync(item.Buffer.AsMemory(0, item.Length), item.Fin, cancellationToken); + + if (write.IsCompletedSuccessfully) + { + ArrayPool.Shared.Return(item.Buffer); + Release(item.StreamId, item.Fin); + } + else + { + inflight.Add(new Pending(write, item.Buffer, item.StreamId, item.Fin)); + } + } + + await DrainAsync(inflight, busy); + } + } + catch (Exception) + { + // Peer went away mid-write. + } + } + + private async ValueTask DrainAsync(List inflight, HashSet busy) + { + foreach (Pending pending in inflight) + { + try + { + await pending.Write; + } + catch (Exception) + { + // Peer went away mid-write; the connection is finished either way. + } + + ArrayPool.Shared.Return(pending.Buffer); + Release(pending.StreamId, pending.Fin); + } + + inflight.Clear(); + busy.Clear(); + } + + /// + /// Releases a finished request stream. Skipping this strands its credit and the peer stalls + /// after MaxInboundBidirectionalStreams requests. + /// + private void Release(long streamId, bool fin) + { + // Unidirectional streams are the connection's control and QPACK streams; they live as long + // as it does. + if (fin && (streamId & 0x3) == 0x0 && _streams.TryRemove(streamId, out QuicStream? finished)) + { + // Off the writer: tearing a stream down is slow enough that awaiting it here serialises + // every other request behind it. + _ = finished.DisposeAsync().AsTask(); + } + } + + private readonly record struct Pending(ValueTask Write, byte[] Buffer, long StreamId, bool Fin); + + public long OpenUniStream() => _spareUniStreams.TryDequeue(out QuicStream? stream) ? stream.Id : -1; + + public void Send(long streamId, ReadOnlySpan data, bool fin) + { + byte[] buffer = ArrayPool.Shared.Rent(Math.Max(1, data.Length)); + data.CopyTo(buffer); + _egress.Writer.TryWrite(new Outbound(streamId, buffer, data.Length, fin)); + } + + public async ValueTask DisposeAsync() + { + foreach (QuicStream stream in _streams.Values) + { + await stream.DisposeAsync(); + } + await _quic.DisposeAsync(); + } + + /// + /// Posts continuations back to the pump, so anything Glyph3 awaits resumes on the one thread + /// allowed to touch it. + /// + private sealed class PumpContext : SynchronizationContext + { + private readonly ChannelWriter _pump; + + internal PumpContext(ChannelWriter pump) => _pump = pump; + + public override void Post(SendOrPostCallback d, object? state) + => _pump.TryWrite(new Inbound(0, null, 0, false, false, () => d(state))); + + public override void Send(SendOrPostCallback d, object? state) => Post(d, state); + + public override SynchronizationContext CreateCopy() => this; + } +} diff --git a/Engine/InternalH3Experimental/Protocol/H3KeyValueList.cs b/Engine/InternalH3Experimental/Protocol/H3KeyValueList.cs new file mode 100644 index 000000000..3f970ffef --- /dev/null +++ b/Engine/InternalH3Experimental/Protocol/H3KeyValueList.cs @@ -0,0 +1,28 @@ +using GenHTTP.Api.Protocol; + +namespace GenHTTP.Engine.InternalH3Experimental.Protocol; + +/// +/// Header and query lists over Glyph3's decoded fields. +/// +/// +/// The engine carries its own rather than reusing the shared one, which wraps Glyph11's list and +/// therefore assumes an HTTP/1.1 parse. +/// +internal sealed class H3KeyValueList : IRequestHeaders, IRequestQuery +{ + private readonly List<(ReadOnlyMemory Name, ReadOnlyMemory Value)> _entries; + + internal H3KeyValueList(List<(ReadOnlyMemory Name, ReadOnlyMemory Value)> entries) + { + _entries = entries; + } + + public int Count => _entries.Count; + + public KeyValuePair, ReadOnlyMemory> GetMemoryEntry(int index) + { + (ReadOnlyMemory name, ReadOnlyMemory value) = _entries[index]; + return new KeyValuePair, ReadOnlyMemory>(name, value); + } +} diff --git a/Engine/InternalH3Experimental/Protocol/H3Request.cs b/Engine/InternalH3Experimental/Protocol/H3Request.cs new file mode 100644 index 000000000..5c5194fec --- /dev/null +++ b/Engine/InternalH3Experimental/Protocol/H3Request.cs @@ -0,0 +1,123 @@ +using System.IO.Pipelines; +using System.Net; +using System.Security.Cryptography.X509Certificates; + +using GenHTTP.Api.Infrastructure; +using GenHTTP.Api.Protocol; +using GenHTTP.Engine.Shared.Types; + +using Glyph3; + +namespace GenHTTP.Engine.InternalH3Experimental.Protocol; + +/// +/// An over a Glyph3 request. +/// +/// +/// Not the shared , whose Source is a Glyph11 BinaryRequest and so assumes an +/// HTTP/1.1 parse. Nothing here is pooled: HTTP/3 multiplexes, so several of these are live on one +/// connection at once and a per-connection pool would need locking to be safe. +/// +internal sealed class H3Request : IRequest +{ + private readonly H3RequestBody? _body; + + private readonly ClientConnection _client = new(); + + private readonly PropertyBag _properties = new(); + + private readonly ResponseBuilder _response = new(); + + private Func? _bodyWrapper; + + private IRequestBody? _wrappedBody; + + private bool _bodyFetched; + + internal H3Request(IServer server, IEndPoint endPoint, Http3Request source, ReadOnlyMemory body, IPAddress? remoteAddress) + { + Server = server; + EndPoint = endPoint; + + Header = new H3RequestHeader(source, ParseQuery(source.Path)); + + _body = body.IsEmpty ? null : new H3RequestBody(body); + + _client.Apply(remoteAddress, ClientProtocol.Https, null); + } + + public IServer Server { get; } + + public IEndPoint EndPoint { get; } + + public IClientConnection Client => _client; + + public IPropertyBag Properties => _properties; + + public IRequestHeader Header { get; } + + public IRequestBody? GetBody(HeaderAccess headerAccess = HeaderAccess.Retain) + { + if (_bodyFetched) + { + throw new InvalidOperationException("Request body can only be fetched once."); + } + + _bodyFetched = true; + + if (_body is null) + { + return null; + } + + return _wrappedBody = _bodyWrapper is not null ? _bodyWrapper(_body) : _body; + } + + public void WrapBody(Func wrapper) => _bodyWrapper = wrapper; + + public IResponseBuilder Respond() => _response.Status(ResponseStatus.Ok); + + /// + /// Not supported. Upgrading a request to a raw byte stream is an HTTP/1.1 mechanism, and QUIC + /// streams are reached through the transport rather than through the request. + /// + public PipeReader Upgrade() + => throw new NotSupportedException("Connection upgrades are not available over HTTP/3."); + + public ValueTask DisposeAsync() => new(); + + // The query string, which HTTP/3 carries inside :path exactly as HTTP/1.1 carries it inside the + // request target. + private static List<(ReadOnlyMemory Name, ReadOnlyMemory Value)> ParseQuery(ReadOnlyMemory path) + { + var parameters = new List<(ReadOnlyMemory, ReadOnlyMemory)>(); + + int mark = path.Span.IndexOf((byte)'?'); + if (mark < 0) + { + return parameters; + } + + ReadOnlyMemory query = path[(mark + 1)..]; + + while (!query.IsEmpty) + { + int end = query.Span.IndexOf((byte)'&'); + ReadOnlyMemory pair = end < 0 ? query : query[..end]; + query = end < 0 ? default : query[(end + 1)..]; + + if (pair.IsEmpty) + { + continue; + } + + int equals = pair.Span.IndexOf((byte)'='); + + parameters.Add(equals < 0 + ? (pair, ReadOnlyMemory.Empty) + : (pair[..equals], pair[(equals + 1)..])); + } + + return parameters; + } +} diff --git a/Engine/InternalH3Experimental/Protocol/H3RequestBody.cs b/Engine/InternalH3Experimental/Protocol/H3RequestBody.cs new file mode 100644 index 000000000..b96e42eda --- /dev/null +++ b/Engine/InternalH3Experimental/Protocol/H3RequestBody.cs @@ -0,0 +1,25 @@ +using GenHTTP.Api.Protocol; + +namespace GenHTTP.Engine.InternalH3Experimental.Protocol; + +/// +/// A request body that Glyph3 has already assembled. +/// +/// +/// Buffered because this engine uses Glyph3's buffered dispatch: the handler runs once the whole +/// request has arrived. Glyph3 also has a streamed flavour, which would replace this with a reader +/// the handler pulls while the body is still in flight. +/// +internal sealed class H3RequestBody : IRequestBody +{ + private readonly ReadOnlyMemory _content; + + internal H3RequestBody(ReadOnlyMemory content) + { + _content = content; + } + + public Stream AsStream() => new MemoryStream(_content.ToArray(), writable: false); + + public ValueTask> AsMemoryAsync() => new(_content); +} diff --git a/Engine/InternalH3Experimental/Protocol/H3RequestHeader.cs b/Engine/InternalH3Experimental/Protocol/H3RequestHeader.cs new file mode 100644 index 000000000..81eeaaf57 --- /dev/null +++ b/Engine/InternalH3Experimental/Protocol/H3RequestHeader.cs @@ -0,0 +1,107 @@ +using GenHTTP.Api.Protocol; +using GenHTTP.Engine.Shared.Types; + +using Glyph3; + +namespace GenHTTP.Engine.InternalH3Experimental.Protocol; + +/// +/// An over a Glyph3 request. +/// +internal sealed class H3RequestHeader : IRequestHeader +{ + private readonly H3KeyValueList _headers; + + private readonly H3KeyValueList _query; + + private readonly RequestTarget _target; + + internal H3RequestHeader(Http3Request source, List<(ReadOnlyMemory Name, ReadOnlyMemory Value)> query) + { + _headers = new H3KeyValueList(WithHost(source)); + _query = new H3KeyValueList(query); + + _target = new RequestTarget(); + + // :path carries the query string, exactly as an HTTP/1.1 request target does. Routing must + // see the path alone, or every request with a query 404s. + Path = new ByteString(WithoutQuery(source.Path)); + Method = new RequestMethod(source.Method); + + _target.Apply(Path); + } + + public RequestMethod Method { get; } + + public ByteString Path { get; } + + public IRequestTarget Target => _target; + + // Always HTTP/3: there is no version on the wire to read. QUIC settles it, and ALPN said "h3" + // before a single byte of this request arrived. + public HttpProtocol Protocol => HttpProtocol.Http3; + + public ReadOnlyMemory Version => Http3Version; + + public IRequestHeaders Headers => _headers; + + public IRequestQuery Query => _query; + + /// + /// HTTP/3 carries the authority as the :authority pseudo-header and clients omit Host entirely. + /// RFC 9114 4.3.1 has an intermediary translating to HTTP/1.1 construct Host from it, which is + /// what this does: everything above the engine expects a Host header to exist. + /// + private static List<(ReadOnlyMemory Name, ReadOnlyMemory Value)> WithHost(Http3Request source) + { + if (source.Authority.IsEmpty) + { + return source.Headers; + } + + foreach ((ReadOnlyMemory name, ReadOnlyMemory _) in source.Headers) + { + if (name.Length == 4 && Matches(name.Span, "host"u8)) + { + return source.Headers; + } + } + + var headers = new List<(ReadOnlyMemory, ReadOnlyMemory)>(source.Headers.Count + 1) + { + (HostName, source.Authority), + }; + + headers.AddRange(source.Headers); + + return headers; + } + + private static bool Matches(ReadOnlySpan name, ReadOnlySpan lowercase) + { + for (int i = 0; i < name.Length; i++) + { + byte c = name[i]; + if (c is >= (byte)'A' and <= (byte)'Z') + { + c += 32; + } + if (c != lowercase[i]) + { + return false; + } + } + + return true; + } + + private static ReadOnlyMemory WithoutQuery(ReadOnlyMemory path) + { + int mark = path.Span.IndexOf((byte)'?'); + return mark < 0 ? path : path[..mark]; + } + + private static readonly ReadOnlyMemory HostName = "host"u8.ToArray(); + + private static readonly ReadOnlyMemory Http3Version = "HTTP/3.0"u8.ToArray(); +} diff --git a/Engine/InternalH3Experimental/Protocol/H3ResponseWriter.cs b/Engine/InternalH3Experimental/Protocol/H3ResponseWriter.cs new file mode 100644 index 000000000..95e9f74a7 --- /dev/null +++ b/Engine/InternalH3Experimental/Protocol/H3ResponseWriter.cs @@ -0,0 +1,101 @@ +using System.Buffers; + +using GenHTTP.Api.Protocol; + +using Glyph3; + +namespace GenHTTP.Engine.InternalH3Experimental.Protocol; + +/// +/// Turns a GenHTTP into a Glyph3 response. +/// +internal static class H3ResponseWriter +{ + + internal static async ValueTask BuildAsync(IResponse response, bool headRequest) + { + ReadOnlyMemory body = default; + + IResponseContent? content = response.Content; + + // A HEAD response keeps the headers its GET would have produced and sends no body. + if (content is not null && !headRequest) + { + var buffer = new ArrayBufferWriter( + content.Length is { } length and > 0 and < int.MaxValue ? (int)length : 4096); + + await content.WriteAsync(new H3Sink(buffer)); + + body = buffer.WrittenMemory; + } + + // Written straight into the response. Collecting into a list first and copying it across + // allocated a second list and its backing array on every single response. + var result = new Http3Response + { + Status = (int)response.Status, + Body = body, + }; + + for (int i = 0; i < response.Headers.Count; i++) + { + KeyValuePair, ReadOnlyMemory> header = response.Headers.GetMemoryEntry(i); + + // Names pass through as they are. HTTP/3 requires them lowercase, but Glyph3 resolves + // static-table names case-insensitively - and lowercases the ones it has to write out - + // so converting here only duplicated the work and allocated to do it. + // + // Connection-specific fields are malformed in HTTP/3 (RFC 9114 4.2), and a peer may + // treat them as a protocol error rather than ignore them. + if (!IsConnectionSpecific(header.Key.Span)) + { + result.Headers.Add((header.Key, header.Value)); + } + } + + if (content is not null) + { + if (content.Type is { } type) + { + result.Headers.Add((ContentTypeName, type.Bytes)); + } + + if (content.Encoding is { } encoding) + { + result.Headers.Add((ContentEncodingName, encoding)); + } + } + + return result; + } + + private static bool IsConnectionSpecific(ReadOnlySpan name) + => Matches(name, "connection"u8) || Matches(name, "keep-alive"u8) || Matches(name, "transfer-encoding"u8) + || Matches(name, "upgrade"u8) || Matches(name, "proxy-connection"u8) || Matches(name, "content-length"u8); + + private static bool Matches(ReadOnlySpan name, ReadOnlySpan lowercase) + { + if (name.Length != lowercase.Length) + { + return false; + } + + for (int i = 0; i < name.Length; i++) + { + byte c = name[i]; + if (c is >= (byte)'A' and <= (byte)'Z') + { + c += 32; + } + if (c != lowercase[i]) + { + return false; + } + } + + return true; + } + + private static readonly ReadOnlyMemory ContentTypeName = "content-type"u8.ToArray(); + private static readonly ReadOnlyMemory ContentEncodingName = "content-encoding"u8.ToArray(); +} diff --git a/Engine/InternalH3Experimental/Protocol/H3Sink.cs b/Engine/InternalH3Experimental/Protocol/H3Sink.cs new file mode 100644 index 000000000..087c37902 --- /dev/null +++ b/Engine/InternalH3Experimental/Protocol/H3Sink.cs @@ -0,0 +1,67 @@ +using System.Buffers; + +using GenHTTP.Api.Protocol; + +namespace GenHTTP.Engine.InternalH3Experimental.Protocol; + +/// +/// The sink a response body writes into. +/// +internal sealed class H3Sink : IResponseSink +{ + private readonly ArrayBufferWriter _writer; + + internal H3Sink(ArrayBufferWriter writer) + { + _writer = writer; + } + + public IBufferWriter Writer => _writer; + + public Stream Stream => _stream ??= new BufferWriterStream(_writer); + + private Stream? _stream; + + // Content that writes to a Stream rather than an IBufferWriter, which most of the IO module + // does. + private sealed class BufferWriterStream : Stream + { + private readonly IBufferWriter _target; + + internal BufferWriterStream(IBufferWriter target) => _target = target; + + public override bool CanRead => false; + public override bool CanSeek => false; + public override bool CanWrite => true; + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Write(byte[] buffer, int offset, int count) => Write(buffer.AsSpan(offset, count)); + + public override void Write(ReadOnlySpan buffer) => _target.Write(buffer); + + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + _target.Write(buffer.Span); + return new ValueTask(); + } + + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + _target.Write(buffer.AsSpan(offset, count)); + return Task.CompletedTask; + } + + public override void Flush() { } + public override Task FlushAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + } +} diff --git a/Engine/InternalH3Experimental/README.md b/Engine/InternalH3Experimental/README.md new file mode 100644 index 000000000..e0c954da9 --- /dev/null +++ b/Engine/InternalH3Experimental/README.md @@ -0,0 +1,42 @@ +# GenHTTP HTTP/3 Engine (experimental) + +Serves an application over HTTP/3, using [Glyph3](https://github.com/dotnet-web-stack/Glyph3) for +HTTP/3 and `System.Net.Quic` (MsQuic) for QUIC. + +```csharp +var h3 = GenHTTP.Engine.InternalH3Experimental.Host.Create() + .Handler(app) + .Bind(IPAddress.Any, 443, certificate); +``` + +Browsers never start on HTTP/3. They connect over TCP first and only try QUIC once a response tells +them where to find it, so this engine is meant to run beside one that serves HTTP/1.1: + +```csharp +var h1 = GenHTTP.Engine.Internal.Host.Create() + .Handler(app) + .Add(AltSvc.To(443)) // Alt-Svc: h3=":443"; ma=86400 + .Bind(IPAddress.Any, 443, certificate); +``` + +TCP:443 and UDP:443 are different sockets, so both bind at once. Two things stop the upgrade +working, both silently: `Alt-Svc` is only honoured when it arrives over TLS, and the certificate on +the HTTP/3 port must be valid for the origin's host name. + +## Requirements + +QUIC needs libmsquic present. Windows 11 / Server 2022+ ships it with the .NET runtime; Linux +installs it (`sudo apt install libmsquic`); macOS uses Homebrew plus `DYLD_FALLBACK_LIBRARY_PATH`. +The engine throws at startup when it is missing rather than failing later. + +## Status + +Experimental. Known gaps: + +- Request bodies are assembled before the handler runs, so a large upload is held in memory. + Glyph3 can stream them; the engine does not yet. +- Response bodies are buffered for the same reason. +- `IRequest.Upgrade()` throws: connection upgrades are an HTTP/1.1 mechanism. +- No server push, no trailers, no 0-RTT. +- Requests carry their own `IRequest` implementation rather than the shared `Request`, whose + `Source` is a Glyph11 `BinaryRequest` and therefore assumes an HTTP/1.1 parse. diff --git a/GenHTTP.slnx b/GenHTTP.slnx index b3128e179..e94424b79 100644 --- a/GenHTTP.slnx +++ b/GenHTTP.slnx @@ -13,6 +13,7 @@ + diff --git a/Playground/GenHTTP.Playground.csproj b/Playground/GenHTTP.Playground.csproj index 19ebe7ed1..9b3bdb383 100644 --- a/Playground/GenHTTP.Playground.csproj +++ b/Playground/GenHTTP.Playground.csproj @@ -1,59 +1,64 @@ - - - - - Exe - net11.0 - true - - false - true - - runtime-async=on - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + Exe + net11.0 + true + + false + true + + runtime-async=on + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Playground/Program.cs b/Playground/Program.cs index 8333f9870..32dec51f1 100644 --- a/Playground/Program.cs +++ b/Playground/Program.cs @@ -1,9 +1,126 @@ -using GenHTTP.Engine.Internal; - -using GenHTTP.Modules.IO; - -var app = Content.From(Resource.FromString("Hello World!")); - -await Host.Create() - .Handler(app) - .RunAsync(); +using System.Net; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +using GenHTTP.Engine.InternalH3Experimental; + +using GenHTTP.Modules.StaticWebsites; +using GenHTTP.Modules.IO; + +using Microsoft.Extensions.Logging.Abstractions; + +// Serves wwwroot over HTTP/1.1 and HTTP/3 at once, so a browser can be watched deciding which to +// use. The page reports the protocol it arrived over. +// +// dotnet run -c Release --project Playground +// curl -k https://localhost:8443/ # HTTP/1.1, and an Alt-Svc header +// snap run curl -k --http3-only https://localhost:8443/ +// +// A browser will not speak HTTP/3 to an untrusted certificate. Chrome can be told to anyway, using +// the SPKI hash this prints on startup: +// +// google-chrome --origin-to-force-quic-on=localhost:8443 \ +// --ignore-certificate-errors-spki-list= \ +// https://localhost:8443/ +// +// One number, two sockets: the HTTP/1.1 host binds TCP 8443 and the HTTP/3 host binds UDP 8443. +const ushort Port = 8443; + +// Bytes of QPACK dynamic table advertised to clients. Nonzero here on purpose: this playground +// exists partly to find out whether a browser uses one, since curl and .NET's client do not. +const int QpackCapacity = 4096; + +// Beside the binary, since wwwroot is copied to the output rather than served from the source +// tree - a relative path would resolve against whatever directory you happened to run from. +string root = Path.Combine(AppContext.BaseDirectory, "wwwroot"); + +var content = StaticWebsite.From(ResourceTree.FromDirectory(root)); + +var certificate = CreateDevelopmentCertificate(); + +Console.WriteLine($"SPKI hash: {SpkiHash(certificate)}"); + +// IPv6Any rather than Loopback: a browser resolves "localhost" itself and prefers ::1, so an +// IPv4-only listener never sees a packet from one. TCP masks this by falling back to IPv4, QUIC +// does not, and the result looks like a broken HTTP/3 server rather than a bind that is too narrow. +// +// HTTP/3 first, so it is listening before anything advertises it. +var h3 = Host.Create(qpackDynamicTableCapacity: QpackCapacity) + .Handler(content) + .Logging(NullLoggerFactory.Instance, logRequests: false) + .Bind(IPAddress.IPv6Any, Port, certificate); + +await h3.StartAsync(); + +// HTTP/1.1, advertising the endpoint above. The port has to match, and nothing checks that it does. +await GenHTTP.Engine.Internal.Host.Create() + .Handler(content) + .Add(AltSvc.To(Port)) + .Logging(NullLoggerFactory.Instance, logRequests: false) + .Bind(IPAddress.IPv6Any, Port, certificate) + .RunAsync(); + +static X509Certificate2 CreateDevelopmentCertificate() +{ + // A PKCS#12 issued by a local CA that the browser already trusts. This is the only arrangement + // Chrome accepts: it dropped support for directly-trusted leaf certificates, so the ASP.NET + // development certificate below (CA:FALSE, self-signed) can never satisfy it however it is + // marked in the trust store. Point PLAYGROUND_CERT at a mkcert-style bundle to use one. + // + // It matters because a certificate the browser merely tolerates is not enough: an origin with + // certificate errors has its Alt-Svc ignored outright, so HTTP/3 stays unreachable. + if (Environment.GetEnvironmentVariable("PLAYGROUND_CERT") is { Length: > 0 } path && File.Exists(path)) + { + return X509CertificateLoader.LoadPkcs12FromFile(path, null); + } + + // Prefer the ASP.NET development certificate if one exists. It is the same across runs, so the + // SPKI hash below stays stable, and `dotnet dev-certs https --trust` is enough for Firefox and + // curl, which do honour a trusted leaf. + if (FindAspNetDevelopmentCertificate() is { } trusted) + { + return trusted; + } + + using var key = RSA.Create(2048); + + var request = new CertificateRequest("CN=localhost", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + + // Without a SAN nothing modern will verify this, only skip it. + var names = new SubjectAlternativeNameBuilder(); + names.AddDnsName("localhost"); + names.AddIpAddress(IPAddress.Loopback); + request.CertificateExtensions.Add(names.Build()); + + using var generated = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(1)); + + // The private key has to come back through a PFX round-trip before a TLS stack will use it. + return X509CertificateLoader.LoadPkcs12(generated.Export(X509ContentType.Pfx), null); +} + +/// +/// The ASP.NET development certificate, if one is installed and still valid. Identified by the +/// extension the tooling stamps on it rather than by its subject, which anything could claim. +/// +static X509Certificate2? FindAspNetDevelopmentCertificate() +{ + const string AspNetHttpsOid = "1.3.6.1.4.1.311.84.1.1"; + + using var store = new X509Store(StoreName.My, StoreLocation.CurrentUser); + store.Open(OpenFlags.ReadOnly); + + // The NEWEST valid one, because that is the one `dotnet dev-certs https --trust` marks. Taking + // whichever the store happened to enumerate first served an older certificate that was equally + // valid and entirely untrusted, which a browser reports as an ordinary certificate error. + return store.Certificates + .Where(candidate => candidate.HasPrivateKey + && candidate.NotAfter > DateTime.Now + && candidate.Extensions.Any(e => e.Oid?.Value == AspNetHttpsOid)) + .OrderByDescending(candidate => candidate.NotAfter) + .FirstOrDefault(); +} + +// What Chrome's --ignore-certificate-errors-spki-list wants: base64 of the SHA-256 of the +// certificate's public key info. +static string SpkiHash(X509Certificate2 certificate) + => Convert.ToBase64String(SHA256.HashData(certificate.PublicKey.ExportSubjectPublicKeyInfo())); diff --git a/Playground/wwwroot/img/a.svg b/Playground/wwwroot/img/a.svg new file mode 100644 index 000000000..cab2ef3e5 --- /dev/null +++ b/Playground/wwwroot/img/a.svg @@ -0,0 +1 @@ +a diff --git a/Playground/wwwroot/img/b.svg b/Playground/wwwroot/img/b.svg new file mode 100644 index 000000000..e08954913 --- /dev/null +++ b/Playground/wwwroot/img/b.svg @@ -0,0 +1 @@ +b diff --git a/Playground/wwwroot/img/c.svg b/Playground/wwwroot/img/c.svg new file mode 100644 index 000000000..0b37869ca --- /dev/null +++ b/Playground/wwwroot/img/c.svg @@ -0,0 +1 @@ +c diff --git a/Playground/wwwroot/index.html b/Playground/wwwroot/index.html new file mode 100644 index 000000000..38e19405b --- /dev/null +++ b/Playground/wwwroot/index.html @@ -0,0 +1,26 @@ + + +GenHTTP over HTTP/3 + +

GenHTTP over HTTP/3

+

+ Served by the experimental HTTP/3 engine, with HTTP/3 framing and QPACK from Glyph3 and QUIC from + System.Net.Quic. +

+

+ The first load arrives over HTTP/1.1. The response carries an Alt-Svc header pointing + at the same port over UDP, so a reload should switch to HTTP/3. Check the Protocol column in the + network tab. +

+ + + +

+ Several subresources on purpose: repeated requests on one connection are what a QPACK dynamic + table would compress. +

+ diff --git a/Playground/wwwroot/js/app.js b/Playground/wwwroot/js/app.js new file mode 100644 index 000000000..d835c4cbf --- /dev/null +++ b/Playground/wwwroot/js/app.js @@ -0,0 +1,5 @@ +// Reports the protocol the browser actually used, which is the point of the page. +const nav = performance.getEntriesByType("navigation")[0]; +const note = document.createElement("p"); +note.innerHTML = `This document arrived over ${nav ? nav.nextHopProtocol || "(unknown)" : "(unknown)"}.`; +document.body.appendChild(note);