From 356d8dceb821c5a03a944896763e1a867fe7ad1d Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Sat, 8 Aug 2026 18:15:02 +0100 Subject: [PATCH 01/55] Ioxide engine: ioxide 0.4.161, all endpoints served, native TLS termination - ioxide 0.1.1 -> 0.4.161; the separate ioxide.tls package is folded into core - migrate renamed APIs (TcpConnection, TcpHandle, TcpConnectionDualPipe, ServerConfig.Tcp) - serve every configured endpoint (primary port + ExtraPorts) instead of the first only - endpoints bound with a certificate are TLS-terminated ring-natively (per-port contexts, certificate exported as PEM); client cert validation and SNI report as unsupported - replace the hand-rolled TlsDuplexPipe with ioxide's TlsConnectionDualPipe - release the connection when the handshake or connection factory faults --- Engine/Ioxide/GenHTTP.Engine.Ioxide.csproj | 3 +- Engine/Ioxide/Hosting/IoxideServer.cs | 95 ++++++++++++---- Engine/Ioxide/Hosting/IoxideServerHost.cs | 2 +- Engine/Ioxide/Protocol/ConnectionDriver.cs | 22 +++- Engine/Ioxide/README.md | 29 +++-- Engine/Ioxide/Server.cs | 11 +- Engine/Ioxide/Tls/IoxideTls.cs | 36 +++--- Engine/Ioxide/Tls/TlsDuplexPipe.cs | 124 --------------------- 8 files changed, 132 insertions(+), 190 deletions(-) delete mode 100644 Engine/Ioxide/Tls/TlsDuplexPipe.cs diff --git a/Engine/Ioxide/GenHTTP.Engine.Ioxide.csproj b/Engine/Ioxide/GenHTTP.Engine.Ioxide.csproj index 596800623..935f22495 100644 --- a/Engine/Ioxide/GenHTTP.Engine.Ioxide.csproj +++ b/Engine/Ioxide/GenHTTP.Engine.Ioxide.csproj @@ -10,8 +10,7 @@ - - + diff --git a/Engine/Ioxide/Hosting/IoxideServer.cs b/Engine/Ioxide/Hosting/IoxideServer.cs index 1ca861cd9..0e76e3f43 100644 --- a/Engine/Ioxide/Hosting/IoxideServer.cs +++ b/Engine/Ioxide/Hosting/IoxideServer.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using System.IO.Pipelines; +using System.Security.Cryptography.X509Certificates; using GenHTTP.Api.Content; using GenHTTP.Api.Infrastructure; @@ -9,6 +10,8 @@ using GenHTTP.Engine.Shared.Types; using ioxide; +using ioxide.tls; + using Microsoft.Extensions.Logging; namespace GenHTTP.Engine.Ioxide.Hosting; @@ -17,13 +20,19 @@ public sealed class IoxideServer : IServer { private readonly ServerConfiguration _config; - private readonly IoxideEndPoint _endPoint; + private readonly IoxideEndPoint _primary; + + private readonly Dictionary _endPointByPort; + + private readonly Dictionary _tls; + + private readonly ushort[] _extraPorts; private readonly Func? _configure; private readonly Action? _onReactorStart; - private readonly Func>? _connectionFactory; + private readonly Func>? _connectionFactory; private readonly ILogger _logger; @@ -45,7 +54,7 @@ public sealed class IoxideServer : IServer public IHandler Handler { get; } - internal IoxideServer(ServerConfiguration config, IHandler handler, Func? configure = null, Action? onReactorStart = null, Func>? connectionFactory = null) + internal IoxideServer(ServerConfiguration config, IHandler handler, Func? configure = null, Action? onReactorStart = null, Func>? connectionFactory = null) { _config = config; Handler = handler; @@ -55,25 +64,51 @@ internal IoxideServer(ServerConfiguration config, IHandler handler, Func(); - var ep = config.EndPoints.First(); // spike: still only SERVE the first endpoint + var mapped = config.EndPoints + .Select(e => new IoxideEndPoint(e.Address, e.Port, e.DualStack, e.Security != null)) + .ToList(); - _endPoint = new IoxideEndPoint(ep.Address, ep.Port, ep.DualStack, ep.Security != null); + _primary = mapped[0]; + _endPointByPort = mapped.ToDictionary(e => e.Port); + _extraPorts = mapped.Skip(1).Select(e => e.Port).ToArray(); - // Advertise every configured endpoint (including secure ones) in IServer.EndPoints so concerns - // that inspect it — e.g. the secure-upgrade redirect, which derives the HTTPS port from a secure - // endpoint — behave correctly, even though the reactor currently only binds the first endpoint. - EndPoints = new IoxideEndPoints( - config.EndPoints.Select(e => (IEndPoint)new IoxideEndPoint(e.Address, e.Port, e.DualStack, e.Security != null)).ToList() - ); + if (mapped.Any(e => e.DualStack != _primary.DualStack)) + { + throw new NotSupportedException("The ioxide engine binds all endpoints with one dual-stack mode."); + } - var endPointCount = config.EndPoints.Count(); + _tls = new Dictionary(); - if (endPointCount > 1) + foreach (var endpoint in config.EndPoints) { - _logger.LogWarning("Configured with {Count} endpoints, but the ioxide engine only serves the first one ({Address}:{Port})", endPointCount, _endPoint.Address, _endPoint.Port); + if (endpoint.Security is not { } security) + { + continue; + } + + if (security.CertificateValidator is not null) + { + throw new NotSupportedException("Client certificate validation is not supported by the ioxide engine."); + } + + var certificate = security.CertificateProvider.Provide(null) + ?? throw new InvalidOperationException($"The certificate provider returned no default certificate for port {endpoint.Port}."); + + _tls[endpoint.Port] = new TlsOptions + { + CertificatePem = certificate.ExportCertificatePem(), + KeyPem = ExportKeyPem(certificate) + }; } + + EndPoints = new IoxideEndPoints(mapped.Cast().ToList()); } + private static string ExportKeyPem(X509Certificate2 certificate) + => certificate.GetRSAPrivateKey()?.ExportPkcs8PrivateKeyPem() + ?? certificate.GetECDsaPrivateKey()?.ExportPkcs8PrivateKeyPem() + ?? throw new InvalidOperationException("The certificate carries no exportable RSA or ECDSA private key."); + public async ValueTask StartAsync() { await PrepareHandlerAsync(); @@ -87,12 +122,16 @@ public async ValueTask StartAsync() cfg = _configure(cfg); } - // The endpoint binding (.Port()/.Bind()) determines the listen port and dual-stack mode, so + // The endpoint bindings (.Port()/.Bind()) determine the listen ports and dual-stack mode, so // they always win over whatever the configuration hook may have set. cfg = cfg with { - Port = _endPoint.Port, - DualStack = _endPoint.DualStack + DualStack = _primary.DualStack, + Tcp = (cfg.Tcp ?? new TcpOptions()) with + { + Port = _primary.Port, + ExtraPorts = _extraPorts + } }; _threads = new Thread[cfg.ReactorCount]; @@ -108,16 +147,26 @@ public async ValueTask StartAsync() { var reactor = new Reactor(i, cfg) { - // Runs once on the reactor's own thread before it serves: bind the reactor into the - // [ThreadStatic] seam so handler code can resolve per-reactor services, then let the - // host register those services (e.g. PgPool.Start(r, ...)) on this reactor's ring. OnStart = r => { IoxideReactor.Bind(r); + + if (_tls.Count > 0) + { + var registry = new TlsRegistry(); + + foreach (var (port, options) in _tls) + { + registry.Add(port, TlsService.Start(r, options, register: false)); + } + + r.AddService(registry); + } + _onReactorStart?.Invoke(r); listening.Signal(); }, - Handle = (_, c) => ConnectionDriver.HandleAsync(this, _endPoint, c, _connectionFactory), + TcpHandle = (_, c) => ConnectionDriver.HandleAsync(this, _endPointByPort[c.ListenerPort], c, _connectionFactory) }; _reactors[i] = reactor; @@ -143,7 +192,7 @@ public async ValueTask StartAsync() _logger.LogWarning("Not all reactors reported listening within 10s; the server may not be fully accepting yet."); } - _logger.LogInformation("Listening on {Address}:{Port} ({Settings})", _endPoint.Address, _endPoint.Port, DescribeSettings()); + _logger.LogInformation("Listening on {Address}:{Port} ({Settings})", _primary.Address, _primary.Port, DescribeSettings()); } private async ValueTask PrepareHandlerAsync() @@ -164,7 +213,7 @@ private async ValueTask PrepareHandlerAsync() } } - private string DescribeSettings() => $"ioxide, {(_endPoint.Secure ? "HTTPS" : "HTTP")}, DualStack: {_endPoint.DualStack}, Reactors: {_reactors?.Length ?? 0}"; + private string DescribeSettings() => $"ioxide, {_endPointByPort.Count} endpoint(s), TLS on {_tls.Count}, DualStack: {_primary.DualStack}, Reactors: {_reactors?.Length ?? 0}"; public async ValueTask DisposeAsync() { diff --git a/Engine/Ioxide/Hosting/IoxideServerHost.cs b/Engine/Ioxide/Hosting/IoxideServerHost.cs index 62335033c..b74098f4f 100644 --- a/Engine/Ioxide/Hosting/IoxideServerHost.cs +++ b/Engine/Ioxide/Hosting/IoxideServerHost.cs @@ -9,7 +9,7 @@ namespace GenHTTP.Engine.Ioxide.Hosting; -public sealed class IoxideServerHost(Func? configure = null, Action? onReactorStart = null, Func>? connectionFactory = null) : ServerHost +public sealed class IoxideServerHost(Func? configure = null, Action? onReactorStart = null, Func>? connectionFactory = null) : ServerHost { protected override IServer Build(ServerConfiguration config, IHandler handler) diff --git a/Engine/Ioxide/Protocol/ConnectionDriver.cs b/Engine/Ioxide/Protocol/ConnectionDriver.cs index cc6aa8fd3..a5dadc6c8 100644 --- a/Engine/Ioxide/Protocol/ConnectionDriver.cs +++ b/Engine/Ioxide/Protocol/ConnectionDriver.cs @@ -15,7 +15,7 @@ using Microsoft.Extensions.Logging; using Connection = GenHTTP.Api.Protocol.Connection; -using IoConnection = ioxide.Connection; +using IoConnection = ioxide.TcpConnection; namespace GenHTTP.Engine.Ioxide.Protocol; @@ -89,10 +89,22 @@ internal static partial class ConnectionDriver internal static async Task HandleAsync(IServer server, IEndPoint endPoint, IoConnection conn, Func>? connectionFactory) { - // Default transport is a plain duplex pipe over the connection. A connectionFactory (e.g. the - // TLS-terminating one supplied by the host for the :8081 listener) can swap in a transport that - // decrypts inbound bytes and writes plaintext for kTLS TX. - var pipe = connectionFactory is null ? new ioxide.ConnectionDualPipe(conn) : await connectionFactory(conn); + IDuplexPipe pipe; + + try + { + pipe = connectionFactory is not null + ? await connectionFactory(conn) + : endPoint.Secure + ? await IoxideTls.AcceptAsync(conn, IoxideReactor.Current.GetService().For(conn.ListenerPort)) + : new ioxide.TcpConnectionDualPipe(conn); + } + catch + { + // failed handshake (or factory fault) - release the connection instead of leaking it + conn.DecRef(); + return; + } var reader = pipe.Input; var writer = pipe.Output; diff --git a/Engine/Ioxide/README.md b/Engine/Ioxide/README.md index 22a029ddb..64a17e62d 100644 --- a/Engine/Ioxide/README.md +++ b/Engine/Ioxide/README.md @@ -13,13 +13,14 @@ no ASP.NET Core. ``` ioxide reactor (one per core, io_uring, SO_REUSEPORT) - └─ accept → Connection - └─ new ConnectionDualPipe(conn) // .Input = PipeReader, .Output = PipeWriter (zero-copy, inline IVTS) + └─ accept → TcpConnection + └─ TcpConnectionDualPipe(conn) // .Input = PipeReader, .Output = PipeWriter (zero-copy, inline IVTS) + (or TlsConnectionDualPipe for endpoints bound with a certificate) └─ ConnectionDriver loop: Glyph11 parser → GenHTTP Request (reused, public) → Handler.HandleAsync → ResponseWriter → PipeWriter ``` -The integration seam is ioxide's `ConnectionDualPipe`: GenHTTP's parse/handle/respond +The integration seam is ioxide's `TcpConnectionDualPipe`: GenHTTP's parse/handle/respond loop is already pure `PipeReader`/`PipeWriter`, so ioxide's native pipe bridge drops straight in. Reused from GenHTTP unchanged: the public `Request` model, the Glyph11 parser, the `IResponseSink` content contract. Forked (thin): the per-connection loop @@ -48,10 +49,10 @@ binding (`.Port()`/`.Bind()`), so any port set in the hook is overridden. ```csharp await Host.Create(c => c with { - ReactorCount = 16, // one io_uring reactor per core - RingEntries = 16384, - RecvBufferSize = 64 * 1024, - BufferRingEntries = 8192, + ReactorCount = 16, // one io_uring reactor per core + RingEntries = 16384, + RecvBufferSize = 64 * 1024, + RecvSlots = 8192, }) .Handler(app) .RunAsync(); @@ -60,9 +61,9 @@ await Host.Create(c => c with ## Dependency on ioxide References the published [`ioxide`](https://www.nuget.org/packages/ioxide) NuGet -package (`0.0.5`). The BCL pipe bridges the engine builds on -(`ConnectionDualPipe`/`ConnectionPipeReader`/`ConnectionPipeWriter`/`ConnectionStream`) -ship in that package. +package (`0.4.161`). The BCL pipe bridges the engine builds on +(`TcpConnectionDualPipe`/`TcpConnectionPipeReader`/`TcpConnectionPipeWriter`) and the +ring-native TLS termination (`TlsService`/`TlsConnectionDualPipe`) ship in that package. **Build note:** requires a .NET SDK with Roslyn 5.3+ (SDK 10.0.301+) because GenHTTP's `MemoryView` source generator references `Microsoft.CodeAnalysis 5.3`. @@ -77,10 +78,14 @@ Response handling mirrors the Internal engine: cached status lines, a once-a-sec `Date` header (per-reactor, thread-static), and a `ChunkedWriter` for unknown-length content. `Handler.PrepareAsync` runs at startup so handlers initialise before serving. +Also validated: **HTTPS** - endpoints bound with a certificate (`.Bind(address, port, cert)`) +are TLS-terminated ring-natively (OpenSSL both ways; kernel TLS stays opt-in through the +`connectionFactory` seam) - and **multiple endpoints**, served by one reactor set via +`ExtraPorts`, mixed plaintext and TLS. + Not yet implemented: -- TLS / HTTPS (ioxide is plaintext `AF_INET` only here; `ioxide.tls`/kTLS would wire HTTPS endpoints). -- IPv6 bind and multiple endpoints (first endpoint only). +- Client certificates, SNI-selected certificates, and per-endpoint dual-stack modes. - Graceful shutdown / connection drain (reactors are background threads; `DisposeAsync` only flips `Running`). - `IServerCompanion` callbacks, the `Host`-header check, and the error-response path. diff --git a/Engine/Ioxide/Server.cs b/Engine/Ioxide/Server.cs index aafbe1ccd..9a6803d40 100644 --- a/Engine/Ioxide/Server.cs +++ b/Engine/Ioxide/Server.cs @@ -26,13 +26,12 @@ public static class Host /// 's reactor seam (IoxideReactor.Current). /// /// - /// Optional hook to turn an accepted into the duplex pipe the engine - /// serves it over. Defaults to a plain ConnectionDualPipe. Supply a custom factory to - /// wrap the transport — e.g. terminate TLS on a second listener port by decrypting inbound bytes - /// and writing plaintext for kTLS TX. A returned pipe implementing - /// is disposed when the connection ends. + /// Optional hook to turn an accepted into the duplex pipe the engine + /// serves it over, overriding the built-in transport selection (plain pipe, or TLS termination + /// for endpoints bound with a certificate). A returned pipe implementing + /// is disposed when the connection ends. /// - public static IServerHost Create(Func? configure = null, Action? onReactorStart = null, Func>? connectionFactory = null) + public static IServerHost Create(Func? configure = null, Action? onReactorStart = null, Func>? connectionFactory = null) => new IoxideServerHost(configure, onReactorStart, connectionFactory); } diff --git a/Engine/Ioxide/Tls/IoxideTls.cs b/Engine/Ioxide/Tls/IoxideTls.cs index ef25bc0bb..10e323ab9 100644 --- a/Engine/Ioxide/Tls/IoxideTls.cs +++ b/Engine/Ioxide/Tls/IoxideTls.cs @@ -6,24 +6,13 @@ namespace GenHTTP.Engine.Ioxide; /// -/// TLS-termination helpers for the ioxide engine. The engine owns the transport plumbing (the -/// decrypt pump + kTLS-TX pipe adapter, ); the host supplies the -/// certificate/key and decides which connections to terminate (typically by listener port). +/// TLS helpers for the ioxide engine. Endpoints bound with a certificate are terminated +/// automatically; these helpers remain for hosts that wire a custom connectionFactory. /// -/// -/// -/// Host.Create( -/// configure: c => c with { ExtraPorts = [8081] }, -/// onReactorStart: r => IoxideTls.StartService(r, new TlsOptions { CertificatePath = cert, KeyPath = key }), -/// connectionFactory: conn => conn.ListenerPort == 8081 -/// ? IoxideTls.AcceptAsync(conn) -/// : new ValueTask<IDuplexPipe>(new ConnectionDualPipe(conn))); -/// -/// public static class IoxideTls { /// - /// onReactorStart hook: start the ring-native TLS service (OpenSSL context) on this reactor. + /// onReactorStart hook: start a ring-native TLS service (OpenSSL context) on this reactor. /// public static void StartService(Reactor reactor, TlsOptions options) => TlsService.Start(reactor, options); @@ -31,9 +20,22 @@ public static class IoxideTls /// connectionFactory helper: TLS-terminate on the current reactor and /// return the duplex pipe the engine serves over. Requires to have run. /// - public static async ValueTask AcceptAsync(Connection conn) + public static async ValueTask AcceptAsync(TcpConnection conn) + => await AcceptAsync(conn, IoxideReactor.Current.GetService()); + + internal static async ValueTask AcceptAsync(TcpConnection conn, TlsService service) { - var session = await IoxideReactor.Current.GetService().AcceptAsync(conn); - return new TlsDuplexPipe(conn, session); + var session = await service.AcceptAsync(conn); + + return new TlsConnectionDualPipe(conn, session); } } + +internal sealed class TlsRegistry +{ + private readonly Dictionary _byPort = []; + + public void Add(ushort port, TlsService service) => _byPort[port] = service; + + public TlsService For(ushort port) => _byPort[port]; +} diff --git a/Engine/Ioxide/Tls/TlsDuplexPipe.cs b/Engine/Ioxide/Tls/TlsDuplexPipe.cs deleted file mode 100644 index 0bf52cdd2..000000000 --- a/Engine/Ioxide/Tls/TlsDuplexPipe.cs +++ /dev/null @@ -1,124 +0,0 @@ -using System.Buffers; -using System.IO.Pipelines; - -using ioxide; -using ioxide.tls; - -namespace GenHTTP.Engine.Ioxide; - -/// -/// Adapts a TLS connection to the duplex pipe GenHTTP serves over. Inbound: a pump reads raw recv -/// slices, decrypts each via the , and writes the plaintext into a Pipe the -/// engine reads. Outbound: the engine writes plaintext to the connection's writer and kTLS TX (enabled -/// during the handshake) has the kernel produce the records — so no explicit encrypt step. -/// -internal sealed class TlsDuplexPipe : IDuplexPipe, IAsyncDisposable -{ - private readonly Connection _conn; - - private readonly TlsSession _tls; - - private readonly Pipe _inbound; - - private readonly ConnectionDualPipe _outer; // only its writer is used (plaintext + kTLS TX) - - private readonly CancellationTokenSource _cts; - - private readonly Task _pump; - - public TlsDuplexPipe(Connection conn, TlsSession session) - { - _conn = conn; - _tls = session; - _inbound = new Pipe(); - _outer = new ConnectionDualPipe(conn); - _cts = new CancellationTokenSource(); - _pump = PumpAsync(_cts.Token); - } - - public PipeReader Input => _inbound.Reader; - - public PipeWriter Output => _outer.Output; - - private async Task PumpAsync(CancellationToken ct) - { - var writer = _inbound.Writer; - - try - { - // The client's first request can ride in bundled with its Finished flight. - var initial = _tls.DrainPlaintext(); - if (!initial.IsEmpty) - { - writer.Write(initial); - await writer.FlushAsync(ct); - } - - while (!ct.IsCancellationRequested) - { - var snapshot = await _conn.ReadAsync(); - - var produced = false; - - unsafe - { - while (_conn.TryGetItem(snapshot, out var item)) - { - if (item.HasBuffer) - { - var plain = _tls.Decrypt(item.Ptr, item.Len); - if (!plain.IsEmpty) - { - writer.Write(plain); - produced = true; - } - } - - _conn.ReturnBuffer(in item); - } - } - - _conn.ResetRead(); - - if (produced) - { - var flush = await writer.FlushAsync(ct); - if (flush.IsCompleted) - { - break; - } - } - - if (snapshot.IsClosed) - { - break; - } - } - } - catch - { - // connection fault / cancellation — the reader is completed in finally - } - finally - { - await writer.CompleteAsync(); - } - } - - public async ValueTask DisposeAsync() - { - _cts.Cancel(); - - try - { - await _pump; - } - catch - { - // ignore teardown faults - } - - _tls.Dispose(); - _cts.Dispose(); - } -} From 383800a4cd99e30fd688493031a5488f90212e95 Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Sat, 8 Aug 2026 19:12:22 +0100 Subject: [PATCH 02/55] Ioxide engine: resolve certificates lazily, refuse handshakes without one The eager Provide(null) in the constructor threw for SNI-only certificate providers (SecurityTests' PickyCertificateProvider), failing host startup for the secure-upgrade redirect cases that never actually handshake. Certificates are now resolved per reactor in OnStart. A secure port whose provider yields no default certificate stays advertised (so redirects derive the https port) but its handshakes are refused with a FIN, so a client sees a fast connection failure instead of a plaintext response on an https port. --- Engine/Ioxide/Hosting/IoxideServer.cs | 43 +++++++++++++--------- Engine/Ioxide/Protocol/ConnectionDriver.cs | 27 +++++++++++--- Engine/Ioxide/Tls/IoxideTls.cs | 2 +- 3 files changed, 49 insertions(+), 23 deletions(-) diff --git a/Engine/Ioxide/Hosting/IoxideServer.cs b/Engine/Ioxide/Hosting/IoxideServer.cs index 0e76e3f43..63c6de1a1 100644 --- a/Engine/Ioxide/Hosting/IoxideServer.cs +++ b/Engine/Ioxide/Hosting/IoxideServer.cs @@ -24,7 +24,7 @@ public sealed class IoxideServer : IServer private readonly Dictionary _endPointByPort; - private readonly Dictionary _tls; + private readonly Dictionary _secure; private readonly ushort[] _extraPorts; @@ -77,31 +77,40 @@ internal IoxideServer(ServerConfiguration config, IHandler handler, Func(); + // Certificates are resolved per reactor in OnStart, not here: the provider is queried for + // its default (no-SNI) certificate then, and a port whose provider yields none is still + // advertised as secure (so secure-upgrade redirects work) but serves no handshake. + _secure = config.EndPoints + .Where(e => e.Security is not null) + .ToDictionary(e => e.Port, e => e.Security!); - foreach (var endpoint in config.EndPoints) - { - if (endpoint.Security is not { } security) - { - continue; - } + EndPoints = new IoxideEndPoints(mapped.Cast().ToList()); + } + // The certificate for every secure port whose provider yields a default (no-SNI) certificate. + // Providers that select by SNI (unsupported here) return none and are skipped - the port stays + // advertised as secure but its handshakes are refused. + private IEnumerable> ResolveTls() + { + foreach (var (port, security) in _secure) + { if (security.CertificateValidator is not null) { throw new NotSupportedException("Client certificate validation is not supported by the ioxide engine."); } - var certificate = security.CertificateProvider.Provide(null) - ?? throw new InvalidOperationException($"The certificate provider returned no default certificate for port {endpoint.Port}."); + if (security.CertificateProvider.Provide(null) is not { } certificate) + { + _logger.LogWarning("No default certificate for secure port {Port}; handshakes there will be refused (SNI selection is unsupported).", port); + continue; + } - _tls[endpoint.Port] = new TlsOptions + yield return new(port, new TlsOptions { CertificatePem = certificate.ExportCertificatePem(), KeyPem = ExportKeyPem(certificate) - }; + }); } - - EndPoints = new IoxideEndPoints(mapped.Cast().ToList()); } private static string ExportKeyPem(X509Certificate2 certificate) @@ -151,11 +160,11 @@ public async ValueTask StartAsync() { IoxideReactor.Bind(r); - if (_tls.Count > 0) + if (_secure.Count > 0) { var registry = new TlsRegistry(); - foreach (var (port, options) in _tls) + foreach (var (port, options) in ResolveTls()) { registry.Add(port, TlsService.Start(r, options, register: false)); } @@ -213,7 +222,7 @@ private async ValueTask PrepareHandlerAsync() } } - private string DescribeSettings() => $"ioxide, {_endPointByPort.Count} endpoint(s), TLS on {_tls.Count}, DualStack: {_primary.DualStack}, Reactors: {_reactors?.Length ?? 0}"; + private string DescribeSettings() => $"ioxide, {_endPointByPort.Count} endpoint(s), TLS on {_secure.Count}, DualStack: {_primary.DualStack}, Reactors: {_reactors?.Length ?? 0}"; public async ValueTask DisposeAsync() { diff --git a/Engine/Ioxide/Protocol/ConnectionDriver.cs b/Engine/Ioxide/Protocol/ConnectionDriver.cs index a5dadc6c8..a760215a1 100644 --- a/Engine/Ioxide/Protocol/ConnectionDriver.cs +++ b/Engine/Ioxide/Protocol/ConnectionDriver.cs @@ -93,11 +93,28 @@ internal static async Task HandleAsync(IServer server, IEndPoint endPoint, IoCon try { - pipe = connectionFactory is not null - ? await connectionFactory(conn) - : endPoint.Secure - ? await IoxideTls.AcceptAsync(conn, IoxideReactor.Current.GetService().For(conn.ListenerPort)) - : new ioxide.TcpConnectionDualPipe(conn); + if (connectionFactory is not null) + { + pipe = await connectionFactory(conn); + } + else if (endPoint.Secure) + { + // A secure port with no certificate (an SNI-only provider yielded none) is advertised + // for redirects but cannot handshake - FIN the connection so the client's handshake + // fails fast rather than a plaintext response landing on an https port. + if (!IoxideReactor.Current.GetService().TryFor(conn.ListenerPort, out var service)) + { + Shutdown(conn.ClientFd, ShutWrite); + conn.DecRef(); + return; + } + + pipe = await IoxideTls.AcceptAsync(conn, service); + } + else + { + pipe = new ioxide.TcpConnectionDualPipe(conn); + } } catch { diff --git a/Engine/Ioxide/Tls/IoxideTls.cs b/Engine/Ioxide/Tls/IoxideTls.cs index 10e323ab9..d0b95483e 100644 --- a/Engine/Ioxide/Tls/IoxideTls.cs +++ b/Engine/Ioxide/Tls/IoxideTls.cs @@ -37,5 +37,5 @@ internal sealed class TlsRegistry public void Add(ushort port, TlsService service) => _byPort[port] = service; - public TlsService For(ushort port) => _byPort[port]; + public bool TryFor(ushort port, out TlsService service) => _byPort.TryGetValue(port, out service!); } From 57e0e0dfbae8779293e381296bfda9ccd64e76dd Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Sun, 9 Aug 2026 13:02:54 +0100 Subject: [PATCH 03/55] Ioxide engine: ioxide 0.4.165, and kernelTx / kernelRx kTLS options --- Engine/Ioxide/GenHTTP.Engine.Ioxide.csproj | 2 +- Engine/Ioxide/Hosting/IoxideServer.cs | 12 ++++++++++-- Engine/Ioxide/Hosting/IoxideServerHost.cs | 6 +++--- Engine/Ioxide/Server.cs | 15 +++++++++++++-- 4 files changed, 27 insertions(+), 8 deletions(-) diff --git a/Engine/Ioxide/GenHTTP.Engine.Ioxide.csproj b/Engine/Ioxide/GenHTTP.Engine.Ioxide.csproj index 935f22495..2bfe3b2c5 100644 --- a/Engine/Ioxide/GenHTTP.Engine.Ioxide.csproj +++ b/Engine/Ioxide/GenHTTP.Engine.Ioxide.csproj @@ -10,7 +10,7 @@ - + diff --git a/Engine/Ioxide/Hosting/IoxideServer.cs b/Engine/Ioxide/Hosting/IoxideServer.cs index 63c6de1a1..9409730d2 100644 --- a/Engine/Ioxide/Hosting/IoxideServer.cs +++ b/Engine/Ioxide/Hosting/IoxideServer.cs @@ -34,6 +34,10 @@ public sealed class IoxideServer : IServer private readonly Func>? _connectionFactory; + private readonly bool _kernelTx; + + private readonly bool _kernelRx; + private readonly ILogger _logger; private Thread[]? _threads; @@ -54,13 +58,15 @@ public sealed class IoxideServer : IServer public IHandler Handler { get; } - internal IoxideServer(ServerConfiguration config, IHandler handler, Func? configure = null, Action? onReactorStart = null, Func>? connectionFactory = null) + internal IoxideServer(ServerConfiguration config, IHandler handler, Func? configure = null, Action? onReactorStart = null, Func>? connectionFactory = null, bool kernelTx = false, bool kernelRx = false) { _config = config; Handler = handler; _configure = configure; _onReactorStart = onReactorStart; _connectionFactory = connectionFactory; + _kernelTx = kernelTx; + _kernelRx = kernelRx; _logger = config.Logging.CreateLogger(); @@ -108,7 +114,9 @@ private IEnumerable> ResolveTls() yield return new(port, new TlsOptions { CertificatePem = certificate.ExportCertificatePem(), - KeyPem = ExportKeyPem(certificate) + KeyPem = ExportKeyPem(certificate), + KernelTx = _kernelTx, + KernelRx = _kernelRx }); } } diff --git a/Engine/Ioxide/Hosting/IoxideServerHost.cs b/Engine/Ioxide/Hosting/IoxideServerHost.cs index b74098f4f..76ce2dc90 100644 --- a/Engine/Ioxide/Hosting/IoxideServerHost.cs +++ b/Engine/Ioxide/Hosting/IoxideServerHost.cs @@ -9,10 +9,10 @@ namespace GenHTTP.Engine.Ioxide.Hosting; -public sealed class IoxideServerHost(Func? configure = null, Action? onReactorStart = null, Func>? connectionFactory = null) : ServerHost +public sealed class IoxideServerHost(Func? configure = null, Action? onReactorStart = null, Func>? connectionFactory = null, bool kernelTx = false, bool kernelRx = false) : ServerHost { - + protected override IServer Build(ServerConfiguration config, IHandler handler) - => new IoxideServer(config, handler, configure, onReactorStart, connectionFactory); + => new IoxideServer(config, handler, configure, onReactorStart, connectionFactory, kernelTx, kernelRx); } diff --git a/Engine/Ioxide/Server.cs b/Engine/Ioxide/Server.cs index 9a6803d40..3bfc619c9 100644 --- a/Engine/Ioxide/Server.cs +++ b/Engine/Ioxide/Server.cs @@ -31,7 +31,18 @@ public static class Host /// for endpoints bound with a certificate). A returned pipe implementing /// is disposed when the connection ends. /// - public static IServerHost Create(Func? configure = null, Action? onReactorStart = null, Func>? connectionFactory = null) - => new IoxideServerHost(configure, onReactorStart, connectionFactory); + /// + /// Offload TLS record ENCRYPTION to the kernel (kTLS TX) on TLS-terminated endpoints instead of + /// encrypting in OpenSSL. Off by default (OpenSSL both ways). The kernel produces the records on + /// the send path while OpenSSL still drives the handshake; requires the Linux tls module + /// and TLS 1.3. + /// + /// + /// Offload TLS record DECRYPTION to the kernel (kTLS RX) on the receive path. Off by default and + /// experimental; it requires (RX shares the ULP handoff TX installs, + /// so ioxide refuses RX alone) and a peer that sends no post-handshake control records. + /// + public static IServerHost Create(Func? configure = null, Action? onReactorStart = null, Func>? connectionFactory = null, bool kernelTx = false, bool kernelRx = false) + => new IoxideServerHost(configure, onReactorStart, connectionFactory, kernelTx, kernelRx); } From dfadfde8f531b3142d2b10f94d1e7898d4a8f365 Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Sun, 9 Aug 2026 13:19:42 +0100 Subject: [PATCH 04/55] Ioxide engine: address Sonar findings (discard shutdown() result, guard Information log) --- Engine/Ioxide/Hosting/IoxideServer.cs | 5 ++++- Engine/Ioxide/Protocol/ConnectionDriver.cs | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/Engine/Ioxide/Hosting/IoxideServer.cs b/Engine/Ioxide/Hosting/IoxideServer.cs index 9409730d2..a4dd65446 100644 --- a/Engine/Ioxide/Hosting/IoxideServer.cs +++ b/Engine/Ioxide/Hosting/IoxideServer.cs @@ -209,7 +209,10 @@ public async ValueTask StartAsync() _logger.LogWarning("Not all reactors reported listening within 10s; the server may not be fully accepting yet."); } - _logger.LogInformation("Listening on {Address}:{Port} ({Settings})", _primary.Address, _primary.Port, DescribeSettings()); + if (_logger.IsEnabled(LogLevel.Information)) + { + _logger.LogInformation("Listening on {Address}:{Port} ({Settings})", _primary.Address, _primary.Port, DescribeSettings()); + } } private async ValueTask PrepareHandlerAsync() diff --git a/Engine/Ioxide/Protocol/ConnectionDriver.cs b/Engine/Ioxide/Protocol/ConnectionDriver.cs index a760215a1..105dd75b7 100644 --- a/Engine/Ioxide/Protocol/ConnectionDriver.cs +++ b/Engine/Ioxide/Protocol/ConnectionDriver.cs @@ -104,7 +104,7 @@ internal static async Task HandleAsync(IServer server, IEndPoint endPoint, IoCon // fails fast rather than a plaintext response landing on an https port. if (!IoxideReactor.Current.GetService().TryFor(conn.ListenerPort, out var service)) { - Shutdown(conn.ClientFd, ShutWrite); + _ = Shutdown(conn.ClientFd, ShutWrite); conn.DecRef(); return; } From c903ca8ad4ad1da761c8262e32c628808864ab26 Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Sun, 9 Aug 2026 21:29:43 +0100 Subject: [PATCH 05/55] IoxideFiles: ioxide.file 0.4.169, and keep serving edited files ioxide.file 0.4.167 became io_uring reads only - it hands out a descriptor and a length, bakes no HTTP responses and caches no bytes. So Asset.Response, Asset.ResponseLength and AssetCache.IsFresh are all gone, and this module could not merely be re-pinned; the bump from 0.1.1 to 0.4.169 crosses that redesign. The engine goes 0.4.165 -> 0.4.169 with it. The baked-response branch is gone: the body is always read off the ring through the per-reactor AssetReader pool, which this class already used for assets too large to bake. The freshness check moves here rather than disappearing. The package dropped per-request statx deliberately - it trusts a snapshot's descriptors and expects Reload() on deploy - but this module's documented behaviour is that an edited file is served, and TestChangedFileServesUpdatedContent asserts it. Adopting the package's model silently would have changed GenHTTP's contract under its users, so AssetFreshness reproduces the size comparison the package used to do. It matters beyond freshness: the handler's length becomes Content-Length, so the body writer must agree with it or the response is malformed - which is exactly how the built-in Files module misbehaves when a file changes under it, serving new content at the old length. Acceptance suite: 2044 (net11) + 1442 (net10) pass, including all 16 Ioxide tests. Playground gains /ring and /disk over one directory to price the two against each other; that file also carries unrelated in-progress work, so it is left uncommitted deliberately. --- Engine/Ioxide/GenHTTP.Engine.Ioxide.csproj | 2 +- Modules/IoxideFiles/AssetFreshness.cs | 46 ++++++++++++ .../GenHTTP.Modules.IoxideFiles.csproj | 2 +- Modules/IoxideFiles/IoxideAssetContent.cs | 74 +++++++++---------- Modules/IoxideFiles/IoxideFilesHandler.cs | 4 +- nuget.config | 8 ++ 6 files changed, 96 insertions(+), 40 deletions(-) create mode 100644 Modules/IoxideFiles/AssetFreshness.cs create mode 100644 nuget.config diff --git a/Engine/Ioxide/GenHTTP.Engine.Ioxide.csproj b/Engine/Ioxide/GenHTTP.Engine.Ioxide.csproj index 2bfe3b2c5..e46653b1f 100644 --- a/Engine/Ioxide/GenHTTP.Engine.Ioxide.csproj +++ b/Engine/Ioxide/GenHTTP.Engine.Ioxide.csproj @@ -10,7 +10,7 @@ - + diff --git a/Modules/IoxideFiles/AssetFreshness.cs b/Modules/IoxideFiles/AssetFreshness.cs new file mode 100644 index 000000000..22ba712a6 --- /dev/null +++ b/Modules/IoxideFiles/AssetFreshness.cs @@ -0,0 +1,46 @@ +using ioxide.file; + +namespace GenHTTP.Modules.IoxideFiles; + +/// +/// Whether a snapshot's asset still matches the file on disk, by size - the same check +/// ioxide.file performed until 0.4.167, when it moved to trusting descriptors for the lifetime of +/// a snapshot. Reproduced here so this module keeps serving edited files without a reload. +/// +internal static class AssetFreshness +{ + + /// + /// True when the descriptor can be trusted. and + /// describe the file as it is now, so a caller that gets + /// false can still serve the changed file rather than 404 it. + /// + internal static bool IsFresh(in AssetCache.Asset asset, out bool exists, out long currentLength) + { + try + { + var info = new FileInfo(asset.Path); + + exists = info.Exists; + currentLength = exists ? info.Length : 0; + + return exists && currentLength == asset.Length; + } + catch (IOException) + { + // Racing with a rename or delete: treat as gone rather than serve a stale descriptor. + exists = false; + currentLength = 0; + + return false; + } + catch (UnauthorizedAccessException) + { + exists = false; + currentLength = 0; + + return false; + } + } + +} diff --git a/Modules/IoxideFiles/GenHTTP.Modules.IoxideFiles.csproj b/Modules/IoxideFiles/GenHTTP.Modules.IoxideFiles.csproj index 5173c7381..5722f1a7a 100644 --- a/Modules/IoxideFiles/GenHTTP.Modules.IoxideFiles.csproj +++ b/Modules/IoxideFiles/GenHTTP.Modules.IoxideFiles.csproj @@ -19,7 +19,7 @@ - + diff --git a/Modules/IoxideFiles/IoxideAssetContent.cs b/Modules/IoxideFiles/IoxideAssetContent.cs index 3f57ff8a4..448277b3b 100644 --- a/Modules/IoxideFiles/IoxideAssetContent.cs +++ b/Modules/IoxideFiles/IoxideAssetContent.cs @@ -1,6 +1,7 @@ using System.Buffers; using Microsoft.Win32.SafeHandles; + using GenHTTP.Api.Protocol; using GenHTTP.Engine.Ioxide; @@ -14,6 +15,10 @@ namespace GenHTTP.Modules.IoxideFiles; /// Writes one asset's body to the response sink, flush-disciplined so it never stages more than /// bytes into the ioxide write slab at once. Re-resolves the asset under its own /// lease, so nothing is held across an await. +/// +/// The body is read positionally off the ring through a per-reactor pool. +/// It cannot use the connection's write slab directly - TcpConnection.ReadFileAsync reads +/// into that slab, and this writes into GenHTTP's response sink instead - so the copy stays. /// public sealed class IoxideAssetContent(StaticAssets assets, string path, long length, ContentType contentType, ReadOnlyMemory? contentEncoding) : IResponseContent { @@ -39,24 +44,19 @@ public async ValueTask WriteAsync(IResponseSink sink) return; // vanished between header and body (rare) } - if (AssetCache.IsFresh(asset, out var exists, out _)) + // ioxide.file bakes no HTTP any more, so there is no cached response to write - the body + // is always read off the ring. The only question is WHICH file: the snapshot's descriptor + // when it still matches disk, or a fresh open when the file changed underneath it. The + // handler resolved the same question to set Content-Length, and `length` carries its + // answer, so the two cannot disagree. + if (AssetFreshness.IsFresh(asset, out var exists, out _)) { - if (asset.Response != 0) - { - // Fresh + baked: write just the body (GenHTTP framed the header). The baked block is - // header+body in native memory; the body is the trailing asset.Length bytes. - await WriteNative(sink, asset.Response + (nint)(asset.ResponseLength - asset.Length), asset.Length); - } - else - { - // Fresh but too large to bake: read off the ring from the cached fd. - await WriteFromDisk(sink, asset.Fd, length); - } + await WriteFromDisk(sink, asset.Fd, length); } else if (exists) { - // Changed on disk (edit or atomic rename): open the current path fresh so a rename resolves - // to the new inode, not the cached fd. + // Changed on disk (edit or atomic rename): open the current path so a rename resolves + // to the new inode rather than the descriptor the snapshot still holds. await WriteChanged(sink, asset.Path, length); } } @@ -80,6 +80,29 @@ private static async ValueTask WriteNative(IResponseSink sink, nint data, long l private static unsafe void WriteChunk(IBufferWriter writer, nint data, int len) => writer.Write(new ReadOnlySpan((byte*)data, len)); + private static async ValueTask WriteChanged(IResponseSink sink, string filePath, long len) + { + SafeFileHandle handle; + + try + { + handle = File.OpenHandle(filePath); + } + catch + { + return; // raced with a delete + } + + try + { + await WriteFromDisk(sink, (int)handle.DangerousGetHandle(), len); + } + finally + { + handle.Dispose(); + } + } + private static async ValueTask WriteFromDisk(IResponseSink sink, int fd, long len) { var readers = RentPool(); @@ -108,29 +131,6 @@ private static async ValueTask WriteFromDisk(IResponseSink sink, int fd, long le } } - private static async ValueTask WriteChanged(IResponseSink sink, string filePath, long len) - { - SafeFileHandle handle; - - try - { - handle = File.OpenHandle(filePath); - } - catch - { - return; // raced with a delete - } - - try - { - await WriteFromDisk(sink, (int)handle.DangerousGetHandle(), len); - } - finally - { - handle.Dispose(); - } - } - // The AssetReader pool is per-reactor; ioxide's GetService throws if absent, so create-and-self- // register on first use on this reactor. private static RingPool RentPool() diff --git a/Modules/IoxideFiles/IoxideFilesHandler.cs b/Modules/IoxideFiles/IoxideFilesHandler.cs index 5563a7f64..8db2de9aa 100644 --- a/Modules/IoxideFiles/IoxideFilesHandler.cs +++ b/Modules/IoxideFiles/IoxideFilesHandler.cs @@ -70,7 +70,9 @@ internal IoxideFilesHandler(StaticAssets assets) return default; // raced away } - if (AssetCache.IsFresh(asset, out var exists, out var currentSize)) + // This length becomes Content-Length, so it has to be the size the body writer will + // actually produce - hence resolving freshness here and not only at write time. + if (AssetFreshness.IsFresh(asset, out var exists, out var currentSize)) { length = asset.Length; } diff --git a/nuget.config b/nuget.config new file mode 100644 index 000000000..173718369 --- /dev/null +++ b/nuget.config @@ -0,0 +1,8 @@ + + + + + + + + From 68ff18b57aa82372f3c05f770b7edb1fa19f1109 Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Sun, 9 Aug 2026 21:36:49 +0100 Subject: [PATCH 06/55] Playground: static served two ways, so the difference can be measured /ring mounts IoxideFiles and /disk GenHTTP's built-in Files module over the SAME directory, on the same engine, so the module is the only variable. GENHTTP_STATIC picks the directory and neither route mounts without it. Measured here with wrk -t8 -c64, best of two interleaved passes: /ring /disk 4 KiB 835409 1041891 64 KiB 365531 509255 The built-in module is ahead, but part of that is work it does not do: edit a file while it runs and it serves the new content at the old Content-Length, truncating the response, where IoxideFiles serves it whole. That check is what AssetFreshness restored. One tuning note for later: IoxideAssetContent flushes every 12 KiB to stay under the 16 KiB write slab, and at 64 KiB that costs about 19% - raising the chunk to 64 KiB measured 433924 against 365531, content verified identical. Left alone because a bigger chunk grows every connection's slab, which is a memory tradeoff worth deciding rather than slipping in. --- Playground/Program.cs | 45 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/Playground/Program.cs b/Playground/Program.cs index 8333f9870..925868c3a 100644 --- a/Playground/Program.cs +++ b/Playground/Program.cs @@ -1,9 +1,36 @@ -using GenHTTP.Engine.Internal; - -using GenHTTP.Modules.IO; - -var app = Content.From(Resource.FromString("Hello World!")); - -await Host.Create() - .Handler(app) - .RunAsync(); +using GenHTTP.Engine.Ioxide; + +using GenHTTP.Modules.Files; +using GenHTTP.Modules.IO; +using GenHTTP.Modules.Layouting; + +// The namespace and the class share a name, so the class needs an alias to be reachable. +using IoxideFilesModule = GenHTTP.Modules.IoxideFiles.IoxideFiles; + +// Two static handlers over the SAME directory, so the difference can be priced rather than argued: +// +// /ring/* IoxideFiles - ioxide.file opens every file once, shares the descriptors across +// reactors and reads them positionally off the io_uring ring. Nothing is cached in +// memory, so resident size stays flat whatever the asset set weighs. +// /disk/* GenHTTP's built-in Files module, for comparison. +// +// GENHTTP_STATIC picks the directory; without it neither route is mounted. +// +// GENHTTP_STATIC=/srv/www dotnet run -c Release --project Playground +// wrk -t8 -c64 -d8s http://127.0.0.1:8080/ring/asset.bin +// wrk -t8 -c64 -d8s http://127.0.0.1:8080/disk/asset.bin + +var staticDir = Environment.GetEnvironmentVariable("GENHTTP_STATIC"); + +var app = Layout.Create() + .Add("ok", Content.From(Resource.FromString("ok"))); + +if (staticDir != null && Directory.Exists(staticDir)) +{ + app = app.Add("ring", IoxideFilesModule.From(staticDir)) + .Add("disk", Assets.From(staticDir)); +} + +await Host.Create() + .Handler(app) + .RunAsync(); From f55db2b81dcca80982c3c0db039bda1efcea72a3 Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Sun, 9 Aug 2026 21:40:38 +0100 Subject: [PATCH 07/55] Drop nuget.config: ioxide 0.4.169 is on nuget.org Added on a wrong assumption that 0.4.169 was unpublished. It is, so the local feed was both unnecessary and a hazard - it pinned an absolute path that only exists on one machine, and it shadowed the published package with a locally built one of the same version. Restore now resolves from nuget.org (verified via .nupkg.metadata source), and the acceptance suite passes against the published package: 2044 on net11, 1442 on net10. --- nuget.config | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 nuget.config diff --git a/nuget.config b/nuget.config deleted file mode 100644 index 173718369..000000000 --- a/nuget.config +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - From b8f4399990056cd7cf5eb207382bebacc706f7ce Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Sun, 16 Aug 2026 00:56:24 +0100 Subject: [PATCH 08/55] feat(ioxide): HTTP/2 and HTTP/3, streamed both ways, with mutual TLS The engine served HTTP/1.1 only. It now serves HTTP/2 - by ALPN on a TLS port, by the connection preface on a plaintext one - and HTTP/3 on the endpoint bound with enableQuic, carried by ngtcp2 and nghttp3. Streamed in both directions on both protocols. A handler starts once the request headers have arrived and pulls the body as it is delivered, paced by flow control so an upload cannot outrun it; the response goes out through the protocol's own writer, where each flush parks until the peer's window allows more. Serving a large file therefore costs the send-retention high-water rather than the size of the file. HTTP/2 and HTTP/3 differ only in transport, so the bridge between them and the handler chain is written once in Protocol/Mux and the two drivers are thin. The server splits along the same line: hosting, TLS termination and the QUIC listener are three partial files rather than one growing class. Certificates come from the caller. ngtcp2 loads PEM from disk rather than taking a certificate object, so Http3CertificatePath and Http3KeyPath name the files directly and nothing is written. Without them the endpoint's certificate is exported to a temporary directory created owner-only before anything is written to it, and removed on shutdown - which is worth avoiding, and the log says so. Mutual TLS across all three protocols, enforced where the connection is terminated: OpenSSL for HTTP/1.1 and HTTP/2, ngtcp2 for HTTP/3. An endpoint bound with a certificateValidator asks for a client certificate; ClientCaPath is what the offered one is validated against. Verified per protocol - a client signed by the configured CA is served, one offering nothing is refused, and one signed by another CA is refused. Engine options move to an IoxideOptions record rather than growing Create's parameter list, and HTTP/3 keys off the enableQuic flag GenHTTP's endpoint model already carries instead of a second switch. Acceptance suite 1442/1442. --- Engine/Ioxide/GenHTTP.Engine.Ioxide.csproj | 7 +- Engine/Ioxide/Hosting/IoxideServer.Quic.cs | 165 ++++++++++++++++++ Engine/Ioxide/Hosting/IoxideServer.Tls.cs | 75 ++++++++ Engine/Ioxide/Hosting/IoxideServer.cs | 100 ++++++----- Engine/Ioxide/Hosting/IoxideServerHost.cs | 4 +- Engine/Ioxide/IoxideOptions.cs | 87 +++++++++ Engine/Ioxide/Protocol/ConnectionDriver.cs | 79 ++++++++- Engine/Ioxide/Protocol/Mux/Http2Driver.cs | 89 ++++++++++ Engine/Ioxide/Protocol/Mux/Http3Driver.cs | 90 ++++++++++ Engine/Ioxide/Protocol/Mux/MuxKeyValueList.cs | 29 +++ Engine/Ioxide/Protocol/Mux/MuxRequest.cs | 124 +++++++++++++ Engine/Ioxide/Protocol/Mux/MuxRequestBody.cs | 122 +++++++++++++ .../Ioxide/Protocol/Mux/MuxRequestHeader.cs | 117 +++++++++++++ Engine/Ioxide/Protocol/Mux/MuxResponder.cs | 156 +++++++++++++++++ Engine/Ioxide/Protocol/Mux/MuxSink.cs | 100 +++++++++++ Engine/Ioxide/Server.cs | 9 +- Engine/Ioxide/Tls/IoxideTls.cs | 10 +- .../GenHTTP.Modules.IoxideFiles.csproj | 2 +- Playground/Program.cs | 72 +++++++- 19 files changed, 1388 insertions(+), 49 deletions(-) create mode 100644 Engine/Ioxide/Hosting/IoxideServer.Quic.cs create mode 100644 Engine/Ioxide/Hosting/IoxideServer.Tls.cs create mode 100644 Engine/Ioxide/IoxideOptions.cs create mode 100644 Engine/Ioxide/Protocol/Mux/Http2Driver.cs create mode 100644 Engine/Ioxide/Protocol/Mux/Http3Driver.cs create mode 100644 Engine/Ioxide/Protocol/Mux/MuxKeyValueList.cs create mode 100644 Engine/Ioxide/Protocol/Mux/MuxRequest.cs create mode 100644 Engine/Ioxide/Protocol/Mux/MuxRequestBody.cs create mode 100644 Engine/Ioxide/Protocol/Mux/MuxRequestHeader.cs create mode 100644 Engine/Ioxide/Protocol/Mux/MuxResponder.cs create mode 100644 Engine/Ioxide/Protocol/Mux/MuxSink.cs diff --git a/Engine/Ioxide/GenHTTP.Engine.Ioxide.csproj b/Engine/Ioxide/GenHTTP.Engine.Ioxide.csproj index e46653b1f..577b254c6 100644 --- a/Engine/Ioxide/GenHTTP.Engine.Ioxide.csproj +++ b/Engine/Ioxide/GenHTTP.Engine.Ioxide.csproj @@ -10,7 +10,12 @@ - + + + + + diff --git a/Engine/Ioxide/Hosting/IoxideServer.Quic.cs b/Engine/Ioxide/Hosting/IoxideServer.Quic.cs new file mode 100644 index 000000000..20ff7f934 --- /dev/null +++ b/Engine/Ioxide/Hosting/IoxideServer.Quic.cs @@ -0,0 +1,165 @@ +using System.Security.Cryptography.X509Certificates; + +using ioxide; +using ioxide.ngtcp2; + +using Microsoft.Extensions.Logging; + +namespace GenHTTP.Engine.Ioxide.Hosting; + +/// +/// The QUIC listener that carries HTTP/3, alongside the TCP one. +/// +public sealed partial class IoxideServer +{ + private QuicEngine? _quic; + + private IoxideEndPoint? _quicEndPoint; + + // Only set when a certificate had to be written out; a user-supplied path is never touched. + private string? _exportedCertPath; + + private string? _exportedKeyPath; + + /// + /// Adds the QUIC listener for the endpoint bound with enableQuic. + /// + /// + /// QUIC carries TLS 1.3 and has no cleartext mode, so this needs a secure endpoint - the + /// certificate bound there is the one it serves. The UDP port is the endpoint's own port, which + /// is what a browser assumes when it reads an Alt-Svc advertisement naming no port of its own. + /// + private ServerConfig WithQuic(ServerConfig cfg, IoxideEndPoint endPoint) + { + if (!_secure.TryGetValue(endPoint.Port, out var security)) + { + _logger.LogWarning("HTTP/3 was requested on port {Port}, which is not bound with a certificate; QUIC has no cleartext mode, so no listener was started.", endPoint.Port); + return cfg; + } + + if (!TryResolveQuicCertificate(security, endPoint.Port, out var certPath, out var keyPath)) + { + return cfg; + } + + _quic = new QuicEngine(certPath, keyPath, alpn: ["h3"], + clientCaPemPath: _options.ClientCaPath, + requireClientCertificate: RequiresClientCertificate(security)); + + _quicEndPoint = endPoint; + + return cfg with + { + Udp = cfg.Udp ?? new UdpOptions(), + Quic = new QuicOptions + { + Port = endPoint.Port, + ConnectionFactory = _quic.CreateFactory(), + }, + }; + } + + /// + /// The PEM files ngtcp2 loads: the ones configured, or the bound certificate written out. + /// + /// + /// ngtcp2 takes paths, not a certificate object, so one of the two has to happen. A configured + /// path is used as it is and nothing is written. Otherwise the endpoint's certificate is + /// exported to a file this user alone can read, which does put a private key on disk for the + /// lifetime of the process - so a deployment holding PEM files should name them through + /// and skip this entirely. + /// + private bool TryResolveQuicCertificate(Shared.Infrastructure.SecurityConfiguration security, ushort port, + out string certPath, out string keyPath) + { + if (_options.Http3CertificatePath is { } configuredCert && _options.Http3KeyPath is { } configuredKey) + { + if (!File.Exists(configuredCert) || !File.Exists(configuredKey)) + { + _logger.LogError("The configured HTTP/3 certificate or key does not exist ({Certificate}, {Key}); no listener was started.", configuredCert, configuredKey); + certPath = keyPath = string.Empty; + return false; + } + + certPath = configuredCert; + keyPath = configuredKey; + return true; + } + + if (security.CertificateProvider.Provide(null) is not { } certificate) + { + _logger.LogWarning("No default certificate for port {Port}; no HTTP/3 listener was started.", port); + certPath = keyPath = string.Empty; + return false; + } + + var directory = Directory.CreateTempSubdirectory("genhttp-ioxide-"); + + // Owner-only, set before anything is written: the key must never exist world-readable, not + // even for the moment between creating the file and tightening it. The engine only runs on + // Linux (io_uring), but the file APIs are cross-platform and the analyzer checks them. + if (!OperatingSystem.IsWindows()) + { + directory.UnixFileMode = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute; + } + + _exportedCertPath = Path.Combine(directory.FullName, "quic.crt"); + _exportedKeyPath = Path.Combine(directory.FullName, "quic.key"); + + WriteOwnerOnly(_exportedCertPath, certificate.ExportCertificatePem()); + WriteOwnerOnly(_exportedKeyPath, ExportKeyPem(certificate)); + + _logger.LogInformation("Exported the certificate bound to port {Port} to {Directory} for the HTTP/3 listener; set Http3CertificatePath to avoid writing a key to disk.", port, directory.FullName); + + certPath = _exportedCertPath; + keyPath = _exportedKeyPath; + return true; + } + + private static void WriteOwnerOnly(string path, string content) + { + var options = new FileStreamOptions + { + Mode = FileMode.CreateNew, + Access = FileAccess.Write, + }; + + if (!OperatingSystem.IsWindows()) + { + options.UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite; + } + + using var stream = new FileStream(path, options); + using var writer = new StreamWriter(stream); + + writer.Write(content); + } + + /// + /// Drops the QUIC engine and anything that was written out for it. + /// + private void DisposeQuic() + { + _quic?.Dispose(); + _quic = null; + + if (_exportedCertPath is null) + { + return; + } + + try + { + Directory.Delete(Path.GetDirectoryName(_exportedCertPath)!, recursive: true); + } + catch (IOException e) + { + // Best effort. A leftover key in a temp directory is worth a line in the log, but not a + // failed shutdown - it is owner-only and the directory name is unique to this process. + _logger.LogWarning(e, "Could not remove the exported HTTP/3 certificate at {Path}", _exportedCertPath); + } + + _exportedCertPath = null; + _exportedKeyPath = null; + } +} diff --git a/Engine/Ioxide/Hosting/IoxideServer.Tls.cs b/Engine/Ioxide/Hosting/IoxideServer.Tls.cs new file mode 100644 index 000000000..03b9fa548 --- /dev/null +++ b/Engine/Ioxide/Hosting/IoxideServer.Tls.cs @@ -0,0 +1,75 @@ +using System.Security.Cryptography.X509Certificates; + +using GenHTTP.Engine.Shared.Infrastructure; + +using ioxide.tls; + +using Microsoft.Extensions.Logging; + +namespace GenHTTP.Engine.Ioxide.Hosting; + +/// +/// TLS termination for the TCP endpoints - HTTP/1.1 and HTTP/2 both ride this. +/// +public sealed partial class IoxideServer +{ + /// + /// The TLS options for every secure port whose provider yields a default (no-SNI) certificate. + /// + /// + /// Providers that select by SNI (unsupported here) return none and are skipped - the port stays + /// advertised as secure, so secure-upgrade redirects still work, but its handshakes are refused. + /// + private IEnumerable> ResolveTls() + { + foreach (var (port, security) in _secure) + { + if (security.CertificateProvider.Provide(null) is not { } certificate) + { + _logger.LogWarning("No default certificate for secure port {Port}; handshakes there will be refused (SNI selection is unsupported).", port); + continue; + } + + yield return new(port, new TlsOptions + { + CertificatePem = certificate.ExportCertificatePem(), + KeyPem = ExportKeyPem(certificate), + + // Server preference, most preferred first: a client offering both gets HTTP/2, one + // offering only http/1.1 is unaffected, and one offering neither continues without + // an ALPN extension at all. + Alpn = _options.Http2 ? ["h2", "http/1.1"] : ["http/1.1"], + + ClientCaPath = _options.ClientCaPath, + ClientCaPem = _options.ClientCaPem, + RequireClientCertificate = RequiresClientCertificate(security), + + KernelTx = _kernelTx, + KernelRx = _kernelRx + }); + } + } + + /// + /// Whether a client offering no certificate is refused on this endpoint. + /// + /// + /// Either the engine says so for every endpoint, or the endpoint's own validator does. A + /// validator that only wants to inspect what arrives leaves RequireCertificate false and + /// still gets asked, because the CertificateRequest goes out either way. + /// + private bool RequiresClientCertificate(SecurityConfiguration security) + => _options.RequireClientCertificate || security.CertificateValidator?.RequireCertificate == true; + + /// + /// Whether any endpoint asks for a client certificate at all. + /// + private bool MutualTlsConfigured + => _options.ClientCaPath is not null || _options.ClientCaPem is not null + || _options.RequireClientCertificate || _secure.Values.Any(s => s.CertificateValidator is not null); + + private static string ExportKeyPem(X509Certificate2 certificate) + => certificate.GetRSAPrivateKey()?.ExportPkcs8PrivateKeyPem() + ?? certificate.GetECDsaPrivateKey()?.ExportPkcs8PrivateKeyPem() + ?? throw new InvalidOperationException("The certificate carries no exportable RSA or ECDSA private key."); +} diff --git a/Engine/Ioxide/Hosting/IoxideServer.cs b/Engine/Ioxide/Hosting/IoxideServer.cs index a4dd65446..814afa417 100644 --- a/Engine/Ioxide/Hosting/IoxideServer.cs +++ b/Engine/Ioxide/Hosting/IoxideServer.cs @@ -1,22 +1,32 @@ using System.Diagnostics; using System.IO.Pipelines; -using System.Security.Cryptography.X509Certificates; using GenHTTP.Api.Content; using GenHTTP.Api.Infrastructure; using GenHTTP.Engine.Ioxide.Protocol; +using GenHTTP.Engine.Ioxide.Protocol.Mux; using GenHTTP.Engine.Shared.Infrastructure; using GenHTTP.Engine.Shared.Types; using ioxide; +using ioxide.nghttp3; using ioxide.tls; using Microsoft.Extensions.Logging; namespace GenHTTP.Engine.Ioxide.Hosting; -public sealed class IoxideServer : IServer +/// +/// Hosts an application on ioxide's io_uring reactors. +/// +/// +/// One reactor per core, each owning a ring and its connections on its own thread. Protocol +/// selection is per endpoint: HTTP/1.1 always, HTTP/2 when enabled (by ALPN on a TLS port, by the +/// connection preface on a plaintext one), and HTTP/3 on the endpoint bound with enableQuic. +/// TLS termination and the QUIC listener live in the other halves of this class. +/// +public sealed partial class IoxideServer : IServer { private readonly ServerConfiguration _config; @@ -28,6 +38,8 @@ public sealed class IoxideServer : IServer private readonly ushort[] _extraPorts; + private readonly IoxideEndPoint? _quicRequested; + private readonly Func? _configure; private readonly Action? _onReactorStart; @@ -38,6 +50,10 @@ public sealed class IoxideServer : IServer private readonly bool _kernelRx; + private readonly IoxideOptions _options; + + private readonly Nghttp3Options _h3Options; + private readonly ILogger _logger; private Thread[]? _threads; @@ -58,7 +74,9 @@ public sealed class IoxideServer : IServer public IHandler Handler { get; } - internal IoxideServer(ServerConfiguration config, IHandler handler, Func? configure = null, Action? onReactorStart = null, Func>? connectionFactory = null, bool kernelTx = false, bool kernelRx = false) + internal IoxideServer(ServerConfiguration config, IHandler handler, Func? configure = null, + Action? onReactorStart = null, Func>? connectionFactory = null, + bool kernelTx = false, bool kernelRx = false, IoxideOptions? options = null) { _config = config; Handler = handler; @@ -67,6 +85,13 @@ internal IoxideServer(ServerConfiguration config, IHandler handler, Func(); @@ -83,6 +108,17 @@ internal IoxideServer(ServerConfiguration config, IHandler handler, Func e.EnableQuic).ToList(); + + if (quic.Count > 1) + { + throw new NotSupportedException("The ioxide engine serves HTTP/3 on one endpoint; enableQuic is set on several."); + } + + _quicRequested = quic.Count == 1 ? _endPointByPort[quic[0].Port] : null; + // Certificates are resolved per reactor in OnStart, not here: the provider is queried for // its default (no-SNI) certificate then, and a port whose provider yields none is still // advertised as secure (so secure-upgrade redirects work) but serves no handshake. @@ -93,39 +129,6 @@ internal IoxideServer(ServerConfiguration config, IHandler handler, Func().ToList()); } - // The certificate for every secure port whose provider yields a default (no-SNI) certificate. - // Providers that select by SNI (unsupported here) return none and are skipped - the port stays - // advertised as secure but its handshakes are refused. - private IEnumerable> ResolveTls() - { - foreach (var (port, security) in _secure) - { - if (security.CertificateValidator is not null) - { - throw new NotSupportedException("Client certificate validation is not supported by the ioxide engine."); - } - - if (security.CertificateProvider.Provide(null) is not { } certificate) - { - _logger.LogWarning("No default certificate for secure port {Port}; handshakes there will be refused (SNI selection is unsupported).", port); - continue; - } - - yield return new(port, new TlsOptions - { - CertificatePem = certificate.ExportCertificatePem(), - KeyPem = ExportKeyPem(certificate), - KernelTx = _kernelTx, - KernelRx = _kernelRx - }); - } - } - - private static string ExportKeyPem(X509Certificate2 certificate) - => certificate.GetRSAPrivateKey()?.ExportPkcs8PrivateKeyPem() - ?? certificate.GetECDsaPrivateKey()?.ExportPkcs8PrivateKeyPem() - ?? throw new InvalidOperationException("The certificate carries no exportable RSA or ECDSA private key."); - public async ValueTask StartAsync() { await PrepareHandlerAsync(); @@ -151,6 +154,11 @@ public async ValueTask StartAsync() } }; + if (_quicRequested is { } quicEndPoint) + { + cfg = WithQuic(cfg, quicEndPoint); + } + _threads = new Thread[cfg.ReactorCount]; _reactors = new Reactor[cfg.ReactorCount]; @@ -183,7 +191,8 @@ public async ValueTask StartAsync() _onReactorStart?.Invoke(r); listening.Signal(); }, - TcpHandle = (_, c) => ConnectionDriver.HandleAsync(this, _endPointByPort[c.ListenerPort], c, _connectionFactory) + TcpHandle = (_, c) => ConnectionDriver.HandleAsync(this, _endPointByPort[c.ListenerPort], c, _connectionFactory, _options.Http2), + QuicHandle = _quic is not null ? (_, c) => Http3Driver.RunAsync(this, _quicEndPoint!, c, _h3Options) : null }; _reactors[i] = reactor; @@ -233,7 +242,19 @@ private async ValueTask PrepareHandlerAsync() } } - private string DescribeSettings() => $"ioxide, {_endPointByPort.Count} endpoint(s), TLS on {_secure.Count}, DualStack: {_primary.DualStack}, Reactors: {_reactors?.Length ?? 0}"; + private string DescribeSettings() + { + var protocols = _options.Http2 ? "HTTP/1.1+2" : "HTTP/1.1"; + + if (_quic is not null) + { + protocols += "+3"; + } + + return $"ioxide, {protocols}, {_endPointByPort.Count} endpoint(s), TLS on {_secure.Count}" + + (MutualTlsConfigured ? ", mTLS" : string.Empty) + + $", DualStack: {_primary.DualStack}, Reactors: {_reactors?.Length ?? 0}"; + } public async ValueTask DisposeAsync() { @@ -270,7 +291,8 @@ await Task.Run(() => } }); + DisposeQuic(); + _logger.LogInformation("Stopped ioxide reactors"); } - } diff --git a/Engine/Ioxide/Hosting/IoxideServerHost.cs b/Engine/Ioxide/Hosting/IoxideServerHost.cs index 76ce2dc90..5d1c4a99f 100644 --- a/Engine/Ioxide/Hosting/IoxideServerHost.cs +++ b/Engine/Ioxide/Hosting/IoxideServerHost.cs @@ -9,10 +9,10 @@ namespace GenHTTP.Engine.Ioxide.Hosting; -public sealed class IoxideServerHost(Func? configure = null, Action? onReactorStart = null, Func>? connectionFactory = null, bool kernelTx = false, bool kernelRx = false) : ServerHost +public sealed class IoxideServerHost(Func? configure = null, Action? onReactorStart = null, Func>? connectionFactory = null, bool kernelTx = false, bool kernelRx = false, IoxideOptions? options = null) : ServerHost { protected override IServer Build(ServerConfiguration config, IHandler handler) - => new IoxideServer(config, handler, configure, onReactorStart, connectionFactory, kernelTx, kernelRx); + => new IoxideServer(config, handler, configure, onReactorStart, connectionFactory, kernelTx, kernelRx, options); } diff --git a/Engine/Ioxide/IoxideOptions.cs b/Engine/Ioxide/IoxideOptions.cs new file mode 100644 index 000000000..c447dbbc3 --- /dev/null +++ b/Engine/Ioxide/IoxideOptions.cs @@ -0,0 +1,87 @@ +namespace GenHTTP.Engine.Ioxide; + +/// +/// Protocol and TLS options for the ioxide engine. +/// +/// +/// Endpoint-level settings stay on Bind where they already are: the port, its certificate, +/// whether it serves HTTP/3 (enableQuic) and whether it asks for a client certificate +/// (certificateValidator). What lives here is what the engine itself needs and GenHTTP's +/// endpoint model has nowhere to put. +/// +public sealed record IoxideOptions +{ + internal static readonly IoxideOptions Default = new(); + + /// + /// Serve HTTP/2. On a TLS endpoint the protocol is chosen by ALPN, so a client offering both + /// gets HTTP/2 and one offering only http/1.1 is unaffected. On a plaintext endpoint a + /// client opening with the HTTP/2 preface (h2c with prior knowledge) is served HTTP/2; the + /// Upgrade: dance is not implemented, which is what every deployed h2c client does. + /// + public bool Http2 { get; init; } + + /// + /// PEM certificate chain for the HTTP/3 listener, as a path. + /// + /// + /// QUIC is terminated by ngtcp2, which loads PEM from disk rather than taking a certificate + /// object. Setting this and hands it the files directly. + /// + /// Left null, the certificate bound to the endpoint is exported to a temporary file + /// instead - readable only by this user, and deleted on shutdown. That works, but it puts a + /// private key on disk for the lifetime of the process, so a deployment that already has PEM + /// files should name them here. + /// + public string? Http3CertificatePath { get; init; } + + /// PEM private key for the HTTP/3 listener. Pairs with . + public string? Http3KeyPath { get; init; } + + /// + /// PEM bundle of trust anchors that client certificates are validated against, as a path. + /// + /// + /// Mutual TLS is enforced where the connection is terminated - by OpenSSL for HTTP/1.1 and + /// HTTP/2, by ngtcp2 for HTTP/3 - so a chain that does not validate is refused before any + /// request exists. An endpoint bound with a certificateValidator asks for a certificate; + /// this is what the offered one is checked against. + /// + /// The file's subject names are also sent in the CertificateRequest, so a client holding + /// several certificates can pick the one this server accepts rather than guessing. + /// is trusted identically but sends no such hint. + /// + public string? ClientCaPath { get; init; } + + /// The client trust anchors as PEM text - the in-memory alternative to . + public string? ClientCaPem { get; init; } + + /// + /// Refuse a client that offers no certificate at all. + /// + /// + /// False asks for one and validates what arrives, but lets a client offering nothing through - + /// which is what a server serving both a public and a mutually authenticated route wants, since + /// it can read who connected and decide per request. True is the usual choice for a private API. + /// + /// An endpoint's certificateValidator can raise this on its own through + /// RequireCertificate; the two are ORed. + /// + public bool RequireClientCertificate { get; init; } + + /// + /// Bytes of QPACK dynamic table advertised to HTTP/3 clients. + /// + /// + /// 0 keeps every header literal against the static table, which costs bytes but can never stall + /// a stream waiting for a table update. Only browsers advertise a table of their own; every + /// other client measured sends 0, which makes the mechanism inert whatever is set here. + /// + public long QpackDynamicTableCapacity { get; init; } + + /// + /// How many HTTP/3 streams may wait on a QPACK table insertion. Only meaningful alongside a + /// nonzero , and the price paid for one. + /// + public long QpackBlockedStreams { get; init; } +} diff --git a/Engine/Ioxide/Protocol/ConnectionDriver.cs b/Engine/Ioxide/Protocol/ConnectionDriver.cs index 105dd75b7..ef0b14d31 100644 --- a/Engine/Ioxide/Protocol/ConnectionDriver.cs +++ b/Engine/Ioxide/Protocol/ConnectionDriver.cs @@ -5,6 +5,7 @@ using GenHTTP.Api.Infrastructure; using GenHTTP.Api.Protocol; +using GenHTTP.Engine.Ioxide.Protocol.Mux; using GenHTTP.Engine.Shared.Types; using Glyph11.Parser; @@ -87,10 +88,14 @@ internal static partial class ConnectionDriver private const int MaxPooledRequests = 1024; - internal static async Task HandleAsync(IServer server, IEndPoint endPoint, IoConnection conn, Func>? connectionFactory) + internal static async Task HandleAsync(IServer server, IEndPoint endPoint, IoConnection conn, Func>? connectionFactory, bool http2 = false) { IDuplexPipe pipe; + // What ALPN settled on, when the transport negotiated anything. Null on a plaintext port, + // and on a TLS port whose client offered nothing we serve. + string? negotiated = null; + try { if (connectionFactory is not null) @@ -109,7 +114,7 @@ internal static async Task HandleAsync(IServer server, IEndPoint endPoint, IoCon return; } - pipe = await IoxideTls.AcceptAsync(conn, service); + (pipe, negotiated) = await IoxideTls.AcceptWithAlpnAsync(conn, service); } else { @@ -129,6 +134,26 @@ internal static async Task HandleAsync(IServer server, IEndPoint endPoint, IoCon // The peer address is constant for the connection; resolve it once from the socket fd. var remoteAddress = GetPeerAddress(conn.ClientFd); + if (http2 && (negotiated == "h2" || (negotiated is null && await StartsWithPrefaceAsync(reader)))) + { + // HTTP/2 owns the connection from here: it multiplexes, so there is no request loop to + // run above it and nothing of the HTTP/1.1 path applies. + try + { + await Http2Driver.RunAsync(server, endPoint, pipe, remoteAddress, endPoint.Secure); + } + catch + { + // client or protocol fault - teardown happens below + } + finally + { + await CloseAsync(pipe, conn); + } + + return; + } + var request = RentRequest(); var into = request.Source; @@ -223,6 +248,56 @@ internal static async Task HandleAsync(IServer server, IEndPoint endPoint, IoCon } } + /// + /// Peeks for the HTTP/2 connection preface without consuming it, so a plaintext client using + /// prior knowledge (h2c) is recognised and the same bytes are handed to the HTTP/2 layer. + /// + private static async ValueTask StartsWithPrefaceAsync(PipeReader reader) + { + while (true) + { + var result = await reader.ReadAsync(); + var buffer = result.Buffer; + + if (buffer.Length >= Preface.Length) + { + Span head = stackalloc byte[Preface.Length]; + buffer.Slice(0, Preface.Length).CopyTo(head); + + // Nothing consumed AND nothing examined: marking these bytes examined would tell the + // pipe we are waiting for more, and whichever protocol reads next would block on data + // that has already arrived. + reader.AdvanceTo(buffer.Start, buffer.Start); + + return head.SequenceEqual(Preface.Span); + } + + // Too short to decide yet - examined to the end, so the next read waits for more. + reader.AdvanceTo(buffer.Start, buffer.End); + + if (result.IsCompleted) + { + return false; + } + } + } + + private static readonly ReadOnlyMemory Preface = "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"u8.ToArray(); + + private static async ValueTask CloseAsync(IDuplexPipe pipe, IoConnection conn) + { + await pipe.Input.CompleteAsync(); + await pipe.Output.CompleteAsync(); + + if (pipe is IAsyncDisposable disposable) + { + await disposable.DisposeAsync(); + } + + Shutdown(conn.ClientFd, ShutWrite); + conn.DecRef(); + } + private static bool TryParseRequest(ref ReadOnlySequence buffer, BinaryRequest into) => UsePico ? TryParseRequestPico(ref buffer, into) : TryParseRequestGlyph11(ref buffer, into); diff --git a/Engine/Ioxide/Protocol/Mux/Http2Driver.cs b/Engine/Ioxide/Protocol/Mux/Http2Driver.cs new file mode 100644 index 000000000..a8517d9a2 --- /dev/null +++ b/Engine/Ioxide/Protocol/Mux/Http2Driver.cs @@ -0,0 +1,89 @@ +using System.IO.Pipelines; +using System.Net; + +using GenHTTP.Api.Infrastructure; +using GenHTTP.Api.Protocol; + +using ioxide.http2; + +using Microsoft.Extensions.Logging; + +namespace GenHTTP.Engine.Ioxide.Protocol.Mux; + +/// +/// Serves an HTTP/2 connection: ioxide.http2 owns framing, HPACK and flow control, this maps each +/// request onto GenHTTP's handler chain. +/// +/// +/// Streamed both ways. A handler starts once the request headers have arrived, pulls the body as it +/// is delivered (paced by flow control, so an upload cannot outrun it) and writes its response into +/// a writer that frames each flush as a DATA frame - so a large download is never assembled in +/// memory and is paced by the peer's window. +/// +internal static class Http2Driver +{ + private static readonly ReadOnlyMemory Head = "HEAD"u8.ToArray(); + + private static readonly Http2Options Options = new() { StreamRequestBodies = true }; + + /// + /// Serves an HTTP/2 connection over an established transport: a TLS pipe that negotiated "h2" by + /// ALPN, or a plaintext pipe carrying h2c with prior knowledge. + /// + internal static Task RunAsync(IServer server, IEndPoint endPoint, IDuplexPipe pipe, IPAddress? remoteAddress, bool secure) + => new Http2Connection(pipe, Options) + .RunAsync((request, writer) => DispatchAsync(server, endPoint, request, writer, remoteAddress, secure)); + + private static async ValueTask DispatchAsync(IServer server, IEndPoint endPoint, Http2Request request, + Http2ResponseWriter writer, IPAddress? remoteAddress, bool secure) + { + try + { + var headers = new List<(ReadOnlyMemory Name, ReadOnlyMemory Value)>(request.Headers.Count); + + for (var i = 0; i < request.Headers.Count; i++) + { + var header = request.Headers[i]; + headers.Add((header.Key, header.Value)); + } + + var headRequest = request.Method.Span.SequenceEqual(Head.Span); + + var reader = request.BodyReader; + + await using var mapped = new MuxRequest(server, endPoint, request.Method, request.Path, request.Authority, + headers, reader is null ? null : reader.ReadAsync, remoteAddress, HttpProtocol.Http2, secure); + + var response = await server.Handler.HandleAsync(mapped) + ?? throw new InvalidOperationException("The root request handler did not return a response"); + + var data = MuxResponder.BuildHeaders(response); + + var head = new Http2Response { Status = data.Status }; + + foreach ((ReadOnlyMemory name, ReadOnlyMemory value) in data.Headers) + { + head.Headers.Add(name, value); + } + + writer.WriteHeaders(head); + + await MuxResponder.WriteBodyAsync(response, writer, writer.FlushAsync, headRequest); + } + catch (Exception e) + { + server.Logging.CreateLogger("GenHTTP.Engine.Ioxide.Protocol.Mux.Http2Driver") + .LogError(e, "Failed to handle HTTP/2 request"); + + if (!writer.IsCompleted) + { + writer.WriteHeaders(new Http2Response { Status = 500 }); + } + } + finally + { + // Ends the stream. Without it the peer waits for a body that is never coming. + await writer.CompleteAsync(); + } + } +} diff --git a/Engine/Ioxide/Protocol/Mux/Http3Driver.cs b/Engine/Ioxide/Protocol/Mux/Http3Driver.cs new file mode 100644 index 000000000..7af6c73e1 --- /dev/null +++ b/Engine/Ioxide/Protocol/Mux/Http3Driver.cs @@ -0,0 +1,90 @@ +using GenHTTP.Api.Infrastructure; +using GenHTTP.Api.Protocol; + +using ioxide; +using ioxide.nghttp3; + +using Microsoft.Extensions.Logging; + +namespace GenHTTP.Engine.Ioxide.Protocol.Mux; + +/// +/// Serves an HTTP/3 connection: ngtcp2 carries QUIC, nghttp3 carries HTTP/3 and QPACK, this maps +/// each request onto GenHTTP's handler chain. +/// +/// +/// Streamed both ways, as with HTTP/2. A response is written as it is produced and each flush parks +/// until the peer's window and the connection's send-retention high-water allow more, so serving a +/// large file costs about that high-water in memory rather than the size of the file. +/// +/// nghttp3 brings the parts that are laborious by hand - QPACK with a static-table encoder, +/// stream priorities, GOAWAY draining - and ngtcp2 brings QUIC itself. +/// +internal static class Http3Driver +{ + private static readonly ReadOnlyMemory Head = "HEAD"u8.ToArray(); + + /// + /// Serves one accepted QUIC connection until it closes. + /// + internal static Task RunAsync(IServer server, IEndPoint endPoint, QuicConnection connection, Nghttp3Options options) + => new Nghttp3Connection(connection, options) + .RunStreamedResponseAsync((request, writer) => DispatchAsync(server, endPoint, request, writer)); + + private static async ValueTask DispatchAsync(IServer server, IEndPoint endPoint, Nghttp3Request request, + Nghttp3ResponseWriter writer) + { + try + { + var headers = new List<(ReadOnlyMemory Name, ReadOnlyMemory Value)>(request.Headers.Count); + + for (var i = 0; i < request.Headers.Count; i++) + { + var header = request.Headers[i]; + headers.Add((header.Key, header.Value)); + } + + var headRequest = request.Method.Span.SequenceEqual(Head.Span); + + var reader = request.BodyReader; + + // Always secure: HTTP/3 runs over QUIC, which carries TLS 1.3 and has no cleartext mode. + // The client address stays null - ioxide's QuicConnection tracks the peer address (it has + // to, for path validation) but exposes no way to read it, and a QUIC peer may migrate + // mid-connection anyway. + await using var mapped = new MuxRequest(server, endPoint, request.Method, request.Path, request.Authority, + headers, reader is null ? null : reader.ReadAsync, remoteAddress: null, HttpProtocol.Http3, secure: true); + + var response = await server.Handler.HandleAsync(mapped) + ?? throw new InvalidOperationException("The root request handler did not return a response"); + + var data = MuxResponder.BuildHeaders(response); + + var head = new Nghttp3Response { Status = data.Status }; + + foreach ((ReadOnlyMemory name, ReadOnlyMemory value) in data.Headers) + { + head.Headers.Add(name, value); + } + + writer.WriteHeaders(head); + + await MuxResponder.WriteBodyAsync(response, writer, writer.FlushAsync, headRequest); + } + catch (Exception e) + { + server.Logging.CreateLogger("GenHTTP.Engine.Ioxide.Protocol.Mux.Http3Driver") + .LogError(e, "Failed to handle HTTP/3 request"); + + if (!writer.IsCompleted) + { + writer.WriteHeaders(new Nghttp3Response { Status = 500 }); + } + } + finally + { + // Ends the stream. Without it the peer waits for a body that is never coming. + await writer.CompleteAsync(); + } + } +} diff --git a/Engine/Ioxide/Protocol/Mux/MuxKeyValueList.cs b/Engine/Ioxide/Protocol/Mux/MuxKeyValueList.cs new file mode 100644 index 000000000..75fdbb16d --- /dev/null +++ b/Engine/Ioxide/Protocol/Mux/MuxKeyValueList.cs @@ -0,0 +1,29 @@ +using GenHTTP.Api.Protocol; + +namespace GenHTTP.Engine.Ioxide.Protocol.Mux; + +/// +/// Header and query lists over fields a multiplexed protocol has already decoded. +/// +/// +/// The shared list wraps Glyph11's parse output and so assumes HTTP/1.1. HPACK and QPACK hand over +/// name/value pairs instead, with no request line and no raw header block to point back at. +/// +internal sealed class MuxKeyValueList : IRequestHeaders, IRequestQuery +{ + private readonly List<(ReadOnlyMemory Name, ReadOnlyMemory Value)> _entries; + + internal MuxKeyValueList(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/Ioxide/Protocol/Mux/MuxRequest.cs b/Engine/Ioxide/Protocol/Mux/MuxRequest.cs new file mode 100644 index 000000000..580ebd99b --- /dev/null +++ b/Engine/Ioxide/Protocol/Mux/MuxRequest.cs @@ -0,0 +1,124 @@ +using System.IO.Pipelines; +using System.Net; + +using GenHTTP.Api.Infrastructure; +using GenHTTP.Api.Protocol; +using GenHTTP.Engine.Shared.Types; + +namespace GenHTTP.Engine.Ioxide.Protocol.Mux; + +/// +/// An over a request decoded by HPACK or QPACK. +/// +/// +/// Not the shared , whose Source is a Glyph11 BinaryRequest and therefore +/// assumes an HTTP/1.1 parse off a pipe. Nothing here is pooled: both protocols multiplex, so +/// several of these are live on one connection at once and a per-connection pool would need locking +/// to be safe - which is exactly what the reactor model is trying to avoid. +/// +internal sealed class MuxRequest : IRequest +{ + private readonly MuxRequestBody? _body; + + private readonly ClientConnection _client = new(); + + private readonly PropertyBag _properties = new(); + + private readonly ResponseBuilder _response = new(); + + private Func? _bodyWrapper; + + private bool _bodyFetched; + + internal MuxRequest(IServer server, IEndPoint endPoint, ReadOnlyMemory method, ReadOnlyMemory path, + ReadOnlyMemory authority, List<(ReadOnlyMemory Name, ReadOnlyMemory Value)> headers, + Func>>? read, IPAddress? remoteAddress, HttpProtocol protocol, bool secure) + { + Server = server; + EndPoint = endPoint; + + Header = new MuxRequestHeader(method, path, authority, headers, ParseQuery(path), protocol); + + _body = read is null ? null : new MuxRequestBody(read); + + _client.Apply(remoteAddress, secure ? ClientProtocol.Https : ClientProtocol.Http, 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 _bodyWrapper is not null ? _bodyWrapper(_body) : _body; + } + + public void WrapBody(Func wrapper) => _bodyWrapper = wrapper; + + public IResponseBuilder Respond() => _response.Status(ResponseStatus.Ok); + + /// + /// Not supported. Upgrading to a raw byte stream is an HTTP/1.1 mechanism; a multiplexed + /// protocol reaches its streams through the transport rather than through a request. + /// + public PipeReader Upgrade() + => throw new NotSupportedException("Connection upgrades are not available over HTTP/2 or HTTP/3."); + + public ValueTask DisposeAsync() => new(); + + // The query string, which both protocols carry 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)>(); + + var mark = path.Span.IndexOf((byte)'?'); + + if (mark < 0) + { + return parameters; + } + + var query = path[(mark + 1)..]; + + while (!query.IsEmpty) + { + var end = query.Span.IndexOf((byte)'&'); + + var pair = end < 0 ? query : query[..end]; + + query = end < 0 ? default : query[(end + 1)..]; + + if (pair.IsEmpty) + { + continue; + } + + var equals = pair.Span.IndexOf((byte)'='); + + parameters.Add(equals < 0 + ? (pair, ReadOnlyMemory.Empty) + : (pair[..equals], pair[(equals + 1)..])); + } + + return parameters; + } +} diff --git a/Engine/Ioxide/Protocol/Mux/MuxRequestBody.cs b/Engine/Ioxide/Protocol/Mux/MuxRequestBody.cs new file mode 100644 index 000000000..e0a5dcdf9 --- /dev/null +++ b/Engine/Ioxide/Protocol/Mux/MuxRequestBody.cs @@ -0,0 +1,122 @@ +using GenHTTP.Api.Protocol; + +namespace GenHTTP.Engine.Ioxide.Protocol.Mux; + +/// +/// A request body pulled from the protocol layer as it arrives. +/// +/// +/// Both protocols dispatch at end-of-headers under streamed dispatch, so the body is still in flight +/// when the handler starts. Reads are paced by flow control: an upload cannot outrun the handler, +/// because the window only reopens as chunks are consumed. +/// +/// The read delegate abstracts the two body readers, which have the same shape but come from +/// different packages. It returns empty once the request stream has ended. +/// +internal sealed class MuxRequestBody : IRequestBody +{ + private readonly Func>> _read; + + internal MuxRequestBody(Func>> read) + { + _read = read; + } + + public Stream AsStream() => new PullStream(_read); + + public async ValueTask> AsMemoryAsync() + { + // Assembling defeats the point of streaming, so this only runs when a handler asks for the + // whole body - which some do, and which has to keep working. + var assembled = new MemoryStream(); + + while (true) + { + var chunk = await _read(); + + if (chunk.IsEmpty) + { + break; + } + + assembled.Write(chunk.Span); + } + + return assembled.ToArray(); + } + + /// + /// Presents the pull-based reader as a forward-only stream. + /// + private sealed class PullStream : Stream + { + private readonly Func>> _read; + + private ReadOnlyMemory _current; + + private bool _ended; + + private long _position; + + internal PullStream(Func>> read) + { + _read = read; + } + + public override bool CanRead => true; + + public override bool CanSeek => false; + + public override bool CanWrite => false; + + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => _position; + set => throw new NotSupportedException(); + } + + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + if (_current.IsEmpty && !_ended) + { + _current = await _read(); + + if (_current.IsEmpty) + { + _ended = true; + } + } + + if (_current.IsEmpty) + { + return 0; + } + + var take = Math.Min(buffer.Length, _current.Length); + + _current[..take].CopyTo(buffer); + _current = _current[take..]; + _position += take; + + return take; + } + + public override async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + => await ReadAsync(buffer.AsMemory(offset, count), cancellationToken); + + // Synchronous reads would have to block the reactor thread waiting for a chunk that only + // arrives when that same thread pumps the connection - a guaranteed deadlock. + public override int Read(byte[] buffer, int offset, int count) + => throw new NotSupportedException("The request body must be read asynchronously."); + + public override void Flush() { } + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + + public override void SetLength(long value) => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + } +} diff --git a/Engine/Ioxide/Protocol/Mux/MuxRequestHeader.cs b/Engine/Ioxide/Protocol/Mux/MuxRequestHeader.cs new file mode 100644 index 000000000..8e3acc7c1 --- /dev/null +++ b/Engine/Ioxide/Protocol/Mux/MuxRequestHeader.cs @@ -0,0 +1,117 @@ +using GenHTTP.Api.Protocol; +using GenHTTP.Engine.Shared.Types; + +namespace GenHTTP.Engine.Ioxide.Protocol.Mux; + +/// +/// An over the pseudo-headers a multiplexed protocol carries. +/// +internal sealed class MuxRequestHeader : IRequestHeader +{ + private static readonly ReadOnlyMemory HostName = "host"u8.ToArray(); + + private readonly MuxKeyValueList _headers; + + private readonly MuxKeyValueList _query; + + private readonly RequestTarget _target; + + internal MuxRequestHeader(ReadOnlyMemory method, ReadOnlyMemory path, ReadOnlyMemory authority, + List<(ReadOnlyMemory Name, ReadOnlyMemory Value)> headers, + List<(ReadOnlyMemory Name, ReadOnlyMemory Value)> query, HttpProtocol protocol) + { + _headers = new MuxKeyValueList(WithHost(headers, authority)); + _query = new MuxKeyValueList(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 carrying a query 404s. + Path = new ByteString(WithoutQuery(path)); + Method = new RequestMethod(method); + + Protocol = protocol; + Version = protocol == HttpProtocol.Http3 ? Http3Version : Http2Version; + + _target.Apply(Path); + } + + public RequestMethod Method { get; } + + public ByteString Path { get; } + + public IRequestTarget Target => _target; + + // Settled before a byte of the request arrived - by ALPN for HTTP/2, by QUIC plus ALPN for + // HTTP/3 - so there is no version token on the wire to read. + public HttpProtocol Protocol { get; } + + public ReadOnlyMemory Version { get; } + + public IRequestHeaders Headers => _headers; + + public IRequestQuery Query => _query; + + /// + /// HTTP/2 and HTTP/3 carry the authority as the :authority pseudo-header, and clients omit Host + /// entirely. RFC 9113 8.3.1 and RFC 9114 4.3.1 have an intermediary translating to HTTP/1.1 + /// construct Host from it, which is what this does: everything above the engine - routing, + /// virtual hosting, redirects - expects a Host header to exist. + /// + private static List<(ReadOnlyMemory Name, ReadOnlyMemory Value)> WithHost( + List<(ReadOnlyMemory Name, ReadOnlyMemory Value)> headers, ReadOnlyMemory authority) + { + if (authority.IsEmpty) + { + return headers; + } + + foreach ((ReadOnlyMemory name, ReadOnlyMemory _) in headers) + { + if (name.Length == 4 && Matches(name.Span, "host"u8)) + { + return headers; + } + } + + var result = new List<(ReadOnlyMemory, ReadOnlyMemory)>(headers.Count + 1) + { + (HostName, authority), + }; + + result.AddRange(headers); + + return result; + } + + private static bool Matches(ReadOnlySpan name, ReadOnlySpan lowercase) + { + for (var i = 0; i < name.Length; i++) + { + var 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) + { + var mark = path.Span.IndexOf((byte)'?'); + + return mark < 0 ? path : path[..mark]; + } + + private static readonly ReadOnlyMemory Http2Version = "HTTP/2.0"u8.ToArray(); + + private static readonly ReadOnlyMemory Http3Version = "HTTP/3.0"u8.ToArray(); +} diff --git a/Engine/Ioxide/Protocol/Mux/MuxResponder.cs b/Engine/Ioxide/Protocol/Mux/MuxResponder.cs new file mode 100644 index 000000000..11acc025c --- /dev/null +++ b/Engine/Ioxide/Protocol/Mux/MuxResponder.cs @@ -0,0 +1,156 @@ +using System.Buffers; +using System.Buffers.Text; + +using GenHTTP.Api.Protocol; + +namespace GenHTTP.Engine.Ioxide.Protocol.Mux; + +/// +/// The status and fields of a response, ready to be handed to a protocol layer. +/// +/// +/// Neutral on purpose. HTTP/2 and HTTP/3 want the same thing, but their response types come from +/// different packages, so each driver builds its own from this. +/// +internal readonly struct MuxResponseData +{ + internal MuxResponseData(int status, List<(ReadOnlyMemory Name, ReadOnlyMemory Value)> headers) + { + Status = status; + Headers = headers; + } + + internal int Status { get; } + + internal List<(ReadOnlyMemory Name, ReadOnlyMemory Value)> Headers { get; } +} + +/// +/// Maps a GenHTTP onto what a multiplexed protocol submits. +/// +internal static class MuxResponder +{ + private static readonly ReadOnlyMemory ContentTypeName = "content-type"u8.ToArray(); + + private static readonly ReadOnlyMemory ContentEncodingName = "content-encoding"u8.ToArray(); + + private static readonly ReadOnlyMemory ContentLengthName = "content-length"u8.ToArray(); + + private static readonly ReadOnlyMemory ServerName = "server"u8.ToArray(); + + private static readonly ReadOnlyMemory ServerValue = "ioxide-genhttp"u8.ToArray(); + + /// + /// Builds the field section. Does not touch the content, which is streamed afterwards. + /// + internal static MuxResponseData BuildHeaders(IResponse response) + { + var headers = new List<(ReadOnlyMemory Name, ReadOnlyMemory Value)>(response.Headers.Count + 4); + + for (var i = 0; i < response.Headers.Count; i++) + { + var header = response.Headers.GetMemoryEntry(i); + + // Connection-specific fields are malformed in HTTP/2 and HTTP/3 (RFC 9113 8.2.2, + // RFC 9114 4.2) - a peer may treat one as a protocol error rather than ignore it. + // Names are passed through as they are: both ioxide layers lowercase as they pack. + if (!IsConnectionSpecific(header.Key.Span)) + { + headers.Add((header.Key, header.Value)); + } + } + + headers.Add((ServerName, ServerValue)); + + if (response.Content is { } content) + { + if (content.Type is { } type) + { + headers.Add((ContentTypeName, type.Bytes)); + } + + if (content.Encoding is { } encoding) + { + headers.Add((ContentEncodingName, encoding)); + } + + // A streamed response has no length by the time its headers go out, so neither layer + // fills this in - unlike their buffered paths, which know the body up front. Send it + // when the content does know, which is every static file and every fixed page. + if (content.Length is { } length) + { + headers.Add((ContentLengthName, Digits(length))); + } + } + + return new MuxResponseData((int)response.Status, headers); + } + + /// + /// Streams the content into the protocol's response writer. + /// + internal static async ValueTask WriteBodyAsync(IResponse response, IBufferWriter writer, Func flush, bool headRequest) + { + var content = response.Content; + + if (content is null) + { + return; + } + + try + { + // A HEAD response keeps the headers its GET would have produced and sends no body. + if (!headRequest) + { + await content.WriteAsync(new MuxSink(writer, flush)); + } + } + finally + { + if (content is IDisposable disposable) + { + disposable.Dispose(); + } + } + } + + private static ReadOnlyMemory Digits(ulong value) + { + var buffer = new byte[20]; + + Utf8Formatter.TryFormat(value, buffer, out var written); + + return buffer.AsMemory(0, written); + } + + 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, "server"u8) + || Matches(name, "content-length"u8); + + private static bool Matches(ReadOnlySpan name, ReadOnlySpan lowercase) + { + if (name.Length != lowercase.Length) + { + return false; + } + + for (var i = 0; i < name.Length; i++) + { + var c = name[i]; + + if (c is >= (byte)'A' and <= (byte)'Z') + { + c += 32; + } + + if (c != lowercase[i]) + { + return false; + } + } + + return true; + } +} diff --git a/Engine/Ioxide/Protocol/Mux/MuxSink.cs b/Engine/Ioxide/Protocol/Mux/MuxSink.cs new file mode 100644 index 000000000..34965fc56 --- /dev/null +++ b/Engine/Ioxide/Protocol/Mux/MuxSink.cs @@ -0,0 +1,100 @@ +using System.Buffers; + +using GenHTTP.Api.Protocol; + +namespace GenHTTP.Engine.Ioxide.Protocol.Mux; + +/// +/// Writes response content straight into a protocol response writer. +/// +/// +/// Both protocol writers are themselves , so content that writes +/// through the buffer channel goes to the wire with nothing in between. +/// +/// The stream channel flushes on every write, which is what makes a large download bounded: +/// a flush parks until the peer's window and the connection's send retention allow more, so the +/// await is the backpressure. Content that writes through the buffer channel instead is flushed +/// once, when it finishes - fine for a page, and the reason file content should use the stream. +/// +internal sealed class MuxSink : IResponseSink +{ + private readonly IBufferWriter _writer; + + private readonly Func _flush; + + private Stream? _stream; + + internal MuxSink(IBufferWriter writer, Func flush) + { + _writer = writer; + _flush = flush; + } + + public IBufferWriter Writer => _writer; + + public Stream Stream => _stream ??= new FlushingStream(_writer, _flush); + + /// + /// Adapts the protocol writer to the stream channel, flushing each write so the content is + /// paced by the peer rather than accumulated. + /// + private sealed class FlushingStream : Stream + { + private readonly IBufferWriter _target; + + private readonly Func _flush; + + private long _written; + + internal FlushingStream(IBufferWriter target, Func flush) + { + _target = target; + _flush = flush; + } + + public override bool CanRead => false; + + public override bool CanSeek => false; + + public override bool CanWrite => true; + + public override long Length => _written; + + public override long Position + { + get => _written; + 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) + { + // Synchronous write: the bytes are staged, but the flush that paces them cannot happen + // here. Content that writes large bodies should use the async path. + _target.Write(buffer); + _written += buffer.Length; + } + + public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + _target.Write(buffer.Span); + _written += buffer.Length; + + await _flush(); + } + + public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + => await WriteAsync(buffer.AsMemory(offset, count), cancellationToken); + + public override void Flush() { } + + public override async Task FlushAsync(CancellationToken cancellationToken) => await _flush(); + + 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/Ioxide/Server.cs b/Engine/Ioxide/Server.cs index 3bfc619c9..1fdf43b7c 100644 --- a/Engine/Ioxide/Server.cs +++ b/Engine/Ioxide/Server.cs @@ -42,7 +42,12 @@ public static class Host /// experimental; it requires (RX shares the ULP handoff TX installs, /// so ioxide refuses RX alone) and a peer that sends no post-handshake control records. /// - public static IServerHost Create(Func? configure = null, Action? onReactorStart = null, Func>? connectionFactory = null, bool kernelTx = false, bool kernelRx = false) - => new IoxideServerHost(configure, onReactorStart, connectionFactory, kernelTx, kernelRx); + /// + /// Protocol and TLS options for the engine: HTTP/2, the HTTP/3 certificate, mutual TLS and + /// QPACK. Endpoint-level settings stay on Bind - the port, its certificate, whether it + /// serves HTTP/3 (enableQuic) and whether it asks for a client certificate. + /// + public static IServerHost Create(Func? configure = null, Action? onReactorStart = null, Func>? connectionFactory = null, bool kernelTx = false, bool kernelRx = false, IoxideOptions? options = null) + => new IoxideServerHost(configure, onReactorStart, connectionFactory, kernelTx, kernelRx, options); } diff --git a/Engine/Ioxide/Tls/IoxideTls.cs b/Engine/Ioxide/Tls/IoxideTls.cs index d0b95483e..ad06e4875 100644 --- a/Engine/Ioxide/Tls/IoxideTls.cs +++ b/Engine/Ioxide/Tls/IoxideTls.cs @@ -24,10 +24,18 @@ public static async ValueTask AcceptAsync(TcpConnection conn) => await AcceptAsync(conn, IoxideReactor.Current.GetService()); internal static async ValueTask AcceptAsync(TcpConnection conn, TlsService service) + => (await AcceptWithAlpnAsync(conn, service)).Pipe; + + /// + /// Terminates TLS and reports what ALPN settled on, which is how a port serving several + /// protocols knows which one this connection speaks. Null means the client offered nothing this + /// port lists, in which case it continues without an ALPN extension and HTTP/1.1 is assumed. + /// + internal static async ValueTask<(IDuplexPipe Pipe, string? Protocol)> AcceptWithAlpnAsync(TcpConnection conn, TlsService service) { var session = await service.AcceptAsync(conn); - return new TlsConnectionDualPipe(conn, session); + return (new TlsConnectionDualPipe(conn, session), session.NegotiatedAlpn); } } diff --git a/Modules/IoxideFiles/GenHTTP.Modules.IoxideFiles.csproj b/Modules/IoxideFiles/GenHTTP.Modules.IoxideFiles.csproj index 5722f1a7a..9dedc5ea6 100644 --- a/Modules/IoxideFiles/GenHTTP.Modules.IoxideFiles.csproj +++ b/Modules/IoxideFiles/GenHTTP.Modules.IoxideFiles.csproj @@ -19,7 +19,7 @@ - + diff --git a/Playground/Program.cs b/Playground/Program.cs index 925868c3a..7b4516434 100644 --- a/Playground/Program.cs +++ b/Playground/Program.cs @@ -1,3 +1,7 @@ +using System.Net; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + using GenHTTP.Engine.Ioxide; using GenHTTP.Modules.Files; @@ -7,6 +11,24 @@ // The namespace and the class share a name, so the class needs an alias to be reachable. using IoxideFilesModule = GenHTTP.Modules.IoxideFiles.IoxideFiles; +// One host, four protocols: +// +// http://localhost:8080 HTTP/1.1, and HTTP/2 without TLS (h2c) for a client that opens with +// the HTTP/2 preface. Both share the port - the first bytes decide. +// https://localhost:8443 HTTP/1.1 or HTTP/2, chosen by ALPN during the handshake, plus +// HTTP/3 on the same number over UDP. +// +// dotnet run -c Release --project Playground +// +// curl http://localhost:8080/ok +// curl --http2-prior-knowledge http://localhost:8080/ok +// curl -k --http2 https://localhost:8443/ok +// curl -k --http3-only https://localhost:8443/ok +// +// Browsers never try HTTP/3 first. They connect over TCP and only move to QUIC once a response has +// told them where to look, so a browser-facing deployment adds an Alt-Svc header pointing at the +// HTTP/3 port. curl reaches it directly with --http3-only, which is why the sample needs nothing. +// // Two static handlers over the SAME directory, so the difference can be priced rather than argued: // // /ring/* IoxideFiles - ioxide.file opens every file once, shares the descriptors across @@ -31,6 +53,54 @@ .Add("disk", Assets.From(staticDir)); } -await Host.Create() +// A throwaway certificate so the sample runs with no setup. Point GENHTTP_CERT at a PKCS#12 bundle +// to serve a real one - a browser will refuse HTTP/3 to a certificate it does not trust. +using var certificate = LoadCertificate(); + +// One io_uring reactor per core by default. GENHTTP_REACTORS lowers it, which is what a benchmark +// wants: a server sized to every core leaves none for the load generator, and the run then measures +// the generator rather than the server. +var reactors = int.TryParse(Environment.GetEnvironmentVariable("GENHTTP_REACTORS"), out var r) ? r : Environment.ProcessorCount; + +await Host.Create( + configure: c => c with { ReactorCount = reactors }, + options: new IoxideOptions + { + // HTTP/2 over TLS (ALPN prefers it) and, without TLS, for a client that opens + // with the HTTP/2 preface. HTTP/1.1 clients are unaffected either way. + Http2 = true, + + // Bytes of QPACK dynamic table offered to HTTP/3 clients. 0 keeps every header + // literal, which costs bytes but can never stall a stream on a table update. In + // practice only browsers advertise a table of their own. + QpackDynamicTableCapacity = 4096, + QpackBlockedStreams = 100, + }) .Handler(app) + .Bind(IPAddress.Loopback, 8080) + // enableQuic adds the HTTP/3 listener on the same port number over UDP. + .Bind(IPAddress.Loopback, 8443, certificate, enableQuic: true) .RunAsync(); + +static X509Certificate2 LoadCertificate() +{ + if (Environment.GetEnvironmentVariable("GENHTTP_CERT") is { Length: > 0 } path && File.Exists(path)) + { + return X509CertificateLoader.LoadPkcs12FromFile(path, Environment.GetEnvironmentVariable("GENHTTP_CERT_PASSWORD")); + } + + using var key = RSA.Create(2048); + + var request = new CertificateRequest("CN=localhost", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + + // Without a subject alternative name nothing modern will verify this, only skip the check. + 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 PKCS#12 round trip before a TLS stack will use it. + return X509CertificateLoader.LoadPkcs12(generated.Export(X509ContentType.Pfx), null); +} From a0e409cbdd43dfaae470d82e64725f0ba050c6e9 Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Sun, 16 Aug 2026 09:43:37 +0100 Subject: [PATCH 09/55] feat(ioxide): protocols are configured per port Http2 was a flag in the engine options while HTTP/3 was enableQuic on the endpoint, so the two protocols were configured in different places and neither could be given a port of its own. Protocols are a property of an endpoint, so they are set per endpoint now: Protocols = IoxideProtocols.Http1, // what a port serves by default ProtocolsByPort = { [8081] = IoxideProtocols.Http2, // h2c only, no HTTP/1.1 here [8443] = IoxideProtocols.All, // h1 + h2 over TCP, h3 over UDP } HTTP/1.1 and HTTP/2 share the TCP socket - ALPN decides on a secure endpoint, the connection preface on a plaintext one - and HTTP/3 is a UDP socket on the same port number, so one port can serve all three or each can have its own. A port that serves neither HTTP/1.1 nor HTTP/2 would otherwise accept TCP connections and answer nothing, so HTTP/1.1 is served there instead. The set is now honoured rather than advisory: an HTTP/2-only port closes a connection that is not HTTP/2, where before it quietly answered HTTP/1.1. HTTP/3 in the DEFAULT set applies only to endpoints that can serve it, since QUIC carries TLS 1.3 and a plaintext port cannot - so Protocols = All reads as "everything each port supports" rather than failing over the plaintext one. Named explicitly for a port it is taken literally. Asking two endpoints for HTTP/3 is still refused, but the message now names the ports and what to do about it. enableQuic on Bind keeps working and still means HTTP/3 for that endpoint. Acceptance suite 1442/1442; mutual TLS still enforced on all three protocols. --- Engine/Ioxide/Hosting/IoxideServer.Tls.cs | 2 +- Engine/Ioxide/Hosting/IoxideServer.cs | 86 ++++++++++++++++++---- Engine/Ioxide/IoxideOptions.cs | 49 +++++++++--- Engine/Ioxide/IoxideProtocols.cs | 40 ++++++++++ Engine/Ioxide/Protocol/ConnectionDriver.cs | 27 ++++++- Playground/Program.cs | 42 ++++++++--- 6 files changed, 208 insertions(+), 38 deletions(-) create mode 100644 Engine/Ioxide/IoxideProtocols.cs diff --git a/Engine/Ioxide/Hosting/IoxideServer.Tls.cs b/Engine/Ioxide/Hosting/IoxideServer.Tls.cs index 03b9fa548..ee766fe5f 100644 --- a/Engine/Ioxide/Hosting/IoxideServer.Tls.cs +++ b/Engine/Ioxide/Hosting/IoxideServer.Tls.cs @@ -38,7 +38,7 @@ private IEnumerable> ResolveTls() // Server preference, most preferred first: a client offering both gets HTTP/2, one // offering only http/1.1 is unaffected, and one offering neither continues without // an ALPN extension at all. - Alpn = _options.Http2 ? ["h2", "http/1.1"] : ["http/1.1"], + Alpn = ProtocolsFor(port).HasFlag(IoxideProtocols.Http2) ? ["h2", "http/1.1"] : ["http/1.1"], ClientCaPath = _options.ClientCaPath, ClientCaPem = _options.ClientCaPem, diff --git a/Engine/Ioxide/Hosting/IoxideServer.cs b/Engine/Ioxide/Hosting/IoxideServer.cs index 814afa417..2caf539d8 100644 --- a/Engine/Ioxide/Hosting/IoxideServer.cs +++ b/Engine/Ioxide/Hosting/IoxideServer.cs @@ -21,10 +21,12 @@ namespace GenHTTP.Engine.Ioxide.Hosting; /// Hosts an application on ioxide's io_uring reactors. /// /// -/// One reactor per core, each owning a ring and its connections on its own thread. Protocol -/// selection is per endpoint: HTTP/1.1 always, HTTP/2 when enabled (by ALPN on a TLS port, by the -/// connection preface on a plaintext one), and HTTP/3 on the endpoint bound with enableQuic. -/// TLS termination and the QUIC listener live in the other halves of this class. +/// One reactor per core, each owning a ring and its connections on its own thread. +/// +/// Protocols are per port. HTTP/1.1 and HTTP/2 share a TCP socket - ALPN decides on a secure +/// endpoint, the connection preface on a plaintext one - and HTTP/3 is a UDP socket on the same +/// port number, so one endpoint can serve all three or each can have a port of its own. TLS +/// termination and the QUIC listener live in the other halves of this class. /// public sealed partial class IoxideServer : IServer { @@ -40,6 +42,8 @@ public sealed partial class IoxideServer : IServer private readonly IoxideEndPoint? _quicRequested; + private readonly Dictionary _protocols; + private readonly Func? _configure; private readonly Action? _onReactorStart; @@ -108,16 +112,22 @@ internal IoxideServer(ServerConfiguration config, IHandler handler, Func e.Port, e => ResolveProtocols(_options, config, e.Port)); + // One QUIC listener: the transport binds a single UDP port for the whole server, so several // endpoints asking for HTTP/3 would each want their own and only the first could have it. - var quic = config.EndPoints.Where(e => e.EnableQuic).ToList(); + var quic = mapped.Where(e => _protocols[e.Port].HasFlag(IoxideProtocols.Http3)).ToList(); if (quic.Count > 1) { - throw new NotSupportedException("The ioxide engine serves HTTP/3 on one endpoint; enableQuic is set on several."); + throw new NotSupportedException( + $"The ioxide engine binds one QUIC listener, but HTTP/3 was requested on ports {string.Join(", ", quic.Select(e => e.Port))}. " + + "Name the protocols per port (ProtocolsByPort) so only one of them serves HTTP/3."); } - _quicRequested = quic.Count == 1 ? _endPointByPort[quic[0].Port] : null; + _quicRequested = quic.Count == 1 ? quic[0] : null; // Certificates are resolved per reactor in OnStart, not here: the provider is queried for // its default (no-SNI) certificate then, and a port whose provider yields none is still @@ -129,6 +139,46 @@ internal IoxideServer(ServerConfiguration config, IHandler handler, Func().ToList()); } + /// + /// What one port serves: the default, its override, and the endpoint's own enableQuic flag. + /// + /// + /// A port with neither HTTP/1.1 nor HTTP/2 still has a TCP listener, because binding the + /// endpoint is what created it - so HTTP/1.1 is served there rather than accepting connections + /// and answering nothing. + /// + private static IoxideProtocols ResolveProtocols(IoxideOptions options, ServerConfiguration config, ushort port) + { + var named = options.ProtocolsByPort.TryGetValue(port, out var configured); + + var protocols = named ? configured : options.Protocols; + + // HTTP/3 from the DEFAULT applies only where it can: QUIC carries TLS 1.3, so a plaintext + // port cannot serve it. Writing Protocols = All then means "everything each port supports" + // rather than an error about the one without a certificate. Named per port it is taken + // literally, and refused loudly below if the port cannot serve it. + if (!named && protocols.HasFlag(IoxideProtocols.Http3) && config.EndPoints.All(e => e.Port != port || e.Security is null)) + { + protocols &= ~IoxideProtocols.Http3; + } + + if (config.EndPoints.Any(e => e.Port == port && e.EnableQuic)) + { + protocols |= IoxideProtocols.Http3; + } + + if ((protocols & IoxideProtocols.Http1AndHttp2) == 0) + { + protocols |= IoxideProtocols.Http1; + } + + return protocols; + } + + /// The protocols this port serves. + private IoxideProtocols ProtocolsFor(ushort port) + => _protocols.TryGetValue(port, out var protocols) ? protocols : IoxideProtocols.Http1; + public async ValueTask StartAsync() { await PrepareHandlerAsync(); @@ -191,7 +241,7 @@ public async ValueTask StartAsync() _onReactorStart?.Invoke(r); listening.Signal(); }, - TcpHandle = (_, c) => ConnectionDriver.HandleAsync(this, _endPointByPort[c.ListenerPort], c, _connectionFactory, _options.Http2), + TcpHandle = (_, c) => ConnectionDriver.HandleAsync(this, _endPointByPort[c.ListenerPort], c, _connectionFactory, ProtocolsFor(c.ListenerPort)), QuicHandle = _quic is not null ? (_, c) => Http3Driver.RunAsync(this, _quicEndPoint!, c, _h3Options) : null }; @@ -244,18 +294,24 @@ private async ValueTask PrepareHandlerAsync() private string DescribeSettings() { - var protocols = _options.Http2 ? "HTTP/1.1+2" : "HTTP/1.1"; - - if (_quic is not null) - { - protocols += "+3"; - } + var protocols = string.Join(" ", _protocols.OrderBy(p => p.Key).Select(p => $"{p.Key}:{Describe(p.Value)}")); - return $"ioxide, {protocols}, {_endPointByPort.Count} endpoint(s), TLS on {_secure.Count}" + return $"ioxide, {protocols}, TLS on {_secure.Count}" + (MutualTlsConfigured ? ", mTLS" : string.Empty) + $", DualStack: {_primary.DualStack}, Reactors: {_reactors?.Length ?? 0}"; } + private static string Describe(IoxideProtocols protocols) + { + var names = new List(3); + + if (protocols.HasFlag(IoxideProtocols.Http1)) names.Add("h1"); + if (protocols.HasFlag(IoxideProtocols.Http2)) names.Add("h2"); + if (protocols.HasFlag(IoxideProtocols.Http3)) names.Add("h3"); + + return string.Join("+", names); + } + public async ValueTask DisposeAsync() { Running = false; diff --git a/Engine/Ioxide/IoxideOptions.cs b/Engine/Ioxide/IoxideOptions.cs index c447dbbc3..50474aa7a 100644 --- a/Engine/Ioxide/IoxideOptions.cs +++ b/Engine/Ioxide/IoxideOptions.cs @@ -4,22 +4,53 @@ namespace GenHTTP.Engine.Ioxide; /// Protocol and TLS options for the ioxide engine. /// /// -/// Endpoint-level settings stay on Bind where they already are: the port, its certificate, -/// whether it serves HTTP/3 (enableQuic) and whether it asks for a client certificate -/// (certificateValidator). What lives here is what the engine itself needs and GenHTTP's -/// endpoint model has nowhere to put. +/// The port, its certificate and whether it asks for a client certificate stay on Bind, +/// where GenHTTP already puts them. Which protocols that port then serves lives here, because +/// GenHTTP's endpoint model has nowhere to put it. /// public sealed record IoxideOptions { internal static readonly IoxideOptions Default = new(); /// - /// Serve HTTP/2. On a TLS endpoint the protocol is chosen by ALPN, so a client offering both - /// gets HTTP/2 and one offering only http/1.1 is unaffected. On a plaintext endpoint a - /// client opening with the HTTP/2 preface (h2c with prior knowledge) is served HTTP/2; the - /// Upgrade: dance is not implemented, which is what every deployed h2c client does. + /// The protocols every endpoint serves, unless says otherwise. /// - public bool Http2 { get; init; } + /// + /// Defaults to HTTP/1.1 alone. Http1AndHttp2 lets the two share a port - ALPN picks on a + /// secure endpoint, the connection preface on a plaintext one - and adding Http3 binds + /// the same port number over UDP as well. + /// + /// An endpoint bound with enableQuic serves HTTP/3 whatever is set here, so code + /// already using that flag keeps working. + /// + public IoxideProtocols Protocols { get; init; } = IoxideProtocols.Http1; + + /// + /// Protocols for one port, overriding . + /// + /// + /// This is how endpoints get different protocols: bind the ports, then name the ones that differ. + /// + /// + /// .Bind(IPAddress.Any, 8080) // HTTP/1.1 + /// .Bind(IPAddress.Any, 8081) // HTTP/2 only, h2c + /// .Bind(IPAddress.Any, 8443, certificate) // all three + /// + /// new IoxideOptions + /// { + /// ProtocolsByPort = + /// { + /// [8081] = IoxideProtocols.Http2, + /// [8443] = IoxideProtocols.All, + /// } + /// } + /// + /// + /// A port left out follows . A port given neither HTTP/1.1 nor + /// HTTP/2 still has a TCP listener, since the endpoint is bound, so HTTP/1.1 is served there + /// rather than accepting connections and answering nothing. + /// + public Dictionary ProtocolsByPort { get; init; } = []; /// /// PEM certificate chain for the HTTP/3 listener, as a path. diff --git a/Engine/Ioxide/IoxideProtocols.cs b/Engine/Ioxide/IoxideProtocols.cs new file mode 100644 index 000000000..96ad70e0c --- /dev/null +++ b/Engine/Ioxide/IoxideProtocols.cs @@ -0,0 +1,40 @@ +namespace GenHTTP.Engine.Ioxide; + +/// +/// The protocols an endpoint serves. +/// +/// +/// and share the TCP socket - which of them a connection +/// gets is settled by ALPN on a secure endpoint and by the connection preface on a plaintext one, so +/// enabling both costs nothing and turns no client away. is a UDP socket on the +/// same port number, independent of either. +/// +/// That independence is what lets one endpoint serve all three: TCP carries HTTP/1.1 and +/// HTTP/2, UDP carries HTTP/3, and a browser told about the third by an Alt-Svc header moves itself +/// across without changing port. +/// +[Flags] +public enum IoxideProtocols +{ + /// HTTP/1.1 over TCP. + Http1 = 1, + + /// + /// HTTP/2 over TCP: by ALPN on a secure endpoint, or by the connection preface (h2c with prior + /// knowledge) on a plaintext one. The Upgrade: dance is not implemented, which is what + /// every deployed h2c client does. + /// + Http2 = 2, + + /// + /// HTTP/3 over QUIC, on this endpoint's port number over UDP. Requires a certificate: QUIC + /// carries TLS 1.3 and has no cleartext mode. + /// + Http3 = 4, + + /// HTTP/1.1 and HTTP/2 on the TCP socket, chosen per connection. + Http1AndHttp2 = Http1 | Http2, + + /// Everything: HTTP/1.1 and HTTP/2 over TCP, HTTP/3 over UDP, one port number. + All = Http1 | Http2 | Http3, +} diff --git a/Engine/Ioxide/Protocol/ConnectionDriver.cs b/Engine/Ioxide/Protocol/ConnectionDriver.cs index ef0b14d31..c0e9b3bb2 100644 --- a/Engine/Ioxide/Protocol/ConnectionDriver.cs +++ b/Engine/Ioxide/Protocol/ConnectionDriver.cs @@ -5,6 +5,7 @@ using GenHTTP.Api.Infrastructure; using GenHTTP.Api.Protocol; +using GenHTTP.Engine.Ioxide; using GenHTTP.Engine.Ioxide.Protocol.Mux; using GenHTTP.Engine.Shared.Types; @@ -88,7 +89,7 @@ internal static partial class ConnectionDriver private const int MaxPooledRequests = 1024; - internal static async Task HandleAsync(IServer server, IEndPoint endPoint, IoConnection conn, Func>? connectionFactory, bool http2 = false) + internal static async Task HandleAsync(IServer server, IEndPoint endPoint, IoConnection conn, Func>? connectionFactory, IoxideProtocols protocols = IoxideProtocols.Http1) { IDuplexPipe pipe; @@ -134,7 +135,20 @@ internal static async Task HandleAsync(IServer server, IEndPoint endPoint, IoCon // The peer address is constant for the connection; resolve it once from the socket fd. var remoteAddress = GetPeerAddress(conn.ClientFd); - if (http2 && (negotiated == "h2" || (negotiated is null && await StartsWithPrefaceAsync(reader)))) + var http2 = protocols.HasFlag(IoxideProtocols.Http2); + var http1 = protocols.HasFlag(IoxideProtocols.Http1); + + // Only worth peeking when both share the port. On an HTTP/2-only port every connection is + // HTTP/2 by definition, and on an HTTP/1.1-only port the preface would be a malformed + // request line either way. + var isHttp2 = http2 && (negotiated == "h2" || (negotiated is null && http1 && await StartsWithPrefaceAsync(reader))); + + if (http2 && !http1) + { + isHttp2 = true; + } + + if (isHttp2) { // HTTP/2 owns the connection from here: it multiplexes, so there is no request loop to // run above it and nothing of the HTTP/1.1 path applies. @@ -154,6 +168,15 @@ internal static async Task HandleAsync(IServer server, IEndPoint endPoint, IoCon return; } + // The port does not serve HTTP/1.1, and this connection is not HTTP/2 - which on a secure + // port means the client offered no ALPN this endpoint accepts. Close rather than answer it + // with a protocol the endpoint was configured not to speak. + if (!http1) + { + await CloseAsync(pipe, conn); + return; + } + var request = RentRequest(); var into = request.Source; diff --git a/Playground/Program.cs b/Playground/Program.cs index 7b4516434..9e42e958d 100644 --- a/Playground/Program.cs +++ b/Playground/Program.cs @@ -11,17 +11,19 @@ // The namespace and the class share a name, so the class needs an alias to be reachable. using IoxideFilesModule = GenHTTP.Modules.IoxideFiles.IoxideFiles; -// One host, four protocols: +// One host, three ports, showing every arrangement the engine allows. Protocols are configured per +// port: HTTP/1.1 and HTTP/2 share a TCP socket, and HTTP/3 is a UDP socket on the same port number, +// so ports can be shared or separated however you like. // -// http://localhost:8080 HTTP/1.1, and HTTP/2 without TLS (h2c) for a client that opens with -// the HTTP/2 preface. Both share the port - the first bytes decide. -// https://localhost:8443 HTTP/1.1 or HTTP/2, chosen by ALPN during the handshake, plus -// HTTP/3 on the same number over UDP. +// http://localhost:8080 HTTP/1.1 only +// http://localhost:8081 HTTP/2 only, without TLS (h2c, for a client using prior knowledge) +// https://localhost:8443 all three at once - HTTP/1.1 and HTTP/2 over TCP, HTTP/3 over UDP // // dotnet run -c Release --project Playground // // curl http://localhost:8080/ok -// curl --http2-prior-knowledge http://localhost:8080/ok +// curl --http2-prior-knowledge http://localhost:8081/ok +// curl -k --http1.1 https://localhost:8443/ok // curl -k --http2 https://localhost:8443/ok // curl -k --http3-only https://localhost:8443/ok // @@ -66,20 +68,38 @@ await Host.Create( configure: c => c with { ReactorCount = reactors }, options: new IoxideOptions { - // HTTP/2 over TLS (ALPN prefers it) and, without TLS, for a client that opens - // with the HTTP/2 preface. HTTP/1.1 clients are unaffected either way. - Http2 = true, + // What a port serves unless named below. + Protocols = IoxideProtocols.Http1, + + ProtocolsByPort = + { + [8081] = IoxideProtocols.Http2, // h2c only - no HTTP/1.1 on this port + [8443] = IoxideProtocols.All, // h1 + h2 over TCP, h3 over UDP, one number + }, // Bytes of QPACK dynamic table offered to HTTP/3 clients. 0 keeps every header // literal, which costs bytes but can never stall a stream on a table update. In // practice only browsers advertise a table of their own. QpackDynamicTableCapacity = 4096, QpackBlockedStreams = 100, + + // HTTP/3 is terminated by ngtcp2, which loads PEM from disk. Name the files here + // and nothing is written; leave them out and the bound certificate is exported to + // an owner-only temporary directory for the lifetime of the process. + // + // Http3CertificatePath = "/etc/ssl/site.crt", + // Http3KeyPath = "/etc/ssl/site.key", + + // Mutual TLS, enforced on all three protocols. Clients are asked for a + // certificate and validated against this bundle. + // + // ClientCaPath = "/etc/ssl/clients.pem", + // RequireClientCertificate = true, }) .Handler(app) .Bind(IPAddress.Loopback, 8080) - // enableQuic adds the HTTP/3 listener on the same port number over UDP. - .Bind(IPAddress.Loopback, 8443, certificate, enableQuic: true) + .Bind(IPAddress.Loopback, 8081) + .Bind(IPAddress.Loopback, 8443, certificate) .RunAsync(); static X509Certificate2 LoadCertificate() From c0dde9a72b6cad57bcf224f9e5d2624f7e2ddb71 Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Sun, 16 Aug 2026 09:46:18 +0100 Subject: [PATCH 10/55] docs(ioxide): say that a port can serve HTTP/1.1 and HTTP/2 together The sample showed 8081 as HTTP/2 only without saying that was a choice, so it read as a limitation. Both protocols share a port when the port is given Http1AndHttp2 - ALPN decides on a secure endpoint, the connection preface on a plaintext one. --- Playground/Program.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Playground/Program.cs b/Playground/Program.cs index 9e42e958d..1e78e4e6a 100644 --- a/Playground/Program.cs +++ b/Playground/Program.cs @@ -19,6 +19,11 @@ // http://localhost:8081 HTTP/2 only, without TLS (h2c, for a client using prior knowledge) // https://localhost:8443 all three at once - HTTP/1.1 and HTTP/2 over TCP, HTTP/3 over UDP // +// Every port is independent, so 8081 serves ONLY HTTP/2 - an HTTP/1.1 client is turned away there. +// Give a port Http1AndHttp2 and it serves both, one connection at a time: ALPN picks during the +// handshake on a secure port, and the HTTP/2 connection preface picks on a plaintext one. That is +// what 8443 does below. +// // dotnet run -c Release --project Playground // // curl http://localhost:8080/ok @@ -73,7 +78,9 @@ await Host.Create( ProtocolsByPort = { - [8081] = IoxideProtocols.Http2, // h2c only - no HTTP/1.1 on this port + // h2c only: an HTTP/1.1 client is turned away here. Http1AndHttp2 would + // serve both on this one port, decided by the connection preface. + [8081] = IoxideProtocols.Http2, [8443] = IoxideProtocols.All, // h1 + h2 over TCP, h3 over UDP, one number }, From 282c1b7eb62e7c73aaa4a5e261ef341d8046c899 Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Sun, 16 Aug 2026 09:52:13 +0100 Subject: [PATCH 11/55] feat(ioxide): every protocol combination, including HTTP/3 without TCP Http1AndHttp3 and Http2AndHttp3 join the named combinations, and both describe real deployments. HTTP/1.1 with HTTP/3 skips HTTP/2 entirely while still serving every client - one that speaks neither gets HTTP/1.1, and a browser told about the QUIC port by Alt-Svc moves itself there. HTTP/2 with HTTP/3 drops HTTP/1.1, which suits somewhere the clients are known, gRPC being the obvious one. An endpoint given only HTTP/3 now opens no TCP listener at all, where before it was quietly given HTTP/1.1 on the grounds that the socket existed anyway. It does not have to: the transport takes a null TCP configuration, so the endpoint binds its UDP socket and nothing else, and a server made entirely of such endpoints opens no TCP listener either. A port left with no protocols is a configuration error rather than something to paper over. Verified per combination, asserting the protocol actually negotiated rather than that a request succeeded - a client asking for HTTP/2 against a port that does not serve it falls back to HTTP/1.1 and answers 200, which reads as success: Http1 h1 only, tcp Http2 h2 only, tcp Http3 h3 only, udp and no tcp listener Http1AndHttp2 h1 h2, tcp Http1AndHttp3 h1 h3, tcp + udp Http2AndHttp3 h2 h3, tcp + udp All h1 h2 h3, tcp + udp Acceptance suite 1442/1442. --- Engine/Ioxide/Hosting/IoxideServer.cs | 24 +++++++++++++++--------- Engine/Ioxide/IoxideProtocols.cs | 21 +++++++++++++++++++++ Playground/Program.cs | 18 ++++++++++++++---- 3 files changed, 50 insertions(+), 13 deletions(-) diff --git a/Engine/Ioxide/Hosting/IoxideServer.cs b/Engine/Ioxide/Hosting/IoxideServer.cs index 2caf539d8..a6e5771e7 100644 --- a/Engine/Ioxide/Hosting/IoxideServer.cs +++ b/Engine/Ioxide/Hosting/IoxideServer.cs @@ -143,9 +143,8 @@ internal IoxideServer(ServerConfiguration config, IHandler handler, Func /// - /// A port with neither HTTP/1.1 nor HTTP/2 still has a TCP listener, because binding the - /// endpoint is what created it - so HTTP/1.1 is served there rather than accepting connections - /// and answering nothing. + /// A port given only HTTP/3 opens no TCP listener at all, rather than binding one that answers + /// nothing. /// private static IoxideProtocols ResolveProtocols(IoxideOptions options, ServerConfiguration config, ushort port) { @@ -167,9 +166,9 @@ private static IoxideProtocols ResolveProtocols(IoxideOptions options, ServerCon protocols |= IoxideProtocols.Http3; } - if ((protocols & IoxideProtocols.Http1AndHttp2) == 0) + if (protocols == 0) { - protocols |= IoxideProtocols.Http1; + throw new NotSupportedException($"Port {port} was given no protocols to serve."); } return protocols; @@ -193,14 +192,21 @@ public async ValueTask StartAsync() } // The endpoint bindings (.Port()/.Bind()) determine the listen ports and dual-stack mode, so - // they always win over whatever the configuration hook may have set. + // they always win over whatever the configuration hook may have set. Only the ports serving + // something over TCP are bound: an HTTP/3-only endpoint has a UDP socket and nothing else, + // and a server made entirely of those opens no TCP listener at all. + var tcpPorts = _protocols.Where(p => (p.Value & IoxideProtocols.Http1AndHttp2) != 0) + .Select(p => p.Key) + .OrderBy(p => p == _primary.Port ? 0 : 1) + .ToArray(); + cfg = cfg with { DualStack = _primary.DualStack, - Tcp = (cfg.Tcp ?? new TcpOptions()) with + Tcp = tcpPorts.Length == 0 ? null : (cfg.Tcp ?? new TcpOptions()) with { - Port = _primary.Port, - ExtraPorts = _extraPorts + Port = tcpPorts[0], + ExtraPorts = tcpPorts.Skip(1).ToArray() } }; diff --git a/Engine/Ioxide/IoxideProtocols.cs b/Engine/Ioxide/IoxideProtocols.cs index 96ad70e0c..6842d1567 100644 --- a/Engine/Ioxide/IoxideProtocols.cs +++ b/Engine/Ioxide/IoxideProtocols.cs @@ -35,6 +35,27 @@ public enum IoxideProtocols /// HTTP/1.1 and HTTP/2 on the TCP socket, chosen per connection. Http1AndHttp2 = Http1 | Http2, + /// + /// HTTP/1.1 over TCP and HTTP/3 over UDP, skipping HTTP/2 entirely. + /// + /// + /// Every client can still be served: one that speaks neither HTTP/2 nor HTTP/3 gets HTTP/1.1, + /// and a browser told about the QUIC port by an Alt-Svc header moves itself there. Worth having + /// when HTTP/2 is not wanted - its flow control and HPACK state cost per connection, and a + /// deployment that has HTTP/3 may have little use for it. + /// + Http1AndHttp3 = Http1 | Http3, + + /// + /// HTTP/2 over TCP and HTTP/3 over UDP, with no HTTP/1.1 at all. + /// + /// + /// A client that speaks neither is turned away, so this is for somewhere the clients are known - + /// a private API, or gRPC, where HTTP/2 is the floor rather than an upgrade. Note that a browser + /// reaching a plaintext endpoint cannot use h2c, so this pairs with a certificate in practice. + /// + Http2AndHttp3 = Http2 | Http3, + /// Everything: HTTP/1.1 and HTTP/2 over TCP, HTTP/3 over UDP, one port number. All = Http1 | Http2 | Http3, } diff --git a/Playground/Program.cs b/Playground/Program.cs index 1e78e4e6a..49a049983 100644 --- a/Playground/Program.cs +++ b/Playground/Program.cs @@ -19,10 +19,20 @@ // http://localhost:8081 HTTP/2 only, without TLS (h2c, for a client using prior knowledge) // https://localhost:8443 all three at once - HTTP/1.1 and HTTP/2 over TCP, HTTP/3 over UDP // -// Every port is independent, so 8081 serves ONLY HTTP/2 - an HTTP/1.1 client is turned away there. -// Give a port Http1AndHttp2 and it serves both, one connection at a time: ALPN picks during the -// handshake on a secure port, and the HTTP/2 connection preface picks on a plaintext one. That is -// what 8443 does below. +// Every port is independent, and any combination is allowed: +// +// Http1 HTTP/1.1 only +// Http2 HTTP/2 only - an HTTP/1.1 client is turned away +// Http3 HTTP/3 only - a UDP socket and NO TCP listener at all +// Http1AndHttp2 both on one TCP socket, decided per connection +// Http1AndHttp3 HTTP/1.1 over TCP, HTTP/3 over UDP, skipping HTTP/2 +// Http2AndHttp3 HTTP/2 over TCP, HTTP/3 over UDP, no HTTP/1.1 +// All everything, one port number +// +// Where two share the TCP socket, ALPN decides during the handshake on a secure port and the +// HTTP/2 connection preface decides on a plaintext one. HTTP/3 always needs a certificate, since +// QUIC carries TLS 1.3 and has no cleartext mode - and only one endpoint may serve it, because the +// transport binds a single QUIC listener. // // dotnet run -c Release --project Playground // From 54b049b0fdf8800dc12b9af0cb0e7a17b988a08f Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Sun, 16 Aug 2026 09:56:56 +0100 Subject: [PATCH 12/55] docs(ioxide): a port per protocol combination in the sample The sample bound three ports and described the rest in a comment. It now binds one per combination that can coexist: 8080 Http1 HTTP/1.1 only 8081 Http2 HTTP/2 only - an HTTP/1.1 client is turned away 8082 Http1AndHttp2 both on one plaintext socket, the preface decides 8443 the HTTP/3 case The four combinations carrying HTTP/3 cannot run together: the transport binds one QUIC listener per server, so a second endpoint asking for HTTP/3 is refused at startup. They take turns on 8443 instead, chosen by GENHTTP_H3 - All (the default), Http1AndHttp3, Http2AndHttp3, or Http3 alone, which leaves that port with a UDP socket and no TCP listener at all. Each mode verified by asserting the protocol negotiated rather than that a request succeeded, and by the listener counts: under GENHTTP_H3=Http3 port 8443 reports tcp=0 udp=32 while the others are TCP only. --- Playground/Program.cs | 69 ++++++++++++++++++++++++------------------- 1 file changed, 39 insertions(+), 30 deletions(-) diff --git a/Playground/Program.cs b/Playground/Program.cs index 49a049983..119f4e00d 100644 --- a/Playground/Program.cs +++ b/Playground/Program.cs @@ -11,37 +11,37 @@ // The namespace and the class share a name, so the class needs an alias to be reachable. using IoxideFilesModule = GenHTTP.Modules.IoxideFiles.IoxideFiles; -// One host, three ports, showing every arrangement the engine allows. Protocols are configured per -// port: HTTP/1.1 and HTTP/2 share a TCP socket, and HTTP/3 is a UDP socket on the same port number, -// so ports can be shared or separated however you like. +// A port per protocol combination. Protocols are configured per port: HTTP/1.1 and HTTP/2 share a +// TCP socket, HTTP/3 is a UDP socket on the same port number, and any combination of the three is +// allowed - so ports can be shared or separated however you like. // -// http://localhost:8080 HTTP/1.1 only -// http://localhost:8081 HTTP/2 only, without TLS (h2c, for a client using prior knowledge) -// https://localhost:8443 all three at once - HTTP/1.1 and HTTP/2 over TCP, HTTP/3 over UDP -// -// Every port is independent, and any combination is allowed: -// -// Http1 HTTP/1.1 only -// Http2 HTTP/2 only - an HTTP/1.1 client is turned away -// Http3 HTTP/3 only - a UDP socket and NO TCP listener at all -// Http1AndHttp2 both on one TCP socket, decided per connection -// Http1AndHttp3 HTTP/1.1 over TCP, HTTP/3 over UDP, skipping HTTP/2 -// Http2AndHttp3 HTTP/2 over TCP, HTTP/3 over UDP, no HTTP/1.1 -// All everything, one port number -// -// Where two share the TCP socket, ALPN decides during the handshake on a secure port and the -// HTTP/2 connection preface decides on a plaintext one. HTTP/3 always needs a certificate, since -// QUIC carries TLS 1.3 and has no cleartext mode - and only one endpoint may serve it, because the -// transport binds a single QUIC listener. +// http://localhost:8080 Http1 HTTP/1.1 only +// http://localhost:8081 Http2 HTTP/2 only (h2c, prior knowledge) - h1 turned away +// http://localhost:8082 Http1AndHttp2 both on one socket, the preface decides +// https://localhost:8443 the HTTP/3 case, see below // // dotnet run -c Release --project Playground // // curl http://localhost:8080/ok // curl --http2-prior-knowledge http://localhost:8081/ok -// curl -k --http1.1 https://localhost:8443/ok -// curl -k --http2 https://localhost:8443/ok +// curl --http1.1 http://localhost:8082/ok +// curl --http2-prior-knowledge http://localhost:8082/ok // curl -k --http3-only https://localhost:8443/ok // +// The four combinations carrying HTTP/3 take turns on 8443, because the transport binds ONE QUIC +// listener per server - asking two endpoints for HTTP/3 is refused at startup. GENHTTP_H3 picks: +// +// All (default) HTTP/1.1 and HTTP/2 over TCP, HTTP/3 over UDP +// Http1AndHttp3 HTTP/1.1 over TCP, HTTP/3 over UDP, no HTTP/2 +// Http2AndHttp3 HTTP/2 over TCP, HTTP/3 over UDP, no HTTP/1.1 +// Http3 HTTP/3 alone - a UDP socket and NO TCP listener on that port +// +// GENHTTP_H3=Http3 dotnet run -c Release --project Playground +// +// Where two protocols share the TCP socket, ALPN decides during the handshake on a secure port and +// the HTTP/2 connection preface decides on a plaintext one. HTTP/3 always needs a certificate, +// since QUIC carries TLS 1.3 and has no cleartext mode. +// // Browsers never try HTTP/3 first. They connect over TCP and only move to QUIC once a response has // told them where to look, so a browser-facing deployment adds an Alt-Svc header pointing at the // HTTP/3 port. curl reaches it directly with --http3-only, which is why the sample needs nothing. @@ -70,15 +70,19 @@ .Add("disk", Assets.From(staticDir)); } -// A throwaway certificate so the sample runs with no setup. Point GENHTTP_CERT at a PKCS#12 bundle -// to serve a real one - a browser will refuse HTTP/3 to a certificate it does not trust. -using var certificate = LoadCertificate(); - // One io_uring reactor per core by default. GENHTTP_REACTORS lowers it, which is what a benchmark // wants: a server sized to every core leaves none for the load generator, and the run then measures // the generator rather than the server. var reactors = int.TryParse(Environment.GetEnvironmentVariable("GENHTTP_REACTORS"), out var r) ? r : Environment.ProcessorCount; +var http3Case = Enum.TryParse(Environment.GetEnvironmentVariable("GENHTTP_H3"), ignoreCase: true, out var selected) + ? selected + : IoxideProtocols.All; + +// A throwaway certificate so the sample runs with no setup. Point GENHTTP_CERT at a PKCS#12 bundle +// to serve a real one - a browser will refuse HTTP/3 to a certificate it does not trust. +using var certificate = LoadCertificate(); + await Host.Create( configure: c => c with { ReactorCount = reactors }, options: new IoxideOptions @@ -88,10 +92,14 @@ await Host.Create( ProtocolsByPort = { - // h2c only: an HTTP/1.1 client is turned away here. Http1AndHttp2 would - // serve both on this one port, decided by the connection preface. + // h2c only: an HTTP/1.1 client is turned away here. [8081] = IoxideProtocols.Http2, - [8443] = IoxideProtocols.All, // h1 + h2 over TCP, h3 over UDP, one number + + // Both on one plaintext socket - the connection preface decides. + [8082] = IoxideProtocols.Http1AndHttp2, + + // The HTTP/3 case under test. With Http3 alone this port has no TCP listener. + [8443] = http3Case, }, // Bytes of QPACK dynamic table offered to HTTP/3 clients. 0 keeps every header @@ -116,6 +124,7 @@ await Host.Create( .Handler(app) .Bind(IPAddress.Loopback, 8080) .Bind(IPAddress.Loopback, 8081) + .Bind(IPAddress.Loopback, 8082) .Bind(IPAddress.Loopback, 8443, certificate) .RunAsync(); From fde5f0c46b4cda01f23f46cd3625c55dcf813699 Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Sun, 16 Aug 2026 10:01:29 +0100 Subject: [PATCH 13/55] docs(ioxide): a live port for every protocol combination, no switches The sample rotated the four HTTP/3 combinations through one port with an environment variable, so six of the seven were only ever described. All seven run at once now, each on its own port: 8080 Http1 8443 All 8081 Http2 8444 Http1AndHttp3 8082 Http1AndHttp2 8445 Http2AndHttp3 8446 Http3 A server binds one QUIC listener, so the four carrying HTTP/3 need a host each - which costs nothing worth avoiding, since a host is a handler and a few reactors. The three plaintext combinations share one host, having no QUIC listener to contend over. Reactors are held at two apiece rather than one per core: six hosts on one machine, and a sample is not where throughput is measured. Verified per port by the protocol actually negotiated, not by a request succeeding - a client asking for HTTP/2 where it is not served falls back to HTTP/1.1 and answers 200: 8080 h1 8443 h1 h2 h3 8081 h2 8444 h1 h3 8082 h1 h2 8445 h2 h3 8446 h3 Port 8446 reports tcp=0 udp=2: an HTTP/3-only endpoint opens no TCP listener. Acceptance suite 1442/1442. --- Playground/Program.cs | 161 +++++++++++++++++++++++------------------- 1 file changed, 89 insertions(+), 72 deletions(-) diff --git a/Playground/Program.cs b/Playground/Program.cs index 119f4e00d..54f43925f 100644 --- a/Playground/Program.cs +++ b/Playground/Program.cs @@ -2,6 +2,9 @@ using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; +using GenHTTP.Api.Content; +using GenHTTP.Api.Infrastructure; + using GenHTTP.Engine.Ioxide; using GenHTTP.Modules.Files; @@ -11,37 +14,35 @@ // The namespace and the class share a name, so the class needs an alias to be reachable. using IoxideFilesModule = GenHTTP.Modules.IoxideFiles.IoxideFiles; -// A port per protocol combination. Protocols are configured per port: HTTP/1.1 and HTTP/2 share a -// TCP socket, HTTP/3 is a UDP socket on the same port number, and any combination of the three is -// allowed - so ports can be shared or separated however you like. +// A port for every protocol combination the engine allows. Protocols are configured per port: +// HTTP/1.1 and HTTP/2 share a TCP socket, HTTP/3 is a UDP socket on the same port number, and any +// combination of the three is allowed. // -// http://localhost:8080 Http1 HTTP/1.1 only -// http://localhost:8081 Http2 HTTP/2 only (h2c, prior knowledge) - h1 turned away -// http://localhost:8082 Http1AndHttp2 both on one socket, the preface decides -// https://localhost:8443 the HTTP/3 case, see below +// http://localhost:8080 Http1 HTTP/1.1 only +// http://localhost:8081 Http2 HTTP/2 only (h2c) - an HTTP/1.1 client is turned away +// http://localhost:8082 Http1AndHttp2 both on one socket, the preface decides +// https://localhost:8443 All HTTP/1.1 + HTTP/2 over TCP, HTTP/3 over UDP +// https://localhost:8444 Http1AndHttp3 HTTP/1.1 over TCP, HTTP/3 over UDP, no HTTP/2 +// https://localhost:8445 Http2AndHttp3 HTTP/2 over TCP, HTTP/3 over UDP, no HTTP/1.1 +// https://localhost:8446 Http3 HTTP/3 alone - a UDP socket and NO TCP listener // // dotnet run -c Release --project Playground // // curl http://localhost:8080/ok // curl --http2-prior-knowledge http://localhost:8081/ok // curl --http1.1 http://localhost:8082/ok -// curl --http2-prior-knowledge http://localhost:8082/ok -// curl -k --http3-only https://localhost:8443/ok -// -// The four combinations carrying HTTP/3 take turns on 8443, because the transport binds ONE QUIC -// listener per server - asking two endpoints for HTTP/3 is refused at startup. GENHTTP_H3 picks: -// -// All (default) HTTP/1.1 and HTTP/2 over TCP, HTTP/3 over UDP -// Http1AndHttp3 HTTP/1.1 over TCP, HTTP/3 over UDP, no HTTP/2 -// Http2AndHttp3 HTTP/2 over TCP, HTTP/3 over UDP, no HTTP/1.1 -// Http3 HTTP/3 alone - a UDP socket and NO TCP listener on that port -// -// GENHTTP_H3=Http3 dotnet run -c Release --project Playground +// curl -k --http2 https://localhost:8443/ok +// curl -k --http3-only https://localhost:8444/ok +// curl -k --http3-only https://localhost:8446/ok // // Where two protocols share the TCP socket, ALPN decides during the handshake on a secure port and // the HTTP/2 connection preface decides on a plaintext one. HTTP/3 always needs a certificate, // since QUIC carries TLS 1.3 and has no cleartext mode. // +// The four combinations carrying HTTP/3 need a host each: one server binds one QUIC listener, so +// asking two of its endpoints for HTTP/3 is refused at startup. Hosts are cheap enough to run side +// by side - each owns its own reactors, kept small here because six of them share the machine. +// // Browsers never try HTTP/3 first. They connect over TCP and only move to QUIC once a response has // told them where to look, so a browser-facing deployment adds an Alt-Svc header pointing at the // HTTP/3 port. curl reaches it directly with --http3-only, which is why the sample needs nothing. @@ -59,6 +60,10 @@ // wrk -t8 -c64 -d8s http://127.0.0.1:8080/ring/asset.bin // wrk -t8 -c64 -d8s http://127.0.0.1:8080/disk/asset.bin +// One reactor per core is the default. Six hosts sharing a machine want fewer, and a sample is not +// where throughput is measured - bench/ is. +const int Reactors = 2; + var staticDir = Environment.GetEnvironmentVariable("GENHTTP_STATIC"); var app = Layout.Create() @@ -70,63 +75,75 @@ .Add("disk", Assets.From(staticDir)); } -// One io_uring reactor per core by default. GENHTTP_REACTORS lowers it, which is what a benchmark -// wants: a server sized to every core leaves none for the load generator, and the run then measures -// the generator rather than the server. -var reactors = int.TryParse(Environment.GetEnvironmentVariable("GENHTTP_REACTORS"), out var r) ? r : Environment.ProcessorCount; - -var http3Case = Enum.TryParse(Environment.GetEnvironmentVariable("GENHTTP_H3"), ignoreCase: true, out var selected) - ? selected - : IoxideProtocols.All; - // A throwaway certificate so the sample runs with no setup. Point GENHTTP_CERT at a PKCS#12 bundle // to serve a real one - a browser will refuse HTTP/3 to a certificate it does not trust. using var certificate = LoadCertificate(); -await Host.Create( - configure: c => c with { ReactorCount = reactors }, - options: new IoxideOptions - { - // What a port serves unless named below. - Protocols = IoxideProtocols.Http1, - - ProtocolsByPort = - { - // h2c only: an HTTP/1.1 client is turned away here. - [8081] = IoxideProtocols.Http2, - - // Both on one plaintext socket - the connection preface decides. - [8082] = IoxideProtocols.Http1AndHttp2, - - // The HTTP/3 case under test. With Http3 alone this port has no TCP listener. - [8443] = http3Case, - }, - - // Bytes of QPACK dynamic table offered to HTTP/3 clients. 0 keeps every header - // literal, which costs bytes but can never stall a stream on a table update. In - // practice only browsers advertise a table of their own. - QpackDynamicTableCapacity = 4096, - QpackBlockedStreams = 100, - - // HTTP/3 is terminated by ngtcp2, which loads PEM from disk. Name the files here - // and nothing is written; leave them out and the bound certificate is exported to - // an owner-only temporary directory for the lifetime of the process. - // - // Http3CertificatePath = "/etc/ssl/site.crt", - // Http3KeyPath = "/etc/ssl/site.key", - - // Mutual TLS, enforced on all three protocols. Clients are asked for a - // certificate and validated against this bundle. - // - // ClientCaPath = "/etc/ssl/clients.pem", - // RequireClientCertificate = true, - }) - .Handler(app) - .Bind(IPAddress.Loopback, 8080) - .Bind(IPAddress.Loopback, 8081) - .Bind(IPAddress.Loopback, 8082) - .Bind(IPAddress.Loopback, 8443, certificate) - .RunAsync(); +// The plaintext combinations share one host: none of them serves HTTP/3, so none needs a QUIC +// listener of its own. +var plaintext = Host.Create( + configure: c => c with { ReactorCount = Reactors }, + options: new IoxideOptions + { + Protocols = IoxideProtocols.Http1, + ProtocolsByPort = + { + [8081] = IoxideProtocols.Http2, + [8082] = IoxideProtocols.Http1AndHttp2, + } + }) + .Handler(app) + .Bind(IPAddress.Loopback, 8080) + .Bind(IPAddress.Loopback, 8081) + .Bind(IPAddress.Loopback, 8082); + +var secure = new[] +{ + Secure(8443, IoxideProtocols.All), + Secure(8444, IoxideProtocols.Http1AndHttp3), + Secure(8445, IoxideProtocols.Http2AndHttp3), + Secure(8446, IoxideProtocols.Http3), +}; + +await plaintext.StartAsync(); + +foreach (var host in secure) +{ + await host.StartAsync(); +} + +Console.WriteLine("Serving 8080 h1 | 8081 h2 | 8082 h1+h2 | 8443 h1+h2+h3 | 8444 h1+h3 | 8445 h2+h3 | 8446 h3"); + +await Task.Delay(Timeout.Infinite); + +IServerHost Secure(ushort port, IoxideProtocols protocols) + => Host.Create( + configure: c => c with { ReactorCount = Reactors }, + options: new IoxideOptions + { + ProtocolsByPort = { [port] = protocols }, + + // Bytes of QPACK dynamic table offered to HTTP/3 clients. 0 keeps every header + // literal, which costs bytes but can never stall a stream on a table update. In + // practice only browsers advertise a table of their own. + QpackDynamicTableCapacity = 4096, + QpackBlockedStreams = 100, + + // HTTP/3 is terminated by ngtcp2, which loads PEM from disk. Name the files here + // and nothing is written; leave them out and the bound certificate is exported to + // an owner-only temporary directory for the lifetime of the process. + // + // Http3CertificatePath = "/etc/ssl/site.crt", + // Http3KeyPath = "/etc/ssl/site.key", + + // Mutual TLS, enforced on all three protocols. Clients are asked for a + // certificate and validated against this bundle. + // + // ClientCaPath = "/etc/ssl/clients.pem", + // RequireClientCertificate = true, + }) + .Handler(app) + .Bind(IPAddress.Loopback, port, certificate); static X509Certificate2 LoadCertificate() { From 8c8eca869807bf1803eb4ab21975a6838df9c078 Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Sun, 16 Aug 2026 10:15:45 +0100 Subject: [PATCH 14/55] docs(ioxide): one host, one combination per port Seven hosts to demonstrate seven combinations was more machinery than the point deserved. One host binds all of them except a second HTTP/3 port, so the sample is one host again: 8080 Http1 HTTP/1.1 only 8081 Http2 HTTP/2 only - an HTTP/1.1 client is turned away 8082 Http1AndHttp2 both on one socket, the preface decides 8443 All HTTP/1.1 + HTTP/2 over TCP, HTTP/3 over UDP Http1AndHttp3, Http2AndHttp3 and Http3-alone are named in the header rather than bound, because only one endpoint per server can carry HTTP/3 - the transport binds a single QUIC listener - and changing what 8443 serves is how to try them. Verified by the protocol negotiated on each port rather than by a request succeeding, and by the listener counts. --- Playground/Program.cs | 128 ++++++++++++++++-------------------------- 1 file changed, 48 insertions(+), 80 deletions(-) diff --git a/Playground/Program.cs b/Playground/Program.cs index 54f43925f..84bc8f8f3 100644 --- a/Playground/Program.cs +++ b/Playground/Program.cs @@ -2,9 +2,6 @@ using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; -using GenHTTP.Api.Content; -using GenHTTP.Api.Infrastructure; - using GenHTTP.Engine.Ioxide; using GenHTTP.Modules.Files; @@ -14,34 +11,31 @@ // The namespace and the class share a name, so the class needs an alias to be reachable. using IoxideFilesModule = GenHTTP.Modules.IoxideFiles.IoxideFiles; -// A port for every protocol combination the engine allows. Protocols are configured per port: -// HTTP/1.1 and HTTP/2 share a TCP socket, HTTP/3 is a UDP socket on the same port number, and any -// combination of the three is allowed. +// One host, a protocol combination per port. HTTP/1.1 and HTTP/2 share a TCP socket, HTTP/3 is a +// UDP socket on the same port number, and any combination of the three is allowed. // // http://localhost:8080 Http1 HTTP/1.1 only // http://localhost:8081 Http2 HTTP/2 only (h2c) - an HTTP/1.1 client is turned away // http://localhost:8082 Http1AndHttp2 both on one socket, the preface decides // https://localhost:8443 All HTTP/1.1 + HTTP/2 over TCP, HTTP/3 over UDP -// https://localhost:8444 Http1AndHttp3 HTTP/1.1 over TCP, HTTP/3 over UDP, no HTTP/2 -// https://localhost:8445 Http2AndHttp3 HTTP/2 over TCP, HTTP/3 over UDP, no HTTP/1.1 -// https://localhost:8446 Http3 HTTP/3 alone - a UDP socket and NO TCP listener // // dotnet run -c Release --project Playground // // curl http://localhost:8080/ok // curl --http2-prior-knowledge http://localhost:8081/ok // curl --http1.1 http://localhost:8082/ok +// curl -k --http1.1 https://localhost:8443/ok // curl -k --http2 https://localhost:8443/ok -// curl -k --http3-only https://localhost:8444/ok -// curl -k --http3-only https://localhost:8446/ok +// curl -k --http3-only https://localhost:8443/ok // // Where two protocols share the TCP socket, ALPN decides during the handshake on a secure port and // the HTTP/2 connection preface decides on a plaintext one. HTTP/3 always needs a certificate, // since QUIC carries TLS 1.3 and has no cleartext mode. // -// The four combinations carrying HTTP/3 need a host each: one server binds one QUIC listener, so -// asking two of its endpoints for HTTP/3 is refused at startup. Hosts are cheap enough to run side -// by side - each owns its own reactors, kept small here because six of them share the machine. +// Http1AndHttp3 and Http2AndHttp3 exist too - HTTP/3 alongside just one of the TCP protocols - and +// so does Http3 on its own, which opens a UDP socket and no TCP listener at all. Only one of them +// can be live here: a server binds a single QUIC listener, so a second endpoint asking for HTTP/3 +// is refused at startup. Change what 8443 serves to try another. // // Browsers never try HTTP/3 first. They connect over TCP and only move to QUIC once a response has // told them where to look, so a browser-facing deployment adds an Alt-Svc header pointing at the @@ -60,7 +54,7 @@ // wrk -t8 -c64 -d8s http://127.0.0.1:8080/ring/asset.bin // wrk -t8 -c64 -d8s http://127.0.0.1:8080/disk/asset.bin -// One reactor per core is the default. Six hosts sharing a machine want fewer, and a sample is not +// One reactor per core is the default. A sample does not need the whole machine, and this is not // where throughput is measured - bench/ is. const int Reactors = 2; @@ -79,71 +73,45 @@ // to serve a real one - a browser will refuse HTTP/3 to a certificate it does not trust. using var certificate = LoadCertificate(); -// The plaintext combinations share one host: none of them serves HTTP/3, so none needs a QUIC -// listener of its own. -var plaintext = Host.Create( - configure: c => c with { ReactorCount = Reactors }, - options: new IoxideOptions - { - Protocols = IoxideProtocols.Http1, - ProtocolsByPort = - { - [8081] = IoxideProtocols.Http2, - [8082] = IoxideProtocols.Http1AndHttp2, - } - }) - .Handler(app) - .Bind(IPAddress.Loopback, 8080) - .Bind(IPAddress.Loopback, 8081) - .Bind(IPAddress.Loopback, 8082); - -var secure = new[] -{ - Secure(8443, IoxideProtocols.All), - Secure(8444, IoxideProtocols.Http1AndHttp3), - Secure(8445, IoxideProtocols.Http2AndHttp3), - Secure(8446, IoxideProtocols.Http3), -}; - -await plaintext.StartAsync(); - -foreach (var host in secure) -{ - await host.StartAsync(); -} - -Console.WriteLine("Serving 8080 h1 | 8081 h2 | 8082 h1+h2 | 8443 h1+h2+h3 | 8444 h1+h3 | 8445 h2+h3 | 8446 h3"); - -await Task.Delay(Timeout.Infinite); - -IServerHost Secure(ushort port, IoxideProtocols protocols) - => Host.Create( - configure: c => c with { ReactorCount = Reactors }, - options: new IoxideOptions - { - ProtocolsByPort = { [port] = protocols }, - - // Bytes of QPACK dynamic table offered to HTTP/3 clients. 0 keeps every header - // literal, which costs bytes but can never stall a stream on a table update. In - // practice only browsers advertise a table of their own. - QpackDynamicTableCapacity = 4096, - QpackBlockedStreams = 100, - - // HTTP/3 is terminated by ngtcp2, which loads PEM from disk. Name the files here - // and nothing is written; leave them out and the bound certificate is exported to - // an owner-only temporary directory for the lifetime of the process. - // - // Http3CertificatePath = "/etc/ssl/site.crt", - // Http3KeyPath = "/etc/ssl/site.key", - - // Mutual TLS, enforced on all three protocols. Clients are asked for a - // certificate and validated against this bundle. - // - // ClientCaPath = "/etc/ssl/clients.pem", - // RequireClientCertificate = true, - }) - .Handler(app) - .Bind(IPAddress.Loopback, port, certificate); +await Host.Create( + configure: c => c with { ReactorCount = Reactors }, + options: new IoxideOptions + { + // What a port serves unless named below. + Protocols = IoxideProtocols.Http1, + + ProtocolsByPort = + { + [8081] = IoxideProtocols.Http2, + [8082] = IoxideProtocols.Http1AndHttp2, + [8443] = IoxideProtocols.All, + }, + + // Bytes of QPACK dynamic table offered to HTTP/3 clients. 0 keeps every header + // literal, which costs bytes but can never stall a stream on a table update. In + // practice only browsers advertise a table of their own. + QpackDynamicTableCapacity = 4096, + QpackBlockedStreams = 100, + + // HTTP/3 is terminated by ngtcp2, which loads PEM from disk. Name the files here + // and nothing is written; leave them out and the bound certificate is exported to + // an owner-only temporary directory for the lifetime of the process. + // + // Http3CertificatePath = "/etc/ssl/site.crt", + // Http3KeyPath = "/etc/ssl/site.key", + + // Mutual TLS, enforced on all three protocols. Clients are asked for a + // certificate and validated against this bundle. + // + // ClientCaPath = "/etc/ssl/clients.pem", + // RequireClientCertificate = true, + }) + .Handler(app) + .Bind(IPAddress.Loopback, 8080) + .Bind(IPAddress.Loopback, 8081) + .Bind(IPAddress.Loopback, 8082) + .Bind(IPAddress.Loopback, 8443, certificate) + .RunAsync(); static X509Certificate2 LoadCertificate() { From 9a4aff15201acc5067cfafd2028375184a5e732a Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Sun, 16 Aug 2026 10:27:02 +0100 Subject: [PATCH 15/55] refactor(ioxide): group the options, and show HTTP/1.1 behind mutual TLS IoxideOptions was a flat list of nine properties from three unrelated concerns. The two that belong together are grouped now: options.Http3.CertificatePath options.MutualTls.ClientCaPath options.Http3.KeyPath options.MutualTls.ClientCaPem options.Http3.QpackDynamicTableCapacity options.Http3.QpackBlockedStreams Protocols and ProtocolsByPort stay at the top, being what the engine is mostly configured through. QUIC's certificate and HTTP/3's QPACK share a group despite belonging to different layers, because they configure the same endpoint. The sample gains a port serving HTTP/1.1 behind mutual TLS, which also shows that requiring a client certificate is decided per endpoint: 8444 is bound with a certificateValidator and demands one, while 8443 alongside it stays open. The CA they are validated against is shared by the server, since that is what the transport takes. An endpoint like that cannot be tried without a client certificate, so the sample writes a CA, one certificate signed by it and one signed by nobody into ./certs on startup, and the header carries the curl commands. Verified: the signed client gets 200, no certificate is refused, the impostor is refused, and 8443 answers both HTTP/1.1 and HTTP/3 without a certificate throughout. Every certificate there shares one validity window. Reading the clock per certificate put the leaf a second beyond its issuer, which is refused outright - the sample crashed on startup until they were pinned. --- Engine/Ioxide/Hosting/IoxideServer.Quic.cs | 6 +- Engine/Ioxide/Hosting/IoxideServer.Tls.cs | 10 +- Engine/Ioxide/Hosting/IoxideServer.cs | 4 +- Engine/Ioxide/IoxideOptions.cs | 94 +++++++++------ Playground/Program.cs | 127 ++++++++++++++++++--- 5 files changed, 180 insertions(+), 61 deletions(-) diff --git a/Engine/Ioxide/Hosting/IoxideServer.Quic.cs b/Engine/Ioxide/Hosting/IoxideServer.Quic.cs index 20ff7f934..cf1b0f79d 100644 --- a/Engine/Ioxide/Hosting/IoxideServer.Quic.cs +++ b/Engine/Ioxide/Hosting/IoxideServer.Quic.cs @@ -43,7 +43,7 @@ private ServerConfig WithQuic(ServerConfig cfg, IoxideEndPoint endPoint) } _quic = new QuicEngine(certPath, keyPath, alpn: ["h3"], - clientCaPemPath: _options.ClientCaPath, + clientCaPemPath: _options.MutualTls.ClientCaPath, requireClientCertificate: RequiresClientCertificate(security)); _quicEndPoint = endPoint; @@ -67,12 +67,12 @@ private ServerConfig WithQuic(ServerConfig cfg, IoxideEndPoint endPoint) /// path is used as it is and nothing is written. Otherwise the endpoint's certificate is /// exported to a file this user alone can read, which does put a private key on disk for the /// lifetime of the process - so a deployment holding PEM files should name them through - /// and skip this entirely. + /// and skip this entirely. /// private bool TryResolveQuicCertificate(Shared.Infrastructure.SecurityConfiguration security, ushort port, out string certPath, out string keyPath) { - if (_options.Http3CertificatePath is { } configuredCert && _options.Http3KeyPath is { } configuredKey) + if (_options.Http3.CertificatePath is { } configuredCert && _options.Http3.KeyPath is { } configuredKey) { if (!File.Exists(configuredCert) || !File.Exists(configuredKey)) { diff --git a/Engine/Ioxide/Hosting/IoxideServer.Tls.cs b/Engine/Ioxide/Hosting/IoxideServer.Tls.cs index ee766fe5f..4fa9d8514 100644 --- a/Engine/Ioxide/Hosting/IoxideServer.Tls.cs +++ b/Engine/Ioxide/Hosting/IoxideServer.Tls.cs @@ -40,8 +40,8 @@ private IEnumerable> ResolveTls() // an ALPN extension at all. Alpn = ProtocolsFor(port).HasFlag(IoxideProtocols.Http2) ? ["h2", "http/1.1"] : ["http/1.1"], - ClientCaPath = _options.ClientCaPath, - ClientCaPem = _options.ClientCaPem, + ClientCaPath = _options.MutualTls.ClientCaPath, + ClientCaPem = _options.MutualTls.ClientCaPem, RequireClientCertificate = RequiresClientCertificate(security), KernelTx = _kernelTx, @@ -59,14 +59,14 @@ private IEnumerable> ResolveTls() /// still gets asked, because the CertificateRequest goes out either way. /// private bool RequiresClientCertificate(SecurityConfiguration security) - => _options.RequireClientCertificate || security.CertificateValidator?.RequireCertificate == true; + => _options.MutualTls.RequireClientCertificate || security.CertificateValidator?.RequireCertificate == true; /// /// Whether any endpoint asks for a client certificate at all. /// private bool MutualTlsConfigured - => _options.ClientCaPath is not null || _options.ClientCaPem is not null - || _options.RequireClientCertificate || _secure.Values.Any(s => s.CertificateValidator is not null); + => _options.MutualTls.ClientCaPath is not null || _options.MutualTls.ClientCaPem is not null + || _options.MutualTls.RequireClientCertificate || _secure.Values.Any(s => s.CertificateValidator is not null); private static string ExportKeyPem(X509Certificate2 certificate) => certificate.GetRSAPrivateKey()?.ExportPkcs8PrivateKeyPem() diff --git a/Engine/Ioxide/Hosting/IoxideServer.cs b/Engine/Ioxide/Hosting/IoxideServer.cs index a6e5771e7..7ec6865aa 100644 --- a/Engine/Ioxide/Hosting/IoxideServer.cs +++ b/Engine/Ioxide/Hosting/IoxideServer.cs @@ -93,8 +93,8 @@ internal IoxideServer(ServerConfiguration config, IHandler handler, Func(); diff --git a/Engine/Ioxide/IoxideOptions.cs b/Engine/Ioxide/IoxideOptions.cs index 50474aa7a..b45021322 100644 --- a/Engine/Ioxide/IoxideOptions.cs +++ b/Engine/Ioxide/IoxideOptions.cs @@ -52,67 +52,95 @@ public sealed record IoxideOptions /// public Dictionary ProtocolsByPort { get; init; } = []; + /// The HTTP/3 endpoint: the certificate QUIC serves, and QPACK. + public IoxideHttp3Options Http3 { get; init; } = new(); + + /// Client certificates: what they are validated against, and whether one is required. + public IoxideMutualTlsOptions MutualTls { get; init; } = new(); +} + +/// +/// The HTTP/3 endpoint. +/// +/// +/// Only consulted when a port serves . QUIC's certificate and +/// HTTP/3's QPACK are both here because they configure the same endpoint, even though they belong +/// to different layers of it. +/// +public sealed record IoxideHttp3Options +{ /// /// PEM certificate chain for the HTTP/3 listener, as a path. /// /// /// QUIC is terminated by ngtcp2, which loads PEM from disk rather than taking a certificate - /// object. Setting this and hands it the files directly. + /// object. Setting this and hands it the files directly. /// /// Left null, the certificate bound to the endpoint is exported to a temporary file /// instead - readable only by this user, and deleted on shutdown. That works, but it puts a /// private key on disk for the lifetime of the process, so a deployment that already has PEM /// files should name them here. /// - public string? Http3CertificatePath { get; init; } + public string? CertificatePath { get; init; } + + /// PEM private key for the HTTP/3 listener. Pairs with . + public string? KeyPath { get; init; } - /// PEM private key for the HTTP/3 listener. Pairs with . - public string? Http3KeyPath { get; init; } + /// + /// Bytes of QPACK dynamic table advertised to HTTP/3 clients. + /// + /// + /// 0 keeps every header literal against the static table, which costs bytes but can never stall + /// a stream waiting for a table update. Only browsers advertise a table of their own; every + /// other client measured sends 0, which makes the mechanism inert whatever is set here. + /// + public long QpackDynamicTableCapacity { get; init; } + /// + /// How many HTTP/3 streams may wait on a QPACK table insertion. Only meaningful alongside a + /// nonzero , and the price paid for one. + /// + public long QpackBlockedStreams { get; init; } +} + +/// +/// Mutual TLS, enforced on every protocol. +/// +/// +/// Validation happens where the connection is terminated - by OpenSSL for HTTP/1.1 and HTTP/2, by +/// ngtcp2 for HTTP/3 - so a chain that does not validate is refused before any request exists. +/// +/// WHICH endpoints ask for a certificate is decided per endpoint, by the +/// certificateValidator passed to Bind: an endpoint with one asks, an endpoint +/// without one does not. What lives here is what the offered certificate is checked against, which +/// the whole server shares. +/// +public sealed record IoxideMutualTlsOptions +{ /// /// PEM bundle of trust anchors that client certificates are validated against, as a path. /// /// - /// Mutual TLS is enforced where the connection is terminated - by OpenSSL for HTTP/1.1 and - /// HTTP/2, by ngtcp2 for HTTP/3 - so a chain that does not validate is refused before any - /// request exists. An endpoint bound with a certificateValidator asks for a certificate; - /// this is what the offered one is checked against. - /// - /// The file's subject names are also sent in the CertificateRequest, so a client holding - /// several certificates can pick the one this server accepts rather than guessing. - /// is trusted identically but sends no such hint. + /// The file's subject names are also sent in the CertificateRequest, so a client holding several + /// certificates can pick the one this server accepts rather than guessing. + /// is trusted identically but sends no such hint. /// public string? ClientCaPath { get; init; } - /// The client trust anchors as PEM text - the in-memory alternative to . + /// The trust anchors as PEM text - the in-memory alternative to . public string? ClientCaPem { get; init; } /// - /// Refuse a client that offers no certificate at all. + /// Refuse a client that offers no certificate at all, on every secure endpoint. /// /// /// False asks for one and validates what arrives, but lets a client offering nothing through - /// which is what a server serving both a public and a mutually authenticated route wants, since - /// it can read who connected and decide per request. True is the usual choice for a private API. + /// it can read who connected and decide per request. /// - /// An endpoint's certificateValidator can raise this on its own through - /// RequireCertificate; the two are ORed. + /// Usually left alone: an endpoint's certificateValidator raises this for that + /// endpoint through RequireCertificate, which is how one port requires a certificate + /// while another stays open. The two are ORed. /// public bool RequireClientCertificate { get; init; } - - /// - /// Bytes of QPACK dynamic table advertised to HTTP/3 clients. - /// - /// - /// 0 keeps every header literal against the static table, which costs bytes but can never stall - /// a stream waiting for a table update. Only browsers advertise a table of their own; every - /// other client measured sends 0, which makes the mechanism inert whatever is set here. - /// - public long QpackDynamicTableCapacity { get; init; } - - /// - /// How many HTTP/3 streams may wait on a QPACK table insertion. Only meaningful alongside a - /// nonzero , and the price paid for one. - /// - public long QpackBlockedStreams { get; init; } } diff --git a/Playground/Program.cs b/Playground/Program.cs index 84bc8f8f3..a5c9ed4f5 100644 --- a/Playground/Program.cs +++ b/Playground/Program.cs @@ -1,7 +1,10 @@ using System.Net; +using System.Net.Security; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; +using GenHTTP.Api.Infrastructure; + using GenHTTP.Engine.Ioxide; using GenHTTP.Modules.Files; @@ -18,6 +21,8 @@ // http://localhost:8081 Http2 HTTP/2 only (h2c) - an HTTP/1.1 client is turned away // http://localhost:8082 Http1AndHttp2 both on one socket, the preface decides // https://localhost:8443 All HTTP/1.1 + HTTP/2 over TCP, HTTP/3 over UDP +// https://localhost:8444 Http1 HTTP/1.1 behind mutual TLS - a client certificate is +// required, and one signed by the wrong CA is refused // // dotnet run -c Release --project Playground // @@ -27,6 +32,12 @@ // curl -k --http1.1 https://localhost:8443/ok // curl -k --http2 https://localhost:8443/ok // curl -k --http3-only https://localhost:8443/ok +// curl -k --cert certs/client.crt --key certs/client.key https://localhost:8444/ok +// +// The mutual TLS port needs a client certificate to answer at all, so the sample writes a CA, a +// certificate signed by it and a second signed by nobody into ./certs on startup, and prints the +// commands. Validation happens in OpenSSL against the CA - the endpoint's certificateValidator is +// what marks the port as requiring one. // // Where two protocols share the TCP socket, ALPN decides during the handshake on a secure port and // the HTTP/2 connection preface decides on a plaintext one. HTTP/3 always needs a certificate, @@ -73,6 +84,10 @@ // to serve a real one - a browser will refuse HTTP/3 to a certificate it does not trust. using var certificate = LoadCertificate(); +// A CA, a client it signs, and an impostor it does not - so the mutual TLS port below can be tried +// both ways without generating anything by hand. +var clientCa = WriteClientCertificates(); + await Host.Create( configure: c => c with { ReactorCount = Reactors }, options: new IoxideOptions @@ -85,34 +100,92 @@ await Host.Create( [8081] = IoxideProtocols.Http2, [8082] = IoxideProtocols.Http1AndHttp2, [8443] = IoxideProtocols.All, + [8444] = IoxideProtocols.Http1, + }, + + MutualTls = new IoxideMutualTlsOptions + { + // What an offered client certificate is validated against. WHICH ports ask + // for one is decided per endpoint, by the validator passed to Bind - so 8443 + // stays open while 8444 requires a certificate. + ClientCaPath = clientCa, }, - // Bytes of QPACK dynamic table offered to HTTP/3 clients. 0 keeps every header - // literal, which costs bytes but can never stall a stream on a table update. In - // practice only browsers advertise a table of their own. - QpackDynamicTableCapacity = 4096, - QpackBlockedStreams = 100, - - // HTTP/3 is terminated by ngtcp2, which loads PEM from disk. Name the files here - // and nothing is written; leave them out and the bound certificate is exported to - // an owner-only temporary directory for the lifetime of the process. - // - // Http3CertificatePath = "/etc/ssl/site.crt", - // Http3KeyPath = "/etc/ssl/site.key", - - // Mutual TLS, enforced on all three protocols. Clients are asked for a - // certificate and validated against this bundle. - // - // ClientCaPath = "/etc/ssl/clients.pem", - // RequireClientCertificate = true, + Http3 = new IoxideHttp3Options + { + // Bytes of QPACK dynamic table offered to HTTP/3 clients. 0 keeps every + // header literal, which costs bytes but can never stall a stream on a table + // update. In practice only browsers advertise a table of their own. + QpackDynamicTableCapacity = 4096, + QpackBlockedStreams = 100, + + // ngtcp2 loads PEM from disk. Name the files here and nothing is written; + // leave them out and the bound certificate is exported to an owner-only + // temporary directory for the lifetime of the process. + // + // CertificatePath = "/etc/ssl/site.crt", + // KeyPath = "/etc/ssl/site.key", + }, }) .Handler(app) .Bind(IPAddress.Loopback, 8080) .Bind(IPAddress.Loopback, 8081) .Bind(IPAddress.Loopback, 8082) .Bind(IPAddress.Loopback, 8443, certificate) + // The validator marks this endpoint as requiring a client certificate. Validation itself + // happens in OpenSSL against ClientCaPath above, so Validate below is never called - the + // engine refuses a bad chain before a request exists. + .Bind(IPAddress.Loopback, 8444, certificate, certificateValidator: new RequireClientCertificate()) .RunAsync(); +/// +/// Writes a client CA, a certificate it signs, and one it does not, and returns the CA's path. +/// +/// +/// A mutual TLS endpoint cannot be tried without a client certificate, so the sample makes one +/// rather than asking you to. The impostor exists so the refusal can be seen as well as the pass. +/// +static string WriteClientCertificates() +{ + var directory = Directory.CreateDirectory(Path.Combine(AppContext.BaseDirectory, "certs")); + + // One instant for every certificate here. Reading the clock per certificate gives the CA and + // the client it signs windows a second apart, and a leaf outliving its issuer is refused. + var from = DateTimeOffset.UtcNow.AddDays(-1); + var until = from.AddYears(1); + + using var caKey = RSA.Create(2048); + + var caRequest = new CertificateRequest("CN=playground client CA", caKey, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + caRequest.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true)); + + using var ca = caRequest.CreateSelfSigned(from, until); + + var caPath = Path.Combine(directory.FullName, "client-ca.crt"); + File.WriteAllText(caPath, ca.ExportCertificatePem()); + + Issue(ca, "client", "CN=alice", directory.FullName, from, until); + Issue(null, "impostor", "CN=mallory", directory.FullName, from, until); + + return caPath; + + static void Issue(X509Certificate2? issuer, string name, string subject, string directory, + DateTimeOffset from, DateTimeOffset until) + { + using var key = RSA.Create(2048); + + var request = new CertificateRequest(subject, key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + + // Signed by the CA the server trusts, or self-signed - which is the impostor. + using var certificate = issuer is null + ? request.CreateSelfSigned(from, until) + : request.Create(issuer, from, until, Guid.NewGuid().ToByteArray()); + + File.WriteAllText(Path.Combine(directory, $"{name}.crt"), certificate.ExportCertificatePem()); + File.WriteAllText(Path.Combine(directory, $"{name}.key"), key.ExportPkcs8PrivateKeyPem()); + } +} + static X509Certificate2 LoadCertificate() { if (Environment.GetEnvironmentVariable("GENHTTP_CERT") is { Length: > 0 } path && File.Exists(path)) @@ -135,3 +208,21 @@ static X509Certificate2 LoadCertificate() // The private key has to come back through a PKCS#12 round trip before a TLS stack will use it. return X509CertificateLoader.LoadPkcs12(generated.Export(X509ContentType.Pfx), null); } + +/// +/// Marks an endpoint as requiring a client certificate. +/// +/// +/// The ioxide engine reads and lets OpenSSL (or ngtcp2 on HTTP/3) +/// validate the offered chain against the configured client CA, so a bad chain is refused before a +/// request exists and is never called. Returning true here would not admit +/// anyone the CA had already rejected. +/// +internal sealed class RequireClientCertificate : ICertificateValidator +{ + public bool RequireCertificate => true; + + public X509RevocationMode RevocationCheck => X509RevocationMode.NoCheck; + + public bool Validate(X509Certificate? certificate, X509Chain? chain, SslPolicyErrors policyErrors) => true; +} From 8f0a17d6c18b9afa6b5b972e8785d23ed9585b16 Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Sun, 16 Aug 2026 10:40:37 +0100 Subject: [PATCH 16/55] docs(ioxide): say which certificate HTTP/3 serves The comment explained that ngtcp2 loads PEM from disk without saying whose PEM, which reads as though HTTP/3 needed a certificate of its own. It serves the one bound to its endpoint, the same as HTTP/1.1 and HTTP/2; the paths only change whether that certificate reaches ngtcp2 from files that already exist or from one written out for it, because ngtcp2 has no in-memory alternative and OpenSSL does. --- Engine/Ioxide/IoxideOptions.cs | 7 +++++-- Playground/Program.cs | 15 +++++++++------ 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/Engine/Ioxide/IoxideOptions.cs b/Engine/Ioxide/IoxideOptions.cs index b45021322..acb47a3b2 100644 --- a/Engine/Ioxide/IoxideOptions.cs +++ b/Engine/Ioxide/IoxideOptions.cs @@ -73,8 +73,11 @@ public sealed record IoxideHttp3Options /// PEM certificate chain for the HTTP/3 listener, as a path. /// /// - /// QUIC is terminated by ngtcp2, which loads PEM from disk rather than taking a certificate - /// object. Setting this and hands it the files directly. + /// Not a second certificate: HTTP/3 serves the one bound to its endpoint, exactly as HTTP/1.1 + /// and HTTP/2 do. This changes only how that certificate reaches ngtcp2, which loads PEM from a + /// file and has no in-memory alternative - unlike OpenSSL, which terminates the TCP protocols + /// and takes the PEM text directly. Setting this and hands it files that + /// already exist. /// /// Left null, the certificate bound to the endpoint is exported to a temporary file /// instead - readable only by this user, and deleted on shutdown. That works, but it puts a diff --git a/Playground/Program.cs b/Playground/Program.cs index a5c9ed4f5..4ed29dd49 100644 --- a/Playground/Program.cs +++ b/Playground/Program.cs @@ -119,9 +119,14 @@ await Host.Create( QpackDynamicTableCapacity = 4096, QpackBlockedStreams = 100, - // ngtcp2 loads PEM from disk. Name the files here and nothing is written; - // leave them out and the bound certificate is exported to an owner-only - // temporary directory for the lifetime of the process. + // HTTP/3 serves the SAME certificate as the rest of the endpoint - the one + // passed to Bind below. These two only change how it reaches ngtcp2, which + // loads PEM from a file and has no in-memory alternative (OpenSSL, which + // terminates HTTP/1.1 and HTTP/2, takes the PEM text directly and touches no + // disk). Name the files and they are used as they are; leave them out and the + // bound certificate is written to an owner-only temporary directory, deleted + // on shutdown - which works, but puts a private key on disk for the lifetime + // of the process. // // CertificatePath = "/etc/ssl/site.crt", // KeyPath = "/etc/ssl/site.key", @@ -132,9 +137,7 @@ await Host.Create( .Bind(IPAddress.Loopback, 8081) .Bind(IPAddress.Loopback, 8082) .Bind(IPAddress.Loopback, 8443, certificate) - // The validator marks this endpoint as requiring a client certificate. Validation itself - // happens in OpenSSL against ClientCaPath above, so Validate below is never called - the - // engine refuses a bad chain before a request exists. + // mTLS .Bind(IPAddress.Loopback, 8444, certificate, certificateValidator: new RequireClientCertificate()) .RunAsync(); From c72045e9c7b542660510912ec558695b40b5765b Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Sun, 16 Aug 2026 12:07:32 +0100 Subject: [PATCH 17/55] fix(ioxide): warn when the HTTP/3 certificate is not the endpoint's Http3.CertificatePath exists to hand ngtcp2 a file, since it loads PEM from disk and has no in-memory alternative - unlike OpenSSL, which terminates the TCP protocols and takes the PEM text directly. Nothing stopped those paths naming a DIFFERENT certificate, and then the same port answered as one host over TCP and another over QUIC, silently. Confirmed on one endpoint: h1/h2: subject=CN = localhost h3: subject: CN=DIFFERENT-h3-identity That breaks the reason the two share a port. A browser moving from HTTP/1.1 to HTTP/3 by an Alt-Svc header expects the alternative to present a certificate valid for the ORIGIN (RFC 7838 3.1), so it would refuse the upgrade - or not notice. Compared by leaf thumbprint, so a file carrying a fuller chain than the bound certificate is not flagged. A warning rather than a refusal: someone may be doing it deliberately, and this is not the place to decide they cannot. The comparison reads the PEM text rather than calling CreateFromPemFile, which wants a private key beside the certificate and throws on the certificate-only file this usually is - the first version of this check threw every time and logged it at Debug, so it looked like the warning simply never fired. --- Engine/Ioxide/Hosting/IoxideServer.Quic.cs | 44 ++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/Engine/Ioxide/Hosting/IoxideServer.Quic.cs b/Engine/Ioxide/Hosting/IoxideServer.Quic.cs index cf1b0f79d..0c43a6c1e 100644 --- a/Engine/Ioxide/Hosting/IoxideServer.Quic.cs +++ b/Engine/Ioxide/Hosting/IoxideServer.Quic.cs @@ -81,6 +81,8 @@ private bool TryResolveQuicCertificate(Shared.Infrastructure.SecurityConfigurati return false; } + WarnIfNotTheBoundCertificate(configuredCert, security, port); + certPath = configuredCert; keyPath = configuredKey; return true; @@ -116,6 +118,48 @@ private bool TryResolveQuicCertificate(Shared.Infrastructure.SecurityConfigurati return true; } + /// + /// Warns when the configured PEM is not the certificate bound to this endpoint. + /// + /// + /// These paths exist to hand ngtcp2 a file rather than to give HTTP/3 an identity of its own, + /// and nothing stops them doing the latter: the port would then answer as one host over TCP and + /// another over QUIC. A browser moving from HTTP/1.1 to HTTP/3 by an Alt-Svc header expects the + /// alternative to present a certificate valid for the ORIGIN (RFC 7838 3.1), so it would refuse + /// the upgrade - or worse, not notice. + /// + /// Compared by leaf thumbprint, so a file carrying a fuller chain than the bound + /// certificate is not flagged. A warning rather than a refusal: someone may be deliberately + /// serving a different certificate, and this is not the place to decide they cannot. + /// + private void WarnIfNotTheBoundCertificate(string configuredCert, Shared.Infrastructure.SecurityConfiguration security, ushort port) + { + if (security.CertificateProvider.Provide(null) is not { } bound) + { + return; + } + + try + { + // From the PEM text, not CreateFromPemFile - that one wants a private key alongside + // the certificate and throws on a certificate-only file, which is what this usually is. + using var configured = X509Certificate2.CreateFromPem(File.ReadAllText(configuredCert)); + + if (!string.Equals(configured.Thumbprint, bound.Thumbprint, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogWarning( + "The HTTP/3 certificate configured for port {Port} ({ConfiguredSubject}) is not the one bound to that endpoint ({BoundSubject}). " + + "The port will answer as one host over TCP and another over QUIC, and a browser following an Alt-Svc advertisement expects them to match.", + port, configured.Subject, bound.Subject); + } + } + catch (Exception e) + { + // Only the comparison failed; ngtcp2 will report a certificate it cannot load itself. + _logger.LogDebug(e, "Could not compare the configured HTTP/3 certificate at {Path} with the bound one", configuredCert); + } + } + private static void WriteOwnerOnly(string path, string content) { var options = new FileStreamOptions From aa4bc126f64ded66459800d949f8b67a62417c3c Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Sun, 16 Aug 2026 12:24:19 +0100 Subject: [PATCH 18/55] fix(playground): owner-only private keys, and no key left in /tmp The sample wrote every generated key with File.WriteAllText, which takes the umask - so client.key and impostor.key landed world-readable. Throwaways, but a sample is read as an example of how to do it, and the engine's own export next to them was already 0600. It also left the HTTP/3 certificate paths unset, so the engine exported the bound certificate to a temporary directory. That works and is owner-only, but the copy outlives a SIGKILL - repeated restarts leave a private key per run under /tmp. The sample now writes its certificate to ./certs and names it, which removes the export entirely and demonstrates the option worth using in a deployment. /tmp/genhttp-ioxide-* gone, 0 export log lines certs/*.key -rw------- certs/*.crt -rw-rw-r-- (public, unchanged) All four still answer: h1 1.1, h2 2, h3 3, and mutual TLS on 8444 with the signed client. --- Playground/Program.cs | 52 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 49 insertions(+), 3 deletions(-) diff --git a/Playground/Program.cs b/Playground/Program.cs index 4ed29dd49..09105791f 100644 --- a/Playground/Program.cs +++ b/Playground/Program.cs @@ -84,6 +84,12 @@ // to serve a real one - a browser will refuse HTTP/3 to a certificate it does not trust. using var certificate = LoadCertificate(); +// Written out and named below, so the HTTP/3 listener loads it from here rather than having the +// engine export a copy into a temporary directory. Same certificate either way - this is only +// about where ngtcp2 reads it from, and a key under ./certs beats one in /tmp that outlives a +// SIGKILL. +var (serverCertPath, serverKeyPath) = WriteServerCertificate(certificate); + // A CA, a client it signs, and an impostor it does not - so the mutual TLS port below can be tried // both ways without generating anything by hand. var clientCa = WriteClientCertificates(); @@ -128,8 +134,8 @@ await Host.Create( // on shutdown - which works, but puts a private key on disk for the lifetime // of the process. // - // CertificatePath = "/etc/ssl/site.crt", - // KeyPath = "/etc/ssl/site.key", + CertificatePath = serverCertPath, + KeyPath = serverKeyPath, }, }) .Handler(app) @@ -141,6 +147,24 @@ await Host.Create( .Bind(IPAddress.Loopback, 8444, certificate, certificateValidator: new RequireClientCertificate()) .RunAsync(); +/// +/// Writes the server certificate as PEM, so ngtcp2 can load it by path. +/// +static (string Certificate, string Key) WriteServerCertificate(X509Certificate2 certificate) +{ + var directory = Directory.CreateDirectory(Path.Combine(AppContext.BaseDirectory, "certs")); + + var certPath = Path.Combine(directory.FullName, "server.crt"); + var keyPath = Path.Combine(directory.FullName, "server.key"); + + File.WriteAllText(certPath, certificate.ExportCertificatePem()); + + WritePrivateKey(keyPath, certificate.GetRSAPrivateKey()?.ExportPkcs8PrivateKeyPem() + ?? throw new InvalidOperationException("The development certificate carries no RSA private key.")); + + return (certPath, keyPath); +} + /// /// Writes a client CA, a certificate it signs, and one it does not, and returns the CA's path. /// @@ -185,7 +209,7 @@ static void Issue(X509Certificate2? issuer, string name, string subject, string : request.Create(issuer, from, until, Guid.NewGuid().ToByteArray()); File.WriteAllText(Path.Combine(directory, $"{name}.crt"), certificate.ExportCertificatePem()); - File.WriteAllText(Path.Combine(directory, $"{name}.key"), key.ExportPkcs8PrivateKeyPem()); + WritePrivateKey(Path.Combine(directory, $"{name}.key"), key.ExportPkcs8PrivateKeyPem()); } } @@ -212,6 +236,28 @@ static X509Certificate2 LoadCertificate() return X509CertificateLoader.LoadPkcs12(generated.Export(X509ContentType.Pfx), null); } +/// +/// Writes a private key readable only by this user. +/// +/// +/// File.WriteAllText takes the umask, which on most machines leaves a key world-readable. These are +/// throwaways, but a sample is read as an example of how to do it. +/// +static void WritePrivateKey(string path, string pem) +{ + var options = new FileStreamOptions { Mode = FileMode.Create, Access = FileAccess.Write }; + + if (!OperatingSystem.IsWindows()) + { + options.UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite; + } + + using var stream = new FileStream(path, options); + using var writer = new StreamWriter(stream); + + writer.Write(pem); +} + /// /// Marks an endpoint as requiring a client certificate. /// From e427a54b447c218e28cabab55eac9434b4e57848 Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Sun, 16 Aug 2026 12:27:07 +0100 Subject: [PATCH 19/55] fix(ioxide): the engine no longer writes a certificate out for HTTP/3 ngtcp2 loads PEM by path, which is the C layer's contract and fine. Working around it was not: an endpoint serving HTTP/3 without configured paths had the bound certificate exported to a temporary directory, so the engine chose a location and a lifetime for someone else's private key. Owner-only, deleted on shutdown - and still there after any shutdown that skips cleanup, one directory per run. Http3.CertificatePath and Http3.KeyPath are required to serve HTTP/3 now. Without them the endpoint is a configuration error, named and explained, rather than a key appearing under /tmp: Port 8443 serves HTTP/3, which needs a PEM certificate and key on disk - ngtcp2 loads them by path. Set IoxideOptions.Http3.CertificatePath and Http3.KeyPath to the same certificate bound to that endpoint. That removes the export, the owner-only temp directory, the writer that made it and the cleanup that chased it - about sixty lines. The check that the configured PEM is actually the endpoint's certificate stays, since naming the wrong one is still possible and still leaves a port answering as two hosts. The sample writes its own throwaway certificate to ./certs and names it, which is what a deployment does with the PEM it already has. --- Engine/Ioxide/Hosting/IoxideServer.Quic.cs | 103 ++++----------------- Engine/Ioxide/IoxideOptions.cs | 19 ++-- Playground/Program.cs | 19 ++-- 3 files changed, 34 insertions(+), 107 deletions(-) diff --git a/Engine/Ioxide/Hosting/IoxideServer.Quic.cs b/Engine/Ioxide/Hosting/IoxideServer.Quic.cs index 0c43a6c1e..a3246cb92 100644 --- a/Engine/Ioxide/Hosting/IoxideServer.Quic.cs +++ b/Engine/Ioxide/Hosting/IoxideServer.Quic.cs @@ -16,11 +16,6 @@ public sealed partial class IoxideServer private IoxideEndPoint? _quicEndPoint; - // Only set when a certificate had to be written out; a user-supplied path is never touched. - private string? _exportedCertPath; - - private string? _exportedKeyPath; - /// /// Adds the QUIC listener for the endpoint bound with enableQuic. /// @@ -60,61 +55,37 @@ private ServerConfig WithQuic(ServerConfig cfg, IoxideEndPoint endPoint) } /// - /// The PEM files ngtcp2 loads: the ones configured, or the bound certificate written out. + /// The PEM files ngtcp2 loads. Configured, or HTTP/3 does not start. /// /// - /// ngtcp2 takes paths, not a certificate object, so one of the two has to happen. A configured - /// path is used as it is and nothing is written. Otherwise the endpoint's certificate is - /// exported to a file this user alone can read, which does put a private key on disk for the - /// lifetime of the process - so a deployment holding PEM files should name them through - /// and skip this entirely. + /// ngtcp2 takes paths rather than a certificate object, so serving HTTP/3 needs PEM on disk. + /// That is the C layer's contract and this honours it rather than working around it: the engine + /// will not write a private key out on your behalf, because a key it creates is a key it has to + /// choose a location and a lifetime for, and one written to a temporary directory outlives any + /// shutdown that skips cleanup. /// private bool TryResolveQuicCertificate(Shared.Infrastructure.SecurityConfiguration security, ushort port, out string certPath, out string keyPath) { - if (_options.Http3.CertificatePath is { } configuredCert && _options.Http3.KeyPath is { } configuredKey) - { - if (!File.Exists(configuredCert) || !File.Exists(configuredKey)) - { - _logger.LogError("The configured HTTP/3 certificate or key does not exist ({Certificate}, {Key}); no listener was started.", configuredCert, configuredKey); - certPath = keyPath = string.Empty; - return false; - } + certPath = keyPath = string.Empty; - WarnIfNotTheBoundCertificate(configuredCert, security, port); - - certPath = configuredCert; - keyPath = configuredKey; - return true; - } - - if (security.CertificateProvider.Provide(null) is not { } certificate) + if (_options.Http3.CertificatePath is not { } configuredCert || _options.Http3.KeyPath is not { } configuredKey) { - _logger.LogWarning("No default certificate for port {Port}; no HTTP/3 listener was started.", port); - certPath = keyPath = string.Empty; - return false; + throw new InvalidOperationException( + $"Port {port} serves HTTP/3, which needs a PEM certificate and key on disk - ngtcp2 loads them by path. " + + "Set IoxideOptions.Http3.CertificatePath and Http3.KeyPath to the same certificate bound to that endpoint."); } - var directory = Directory.CreateTempSubdirectory("genhttp-ioxide-"); - - // Owner-only, set before anything is written: the key must never exist world-readable, not - // even for the moment between creating the file and tightening it. The engine only runs on - // Linux (io_uring), but the file APIs are cross-platform and the analyzer checks them. - if (!OperatingSystem.IsWindows()) + if (!File.Exists(configuredCert) || !File.Exists(configuredKey)) { - directory.UnixFileMode = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute; + _logger.LogError("The configured HTTP/3 certificate or key does not exist ({Certificate}, {Key}); no listener was started.", configuredCert, configuredKey); + return false; } - _exportedCertPath = Path.Combine(directory.FullName, "quic.crt"); - _exportedKeyPath = Path.Combine(directory.FullName, "quic.key"); - - WriteOwnerOnly(_exportedCertPath, certificate.ExportCertificatePem()); - WriteOwnerOnly(_exportedKeyPath, ExportKeyPem(certificate)); - - _logger.LogInformation("Exported the certificate bound to port {Port} to {Directory} for the HTTP/3 listener; set Http3CertificatePath to avoid writing a key to disk.", port, directory.FullName); + WarnIfNotTheBoundCertificate(configuredCert, security, port); - certPath = _exportedCertPath; - keyPath = _exportedKeyPath; + certPath = configuredCert; + keyPath = configuredKey; return true; } @@ -160,50 +131,12 @@ private void WarnIfNotTheBoundCertificate(string configuredCert, Shared.Infrastr } } - private static void WriteOwnerOnly(string path, string content) - { - var options = new FileStreamOptions - { - Mode = FileMode.CreateNew, - Access = FileAccess.Write, - }; - - if (!OperatingSystem.IsWindows()) - { - options.UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite; - } - - using var stream = new FileStream(path, options); - using var writer = new StreamWriter(stream); - - writer.Write(content); - } - /// - /// Drops the QUIC engine and anything that was written out for it. + /// Drops the QUIC engine. Nothing was written for it, so nothing is cleaned up. /// private void DisposeQuic() { _quic?.Dispose(); _quic = null; - - if (_exportedCertPath is null) - { - return; - } - - try - { - Directory.Delete(Path.GetDirectoryName(_exportedCertPath)!, recursive: true); - } - catch (IOException e) - { - // Best effort. A leftover key in a temp directory is worth a line in the log, but not a - // failed shutdown - it is owner-only and the directory name is unique to this process. - _logger.LogWarning(e, "Could not remove the exported HTTP/3 certificate at {Path}", _exportedCertPath); - } - - _exportedCertPath = null; - _exportedKeyPath = null; } } diff --git a/Engine/Ioxide/IoxideOptions.cs b/Engine/Ioxide/IoxideOptions.cs index acb47a3b2..9687c9fa7 100644 --- a/Engine/Ioxide/IoxideOptions.cs +++ b/Engine/Ioxide/IoxideOptions.cs @@ -70,19 +70,18 @@ public sealed record IoxideOptions public sealed record IoxideHttp3Options { /// - /// PEM certificate chain for the HTTP/3 listener, as a path. + /// PEM certificate chain for the HTTP/3 listener, as a path. Required to serve HTTP/3. /// /// - /// Not a second certificate: HTTP/3 serves the one bound to its endpoint, exactly as HTTP/1.1 - /// and HTTP/2 do. This changes only how that certificate reaches ngtcp2, which loads PEM from a - /// file and has no in-memory alternative - unlike OpenSSL, which terminates the TCP protocols - /// and takes the PEM text directly. Setting this and hands it files that - /// already exist. + /// Not a second certificate: this should be the one bound to the endpoint, which is what + /// HTTP/1.1 and HTTP/2 serve there. It is named separately because ngtcp2 loads PEM by path and + /// has no in-memory alternative - unlike OpenSSL, which terminates the TCP protocols and takes + /// the PEM text directly. /// - /// Left null, the certificate bound to the endpoint is exported to a temporary file - /// instead - readable only by this user, and deleted on shutdown. That works, but it puts a - /// private key on disk for the lifetime of the process, so a deployment that already has PEM - /// files should name them here. + /// The engine will not write one out for you. A key it creates is a key it has to choose + /// a location and a lifetime for, and one written to a temporary directory outlives any + /// shutdown that skips cleanup - so an endpoint asking for HTTP/3 without this is a + /// configuration error rather than something to paper over. /// public string? CertificatePath { get; init; } diff --git a/Playground/Program.cs b/Playground/Program.cs index 09105791f..2f233bf9a 100644 --- a/Playground/Program.cs +++ b/Playground/Program.cs @@ -84,10 +84,9 @@ // to serve a real one - a browser will refuse HTTP/3 to a certificate it does not trust. using var certificate = LoadCertificate(); -// Written out and named below, so the HTTP/3 listener loads it from here rather than having the -// engine export a copy into a temporary directory. Same certificate either way - this is only -// about where ngtcp2 reads it from, and a key under ./certs beats one in /tmp that outlives a -// SIGKILL. +// ngtcp2 loads PEM by path, so serving HTTP/3 means having the certificate on disk. The engine will +// not write one out on your behalf, so the sample writes its own throwaway certificate here and +// names it below. A deployment points at the PEM it already has. var (serverCertPath, serverKeyPath) = WriteServerCertificate(certificate); // A CA, a client it signs, and an impostor it does not - so the mutual TLS port below can be tried @@ -125,14 +124,10 @@ await Host.Create( QpackDynamicTableCapacity = 4096, QpackBlockedStreams = 100, - // HTTP/3 serves the SAME certificate as the rest of the endpoint - the one - // passed to Bind below. These two only change how it reaches ngtcp2, which - // loads PEM from a file and has no in-memory alternative (OpenSSL, which - // terminates HTTP/1.1 and HTTP/2, takes the PEM text directly and touches no - // disk). Name the files and they are used as they are; leave them out and the - // bound certificate is written to an owner-only temporary directory, deleted - // on shutdown - which works, but puts a private key on disk for the lifetime - // of the process. + // The SAME certificate passed to Bind below, named again because ngtcp2 + // loads PEM by path - OpenSSL, which terminates HTTP/1.1 and HTTP/2, takes + // the PEM text directly and touches no disk. Required for HTTP/3: the engine + // refuses to start a QUIC listener without it rather than writing a key out. // CertificatePath = serverCertPath, KeyPath = serverKeyPath, From 3c907279fcbb6cf844508a91dca02a827445803f Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Sun, 16 Aug 2026 13:14:27 +0100 Subject: [PATCH 20/55] refactor(ioxide): name the shared HTTP/2 and HTTP/3 code for what it is "Mux" was jargon for the one thing HTTP/2 and HTTP/3 have in common, and it read as though the folder were a protocol of its own. Splitting it into Http2 and Http3 folders was the obvious alternative and does not work: 648 of those lines are used verbatim by both protocols against 179 in the drivers, which themselves differ by 39 lines once the protocol names are normalised. Splitting would either duplicate the 648 or leave a third shared folder anyway - the same shape under another name. So the folder is Multiplexed, which says why the code is shared, and the two drivers move up beside ConnectionDriver, the HTTP/1.1 one. Each protocol now has its driver in Protocol/ and the request and response bridge they share sits in Protocol/Multiplexed/. Types renamed to match. No behaviour change: h1 1.1, h2c 2, h2 2, h3 3, mutual TLS 200, acceptance 1442. --- Engine/Ioxide/Hosting/IoxideServer.cs | 2 +- Engine/Ioxide/Protocol/ConnectionDriver.cs | 2 +- Engine/Ioxide/Protocol/{Mux => }/Http2Driver.cs | 12 +++++++----- Engine/Ioxide/Protocol/{Mux => }/Http3Driver.cs | 12 +++++++----- .../MultiplexedKeyValueList.cs} | 6 +++--- .../MultiplexedRequest.cs} | 12 ++++++------ .../MultiplexedRequestBody.cs} | 6 +++--- .../MultiplexedRequestHeader.cs} | 14 +++++++------- .../MultiplexedResponder.cs} | 14 +++++++------- .../MuxSink.cs => Multiplexed/MultiplexedSink.cs} | 6 +++--- 10 files changed, 45 insertions(+), 41 deletions(-) rename Engine/Ioxide/Protocol/{Mux => }/Http2Driver.cs (88%) rename Engine/Ioxide/Protocol/{Mux => }/Http3Driver.cs (89%) rename Engine/Ioxide/Protocol/{Mux/MuxKeyValueList.cs => Multiplexed/MultiplexedKeyValueList.cs} (77%) rename Engine/Ioxide/Protocol/{Mux/MuxRequest.cs => Multiplexed/MultiplexedRequest.cs} (88%) rename Engine/Ioxide/Protocol/{Mux/MuxRequestBody.cs => Multiplexed/MultiplexedRequestBody.cs} (95%) rename Engine/Ioxide/Protocol/{Mux/MuxRequestHeader.cs => Multiplexed/MultiplexedRequestHeader.cs} (87%) rename Engine/Ioxide/Protocol/{Mux/MuxResponder.cs => Multiplexed/MultiplexedResponder.cs} (90%) rename Engine/Ioxide/Protocol/{Mux/MuxSink.cs => Multiplexed/MultiplexedSink.cs} (94%) diff --git a/Engine/Ioxide/Hosting/IoxideServer.cs b/Engine/Ioxide/Hosting/IoxideServer.cs index 7ec6865aa..c557b2e59 100644 --- a/Engine/Ioxide/Hosting/IoxideServer.cs +++ b/Engine/Ioxide/Hosting/IoxideServer.cs @@ -5,7 +5,7 @@ using GenHTTP.Api.Infrastructure; using GenHTTP.Engine.Ioxide.Protocol; -using GenHTTP.Engine.Ioxide.Protocol.Mux; +using GenHTTP.Engine.Ioxide.Protocol.Multiplexed; using GenHTTP.Engine.Shared.Infrastructure; using GenHTTP.Engine.Shared.Types; diff --git a/Engine/Ioxide/Protocol/ConnectionDriver.cs b/Engine/Ioxide/Protocol/ConnectionDriver.cs index c0e9b3bb2..f950237fc 100644 --- a/Engine/Ioxide/Protocol/ConnectionDriver.cs +++ b/Engine/Ioxide/Protocol/ConnectionDriver.cs @@ -6,7 +6,7 @@ using GenHTTP.Api.Protocol; using GenHTTP.Engine.Ioxide; -using GenHTTP.Engine.Ioxide.Protocol.Mux; +using GenHTTP.Engine.Ioxide.Protocol.Multiplexed; using GenHTTP.Engine.Shared.Types; using Glyph11.Parser; diff --git a/Engine/Ioxide/Protocol/Mux/Http2Driver.cs b/Engine/Ioxide/Protocol/Http2Driver.cs similarity index 88% rename from Engine/Ioxide/Protocol/Mux/Http2Driver.cs rename to Engine/Ioxide/Protocol/Http2Driver.cs index a8517d9a2..b14b9b8b1 100644 --- a/Engine/Ioxide/Protocol/Mux/Http2Driver.cs +++ b/Engine/Ioxide/Protocol/Http2Driver.cs @@ -8,7 +8,9 @@ using Microsoft.Extensions.Logging; -namespace GenHTTP.Engine.Ioxide.Protocol.Mux; +using GenHTTP.Engine.Ioxide.Protocol.Multiplexed; + +namespace GenHTTP.Engine.Ioxide.Protocol; /// /// Serves an HTTP/2 connection: ioxide.http2 owns framing, HPACK and flow control, this maps each @@ -51,13 +53,13 @@ private static async ValueTask DispatchAsync(IServer server, IEndPoint endPoint, var reader = request.BodyReader; - await using var mapped = new MuxRequest(server, endPoint, request.Method, request.Path, request.Authority, + await using var mapped = new MultiplexedRequest(server, endPoint, request.Method, request.Path, request.Authority, headers, reader is null ? null : reader.ReadAsync, remoteAddress, HttpProtocol.Http2, secure); var response = await server.Handler.HandleAsync(mapped) ?? throw new InvalidOperationException("The root request handler did not return a response"); - var data = MuxResponder.BuildHeaders(response); + var data = MultiplexedResponder.BuildHeaders(response); var head = new Http2Response { Status = data.Status }; @@ -68,11 +70,11 @@ private static async ValueTask DispatchAsync(IServer server, IEndPoint endPoint, writer.WriteHeaders(head); - await MuxResponder.WriteBodyAsync(response, writer, writer.FlushAsync, headRequest); + await MultiplexedResponder.WriteBodyAsync(response, writer, writer.FlushAsync, headRequest); } catch (Exception e) { - server.Logging.CreateLogger("GenHTTP.Engine.Ioxide.Protocol.Mux.Http2Driver") + server.Logging.CreateLogger("GenHTTP.Engine.Ioxide.Protocol.Http2Driver") .LogError(e, "Failed to handle HTTP/2 request"); if (!writer.IsCompleted) diff --git a/Engine/Ioxide/Protocol/Mux/Http3Driver.cs b/Engine/Ioxide/Protocol/Http3Driver.cs similarity index 89% rename from Engine/Ioxide/Protocol/Mux/Http3Driver.cs rename to Engine/Ioxide/Protocol/Http3Driver.cs index 7af6c73e1..ffeb39cf5 100644 --- a/Engine/Ioxide/Protocol/Mux/Http3Driver.cs +++ b/Engine/Ioxide/Protocol/Http3Driver.cs @@ -6,7 +6,9 @@ using Microsoft.Extensions.Logging; -namespace GenHTTP.Engine.Ioxide.Protocol.Mux; +using GenHTTP.Engine.Ioxide.Protocol.Multiplexed; + +namespace GenHTTP.Engine.Ioxide.Protocol; /// /// Serves an HTTP/3 connection: ngtcp2 carries QUIC, nghttp3 carries HTTP/3 and QPACK, this maps @@ -52,13 +54,13 @@ private static async ValueTask DispatchAsync(IServer server, IEndPoint endPoint, // The client address stays null - ioxide's QuicConnection tracks the peer address (it has // to, for path validation) but exposes no way to read it, and a QUIC peer may migrate // mid-connection anyway. - await using var mapped = new MuxRequest(server, endPoint, request.Method, request.Path, request.Authority, + await using var mapped = new MultiplexedRequest(server, endPoint, request.Method, request.Path, request.Authority, headers, reader is null ? null : reader.ReadAsync, remoteAddress: null, HttpProtocol.Http3, secure: true); var response = await server.Handler.HandleAsync(mapped) ?? throw new InvalidOperationException("The root request handler did not return a response"); - var data = MuxResponder.BuildHeaders(response); + var data = MultiplexedResponder.BuildHeaders(response); var head = new Nghttp3Response { Status = data.Status }; @@ -69,11 +71,11 @@ private static async ValueTask DispatchAsync(IServer server, IEndPoint endPoint, writer.WriteHeaders(head); - await MuxResponder.WriteBodyAsync(response, writer, writer.FlushAsync, headRequest); + await MultiplexedResponder.WriteBodyAsync(response, writer, writer.FlushAsync, headRequest); } catch (Exception e) { - server.Logging.CreateLogger("GenHTTP.Engine.Ioxide.Protocol.Mux.Http3Driver") + server.Logging.CreateLogger("GenHTTP.Engine.Ioxide.Protocol.Http3Driver") .LogError(e, "Failed to handle HTTP/3 request"); if (!writer.IsCompleted) diff --git a/Engine/Ioxide/Protocol/Mux/MuxKeyValueList.cs b/Engine/Ioxide/Protocol/Multiplexed/MultiplexedKeyValueList.cs similarity index 77% rename from Engine/Ioxide/Protocol/Mux/MuxKeyValueList.cs rename to Engine/Ioxide/Protocol/Multiplexed/MultiplexedKeyValueList.cs index 75fdbb16d..c3944f1d4 100644 --- a/Engine/Ioxide/Protocol/Mux/MuxKeyValueList.cs +++ b/Engine/Ioxide/Protocol/Multiplexed/MultiplexedKeyValueList.cs @@ -1,6 +1,6 @@ using GenHTTP.Api.Protocol; -namespace GenHTTP.Engine.Ioxide.Protocol.Mux; +namespace GenHTTP.Engine.Ioxide.Protocol.Multiplexed; /// /// Header and query lists over fields a multiplexed protocol has already decoded. @@ -9,11 +9,11 @@ namespace GenHTTP.Engine.Ioxide.Protocol.Mux; /// The shared list wraps Glyph11's parse output and so assumes HTTP/1.1. HPACK and QPACK hand over /// name/value pairs instead, with no request line and no raw header block to point back at. /// -internal sealed class MuxKeyValueList : IRequestHeaders, IRequestQuery +internal sealed class MultiplexedKeyValueList : IRequestHeaders, IRequestQuery { private readonly List<(ReadOnlyMemory Name, ReadOnlyMemory Value)> _entries; - internal MuxKeyValueList(List<(ReadOnlyMemory Name, ReadOnlyMemory Value)> entries) + internal MultiplexedKeyValueList(List<(ReadOnlyMemory Name, ReadOnlyMemory Value)> entries) { _entries = entries; } diff --git a/Engine/Ioxide/Protocol/Mux/MuxRequest.cs b/Engine/Ioxide/Protocol/Multiplexed/MultiplexedRequest.cs similarity index 88% rename from Engine/Ioxide/Protocol/Mux/MuxRequest.cs rename to Engine/Ioxide/Protocol/Multiplexed/MultiplexedRequest.cs index 580ebd99b..2cf0b6fd1 100644 --- a/Engine/Ioxide/Protocol/Mux/MuxRequest.cs +++ b/Engine/Ioxide/Protocol/Multiplexed/MultiplexedRequest.cs @@ -5,7 +5,7 @@ using GenHTTP.Api.Protocol; using GenHTTP.Engine.Shared.Types; -namespace GenHTTP.Engine.Ioxide.Protocol.Mux; +namespace GenHTTP.Engine.Ioxide.Protocol.Multiplexed; /// /// An over a request decoded by HPACK or QPACK. @@ -16,9 +16,9 @@ namespace GenHTTP.Engine.Ioxide.Protocol.Mux; /// several of these are live on one connection at once and a per-connection pool would need locking /// to be safe - which is exactly what the reactor model is trying to avoid. /// -internal sealed class MuxRequest : IRequest +internal sealed class MultiplexedRequest : IRequest { - private readonly MuxRequestBody? _body; + private readonly MultiplexedRequestBody? _body; private readonly ClientConnection _client = new(); @@ -30,16 +30,16 @@ internal sealed class MuxRequest : IRequest private bool _bodyFetched; - internal MuxRequest(IServer server, IEndPoint endPoint, ReadOnlyMemory method, ReadOnlyMemory path, + internal MultiplexedRequest(IServer server, IEndPoint endPoint, ReadOnlyMemory method, ReadOnlyMemory path, ReadOnlyMemory authority, List<(ReadOnlyMemory Name, ReadOnlyMemory Value)> headers, Func>>? read, IPAddress? remoteAddress, HttpProtocol protocol, bool secure) { Server = server; EndPoint = endPoint; - Header = new MuxRequestHeader(method, path, authority, headers, ParseQuery(path), protocol); + Header = new MultiplexedRequestHeader(method, path, authority, headers, ParseQuery(path), protocol); - _body = read is null ? null : new MuxRequestBody(read); + _body = read is null ? null : new MultiplexedRequestBody(read); _client.Apply(remoteAddress, secure ? ClientProtocol.Https : ClientProtocol.Http, null); } diff --git a/Engine/Ioxide/Protocol/Mux/MuxRequestBody.cs b/Engine/Ioxide/Protocol/Multiplexed/MultiplexedRequestBody.cs similarity index 95% rename from Engine/Ioxide/Protocol/Mux/MuxRequestBody.cs rename to Engine/Ioxide/Protocol/Multiplexed/MultiplexedRequestBody.cs index e0a5dcdf9..ecaf1a2b4 100644 --- a/Engine/Ioxide/Protocol/Mux/MuxRequestBody.cs +++ b/Engine/Ioxide/Protocol/Multiplexed/MultiplexedRequestBody.cs @@ -1,6 +1,6 @@ using GenHTTP.Api.Protocol; -namespace GenHTTP.Engine.Ioxide.Protocol.Mux; +namespace GenHTTP.Engine.Ioxide.Protocol.Multiplexed; /// /// A request body pulled from the protocol layer as it arrives. @@ -13,11 +13,11 @@ namespace GenHTTP.Engine.Ioxide.Protocol.Mux; /// The read delegate abstracts the two body readers, which have the same shape but come from /// different packages. It returns empty once the request stream has ended. /// -internal sealed class MuxRequestBody : IRequestBody +internal sealed class MultiplexedRequestBody : IRequestBody { private readonly Func>> _read; - internal MuxRequestBody(Func>> read) + internal MultiplexedRequestBody(Func>> read) { _read = read; } diff --git a/Engine/Ioxide/Protocol/Mux/MuxRequestHeader.cs b/Engine/Ioxide/Protocol/Multiplexed/MultiplexedRequestHeader.cs similarity index 87% rename from Engine/Ioxide/Protocol/Mux/MuxRequestHeader.cs rename to Engine/Ioxide/Protocol/Multiplexed/MultiplexedRequestHeader.cs index 8e3acc7c1..a7ad76981 100644 --- a/Engine/Ioxide/Protocol/Mux/MuxRequestHeader.cs +++ b/Engine/Ioxide/Protocol/Multiplexed/MultiplexedRequestHeader.cs @@ -1,27 +1,27 @@ using GenHTTP.Api.Protocol; using GenHTTP.Engine.Shared.Types; -namespace GenHTTP.Engine.Ioxide.Protocol.Mux; +namespace GenHTTP.Engine.Ioxide.Protocol.Multiplexed; /// /// An over the pseudo-headers a multiplexed protocol carries. /// -internal sealed class MuxRequestHeader : IRequestHeader +internal sealed class MultiplexedRequestHeader : IRequestHeader { private static readonly ReadOnlyMemory HostName = "host"u8.ToArray(); - private readonly MuxKeyValueList _headers; + private readonly MultiplexedKeyValueList _headers; - private readonly MuxKeyValueList _query; + private readonly MultiplexedKeyValueList _query; private readonly RequestTarget _target; - internal MuxRequestHeader(ReadOnlyMemory method, ReadOnlyMemory path, ReadOnlyMemory authority, + internal MultiplexedRequestHeader(ReadOnlyMemory method, ReadOnlyMemory path, ReadOnlyMemory authority, List<(ReadOnlyMemory Name, ReadOnlyMemory Value)> headers, List<(ReadOnlyMemory Name, ReadOnlyMemory Value)> query, HttpProtocol protocol) { - _headers = new MuxKeyValueList(WithHost(headers, authority)); - _query = new MuxKeyValueList(query); + _headers = new MultiplexedKeyValueList(WithHost(headers, authority)); + _query = new MultiplexedKeyValueList(query); _target = new RequestTarget(); diff --git a/Engine/Ioxide/Protocol/Mux/MuxResponder.cs b/Engine/Ioxide/Protocol/Multiplexed/MultiplexedResponder.cs similarity index 90% rename from Engine/Ioxide/Protocol/Mux/MuxResponder.cs rename to Engine/Ioxide/Protocol/Multiplexed/MultiplexedResponder.cs index 11acc025c..cd62fca17 100644 --- a/Engine/Ioxide/Protocol/Mux/MuxResponder.cs +++ b/Engine/Ioxide/Protocol/Multiplexed/MultiplexedResponder.cs @@ -3,7 +3,7 @@ using GenHTTP.Api.Protocol; -namespace GenHTTP.Engine.Ioxide.Protocol.Mux; +namespace GenHTTP.Engine.Ioxide.Protocol.Multiplexed; /// /// The status and fields of a response, ready to be handed to a protocol layer. @@ -12,9 +12,9 @@ namespace GenHTTP.Engine.Ioxide.Protocol.Mux; /// Neutral on purpose. HTTP/2 and HTTP/3 want the same thing, but their response types come from /// different packages, so each driver builds its own from this. /// -internal readonly struct MuxResponseData +internal readonly struct MultiplexedResponseData { - internal MuxResponseData(int status, List<(ReadOnlyMemory Name, ReadOnlyMemory Value)> headers) + internal MultiplexedResponseData(int status, List<(ReadOnlyMemory Name, ReadOnlyMemory Value)> headers) { Status = status; Headers = headers; @@ -28,7 +28,7 @@ internal MuxResponseData(int status, List<(ReadOnlyMemory Name, ReadOnlyMe /// /// Maps a GenHTTP onto what a multiplexed protocol submits. /// -internal static class MuxResponder +internal static class MultiplexedResponder { private static readonly ReadOnlyMemory ContentTypeName = "content-type"u8.ToArray(); @@ -43,7 +43,7 @@ internal static class MuxResponder /// /// Builds the field section. Does not touch the content, which is streamed afterwards. /// - internal static MuxResponseData BuildHeaders(IResponse response) + internal static MultiplexedResponseData BuildHeaders(IResponse response) { var headers = new List<(ReadOnlyMemory Name, ReadOnlyMemory Value)>(response.Headers.Count + 4); @@ -83,7 +83,7 @@ internal static MuxResponseData BuildHeaders(IResponse response) } } - return new MuxResponseData((int)response.Status, headers); + return new MultiplexedResponseData((int)response.Status, headers); } /// @@ -103,7 +103,7 @@ internal static async ValueTask WriteBodyAsync(IResponse response, IBufferWriter // A HEAD response keeps the headers its GET would have produced and sends no body. if (!headRequest) { - await content.WriteAsync(new MuxSink(writer, flush)); + await content.WriteAsync(new MultiplexedSink(writer, flush)); } } finally diff --git a/Engine/Ioxide/Protocol/Mux/MuxSink.cs b/Engine/Ioxide/Protocol/Multiplexed/MultiplexedSink.cs similarity index 94% rename from Engine/Ioxide/Protocol/Mux/MuxSink.cs rename to Engine/Ioxide/Protocol/Multiplexed/MultiplexedSink.cs index 34965fc56..c676ba3ac 100644 --- a/Engine/Ioxide/Protocol/Mux/MuxSink.cs +++ b/Engine/Ioxide/Protocol/Multiplexed/MultiplexedSink.cs @@ -2,7 +2,7 @@ using GenHTTP.Api.Protocol; -namespace GenHTTP.Engine.Ioxide.Protocol.Mux; +namespace GenHTTP.Engine.Ioxide.Protocol.Multiplexed; /// /// Writes response content straight into a protocol response writer. @@ -16,7 +16,7 @@ namespace GenHTTP.Engine.Ioxide.Protocol.Mux; /// await is the backpressure. Content that writes through the buffer channel instead is flushed /// once, when it finishes - fine for a page, and the reason file content should use the stream. /// -internal sealed class MuxSink : IResponseSink +internal sealed class MultiplexedSink : IResponseSink { private readonly IBufferWriter _writer; @@ -24,7 +24,7 @@ internal sealed class MuxSink : IResponseSink private Stream? _stream; - internal MuxSink(IBufferWriter writer, Func flush) + internal MultiplexedSink(IBufferWriter writer, Func flush) { _writer = writer; _flush = flush; From a44b5df3b0c209e038c5aeaa577ea564ee70681d Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Sun, 16 Aug 2026 13:36:54 +0100 Subject: [PATCH 21/55] refactor(ioxide): split the HTTP/1.1 loop out of ConnectionDriver, drop the duplicated StatusLine ConnectionDriver had grown into two unrelated jobs: deciding what protocol a TCP connection speaks, and then serving it when the answer was HTTP/1.1. The second half moves to Http1Driver, alongside Http2Driver and Http3Driver - so each protocol is one file, and ConnectionDriver is only the transport plus the ALPN/preface decision that routes to them. The engine also carried its own copy of StatusLine, byte-identical to the one in GenHTTP.Engine.Shared.Types. Use the shared one; the Ioxide engine gets the same InternalsVisibleTo the acceptance tests already have. DateHeader stays duplicated on purpose - the engine's is [ThreadStatic] so each reactor owns its buffer, which the shared static cannot be. --- Engine/Ioxide/GenHTTP.Engine.Ioxide.csproj | 2 +- Engine/Ioxide/Protocol/ConnectionDriver.cs | 285 ++++----------------- Engine/Ioxide/Protocol/Http1Driver.cs | 227 ++++++++++++++++ Engine/Ioxide/Protocol/StatusLine.cs | 89 ------- Engine/Shared/GenHTTP.Engine.Shared.csproj | 1 + 5 files changed, 272 insertions(+), 332 deletions(-) create mode 100644 Engine/Ioxide/Protocol/Http1Driver.cs delete mode 100644 Engine/Ioxide/Protocol/StatusLine.cs diff --git a/Engine/Ioxide/GenHTTP.Engine.Ioxide.csproj b/Engine/Ioxide/GenHTTP.Engine.Ioxide.csproj index 577b254c6..562e204ee 100644 --- a/Engine/Ioxide/GenHTTP.Engine.Ioxide.csproj +++ b/Engine/Ioxide/GenHTTP.Engine.Ioxide.csproj @@ -17,7 +17,7 @@ - + diff --git a/Engine/Ioxide/Protocol/ConnectionDriver.cs b/Engine/Ioxide/Protocol/ConnectionDriver.cs index f950237fc..5565d0137 100644 --- a/Engine/Ioxide/Protocol/ConnectionDriver.cs +++ b/Engine/Ioxide/Protocol/ConnectionDriver.cs @@ -2,44 +2,28 @@ using System.IO.Pipelines; using System.Net; using System.Runtime.InteropServices; -using GenHTTP.Api.Infrastructure; -using GenHTTP.Api.Protocol; - -using GenHTTP.Engine.Ioxide; -using GenHTTP.Engine.Ioxide.Protocol.Multiplexed; -using GenHTTP.Engine.Shared.Types; -using Glyph11.Parser; -using Glyph11.Parser.UltraHardened; -using Glyph11.Pico; -using Glyph11.Protocol; - -using Microsoft.Extensions.Logging; +using GenHTTP.Api.Infrastructure; -using Connection = GenHTTP.Api.Protocol.Connection; using IoConnection = ioxide.TcpConnection; namespace GenHTTP.Engine.Ioxide.Protocol; /// -/// Drives GenHTTP's parse -> handle -> respond loop over an ioxide connection. One instance of this -/// runs per accepted connection on the reactor thread; awaited continuations resume inline on that -/// same thread. +/// Takes an accepted TCP connection, establishes its transport, and hands it to the protocol it +/// turns out to be speaking. /// +/// +/// Everything arriving over TCP comes through here - HTTP/1.1 and HTTP/2 both, since they share the +/// socket. Which of them a connection is settles two ways: ALPN during the TLS handshake on a secure +/// endpoint, and the HTTP/2 connection preface on a plaintext one. HTTP/3 never reaches this: QUIC +/// is a UDP listener and goes straight to . +/// +/// Once decided, the connection belongs to or +/// for its lifetime, and this only tears it down again. +/// internal static partial class ConnectionDriver { - private static readonly ParserLimits Limits = ParserLimits.Default; - - // Benchmark switch: set GENHTTP_IOXIDE_PARSER=pico to parse request headers with - // Glyph11.Pico (picohttpparser, native) instead of the hardened managed Glyph11 parser. - // Both fill the same BinaryRequest, so the rest of the pipeline is identical — only the - // header-parsing implementation differs. NOTE: the Pico path does picohttpparser-level - // validation only (no path/token/smuggling hardening); it's for benchmarking, not for - // hardening untrusted traffic. - private static readonly bool UsePico = - string.Equals(Environment.GetEnvironmentVariable("GENHTTP_IOXIDE_PARSER"), "pico", StringComparison.OrdinalIgnoreCase); - - /// /// Half-close (SHUT_WR = 1) the socket's write side to send FIN. ioxide's refcounted teardown does not /// FIN a server-initiated close by itself (the reactor's active recv keeps a reference), so an @@ -54,42 +38,10 @@ internal static partial class ConnectionDriver [LibraryImport("libc", EntryPoint = "getpeername")] private static partial int GetPeerName(int sockfd, [Out] byte[] addr, ref int addrlen); - private static readonly ReadOnlyMemory KeepAliveValue = "Keep-Alive"u8.ToArray(); - - // The connected client's remote address, read once per connection straight from the socket fd - // (ioxide exposes the fd but not the peer address). Mirrors the Internal engine's - // Socket.RemoteEndPoint.Address - returned as-is (IPv4-mapped IPv6 on a dual-stack listener), which - // IPAddress.IsLoopback and the rest of the pipeline already handle. - private static IPAddress? GetPeerAddress(int fd) - { - var addr = new byte[128]; // sockaddr_storage - var len = addr.Length; - - if (GetPeerName(fd, addr, ref len) != 0) - { - return null; - } - - var family = addr[0] | (addr[1] << 8); - - return family switch - { - 2 => new IPAddress(addr[4..8]), // AF_INET -> sin_addr - 10 => new IPAddress(addr[8..24]), // AF_INET6 -> sin6_addr - _ => null, - }; - } - - // Per-reactor pool of Request objects. Each reactor runs on its own thread and services its - // connections cooperatively, so the stack needs no locking. Reuses the per-connection Request - // allocation, which matters under connection churn (e.g. limited-conn). Mirrors the Internal - // engine's ClientContext pool, adapted for thread-per-core. - [ThreadStatic] - private static Stack? _requestPool; - - private const int MaxPooledRequests = 1024; + private static readonly ReadOnlyMemory Preface = "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"u8.ToArray(); - internal static async Task HandleAsync(IServer server, IEndPoint endPoint, IoConnection conn, Func>? connectionFactory, IoxideProtocols protocols = IoxideProtocols.Http1) + internal static async Task HandleAsync(IServer server, IEndPoint endPoint, IoConnection conn, + Func>? connectionFactory, IoxideProtocols protocols = IoxideProtocols.Http1) { IDuplexPipe pipe; @@ -129,9 +81,6 @@ internal static async Task HandleAsync(IServer server, IEndPoint endPoint, IoCon return; } - var reader = pipe.Input; - var writer = pipe.Output; - // The peer address is constant for the connection; resolve it once from the socket fd. var remoteAddress = GetPeerAddress(conn.ClientFd); @@ -141,7 +90,7 @@ internal static async Task HandleAsync(IServer server, IEndPoint endPoint, IoCon // Only worth peeking when both share the port. On an HTTP/2-only port every connection is // HTTP/2 by definition, and on an HTTP/1.1-only port the preface would be a malformed // request line either way. - var isHttp2 = http2 && (negotiated == "h2" || (negotiated is null && http1 && await StartsWithPrefaceAsync(reader))); + var isHttp2 = http2 && (negotiated == "h2" || (negotiated is null && http1 && await StartsWithPrefaceAsync(pipe.Input))); if (http2 && !http1) { @@ -150,8 +99,6 @@ internal static async Task HandleAsync(IServer server, IEndPoint endPoint, IoCon if (isHttp2) { - // HTTP/2 owns the connection from here: it multiplexes, so there is no request loop to - // run above it and nothing of the HTTP/1.1 path applies. try { await Http2Driver.RunAsync(server, endPoint, pipe, remoteAddress, endPoint.Secure); @@ -177,98 +124,8 @@ internal static async Task HandleAsync(IServer server, IEndPoint endPoint, IoCon return; } - var request = RentRequest(); - var into = request.Source; - - // Diagnostic: the [ThreadStatic] Request pool and lock-free pooling assume every continuation - // in this method resumes on the reactor thread that entered it. Capture that thread now — before - // the first await — so we can warn (once) if ioxide ever resumes us on a different thread - // (e.g. under a work-stealing scheduler), which silently degrades the pool. - var reactorThreadId = Environment.CurrentManagedThreadId; - - try - { - var dataRemaining = false; - ReadResult readResult = default; - - while (server.Running) - { - if (!dataRemaining) - { - readResult = await reader.ReadAsync(); - WarnIfThreadHopped(server, reactorThreadId, "after-read"); - } - - dataRemaining = false; - - var buffer = readResult.Buffer; - - if (!TryParseRequest(ref buffer, into)) - { - reader.AdvanceTo(readResult.Buffer.Start, readResult.Buffer.End); - if (readResult.IsCompleted) - { - break; - } - continue; - } - - // Client cert stays null until TLS termination lands; the remote address is read from the socket. - request.Apply(server, endPoint, reader, buffer.Start, remoteAddress, null); - - var keepAlive = await HandleRequestAsync(server, writer, request); - WarnIfThreadHopped(server, reactorThreadId, "after-handle"); - - if (!keepAlive) - { - await writer.FlushAsync(); - break; - } - - await request.DrainAsync(); - request.Reset(); - - if (readResult.IsCompleted) - { - break; - } - - if (reader.TryRead(out var next)) // pipeline mode (more data available) - { - readResult = next; - dataRemaining = true; - } - else - { - await writer.FlushAsync(); - } - } - } - catch - { - // spike: swallow client/protocol faults; teardown happens in finally - } - finally - { - await reader.CompleteAsync(); - - // Signal end-of-writes so the client sees EOF. Length-delimited responses don't strictly - // need this, but connection-close and upgrade (101) responses are delimited by FIN — without - // completing the writer the client blocks waiting for bytes that never come. Mirrors the - // reader completion above. - await writer.CompleteAsync(); - - WarnIfThreadHopped(server, reactorThreadId, "before-return"); - if (pipe is IAsyncDisposable disposable) - { - await disposable.DisposeAsync(); // tears down a TLS transport (stops the decrypt pump, close_notify) - } - // Send FIN for server-initiated closes so EOF-delimited responses terminate for the client - // (see ShutWrite above). Harmless for client-initiated closes — the fd is already closing. - Shutdown(conn.ClientFd, ShutWrite); - conn.DecRef(); - ReturnRequest(request); - } + // HTTP/1.1 tears the connection down itself, so that it can return its pooled request first. + await Http1Driver.RunAsync(server, endPoint, pipe, conn, remoteAddress); } /// @@ -305,105 +162,49 @@ private static async ValueTask StartsWithPrefaceAsync(PipeReader reader) } } - private static readonly ReadOnlyMemory Preface = "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"u8.ToArray(); - - private static async ValueTask CloseAsync(IDuplexPipe pipe, IoConnection conn) + /// + /// Ends a connection: complete both halves, tear down the transport, FIN, release. + /// + /// + /// Completing the writer matters beyond tidiness. Length-delimited responses do not need it, but + /// connection-close and upgrade (101) responses are delimited by FIN, and without it the client + /// waits for bytes that never come. + /// + internal static async ValueTask CloseAsync(IDuplexPipe pipe, IoConnection conn) { await pipe.Input.CompleteAsync(); await pipe.Output.CompleteAsync(); if (pipe is IAsyncDisposable disposable) { - await disposable.DisposeAsync(); + await disposable.DisposeAsync(); // tears down a TLS transport (stops the decrypt pump, close_notify) } Shutdown(conn.ClientFd, ShutWrite); conn.DecRef(); } - private static bool TryParseRequest(ref ReadOnlySequence buffer, BinaryRequest into) - => UsePico ? TryParseRequestPico(ref buffer, into) : TryParseRequestGlyph11(ref buffer, into); - - // Hardened managed parser (default): full RFC + smuggling validation. - private static bool TryParseRequestGlyph11(ref ReadOnlySequence buffer, BinaryRequest into) - { - if (!UltraHardenedParser.TryExtractFullHeaderValidated(ref buffer, into, Limits, out var bytesRead)) - { - return false; - } - - buffer = buffer.Slice(bytesRead + 1); - return true; - } - - // picohttpparser (native) — single-segment is parsed in place, multi-segment is linearized. - // `consumed` follows the same -1 convention as the managed parser, so the slice is identical. - private static bool TryParseRequestPico(ref ReadOnlySequence buffer, BinaryRequest into) - { - if (!PicoParser.TryParse(buffer, into, out var consumed)) - { - return false; - } - - buffer = buffer.Slice(consumed + 1); - return true; - } - - private static async ValueTask HandleRequestAsync(IServer server, PipeWriter writer, Request request) - { - var header = request.Header; - - var headRequest = header.Method == RequestMethod.Head; - - var connectionHeader = header.Headers.GetEntry(KnownHeaders.Connection); - - var keepAliveRequested = connectionHeader?.Bytes.Span.SequenceEqual(KeepAliveValue.Span) ?? (header.Protocol == HttpProtocol.Http11); - - var response = await server.Handler.HandleAsync(request) ?? throw new InvalidOperationException("The root request handler did not return a response"); - - var closeRequested = response.Mode is Connection.Close or Connection.Upgrade; - - await ResponseWriter.WriteAsync(writer, request, response, keepAliveRequested && !closeRequested, headRequest); - - return keepAliveRequested && !closeRequested; - } - - private static Request RentRequest() - => _requestPool is { } pool && pool.TryPop(out var request) ? request : new Request(); - - private static void ReturnRequest(Request request) + // The connected client's remote address, read once per connection straight from the socket fd + // (ioxide exposes the fd but not the peer address). Mirrors the Internal engine's + // Socket.RemoteEndPoint.Address - returned as-is (IPv4-mapped IPv6 on a dual-stack listener), which + // IPAddress.IsLoopback and the rest of the pipeline already handle. + private static IPAddress? GetPeerAddress(int fd) { - request.Reset(); - - var pool = _requestPool ??= new Stack(); + var addr = new byte[128]; // sockaddr_storage + var len = addr.Length; - if (pool.Count < MaxPooledRequests) + if (GetPeerName(fd, addr, ref len) != 0) { - pool.Push(request); + return null; } - } - - // Set to 1 the first time a continuation is seen resuming off the reactor thread. - private static int _hopWarned; - - // Warns at most once per process if this phase's continuation resumed on a different thread than the - // reactor thread that entered HandleAsync. On the fast (affine) path it's a single int compare, so it's - // safe to leave enabled during benchmarks — the one-shot guard keeps it from perturbing throughput. - private static void WarnIfThreadHopped(IServer server, int reactorThreadId, string phase) - { - var now = Environment.CurrentManagedThreadId; - if (now == reactorThreadId || _hopWarned != 0) - { - return; - } + var family = addr[0] | (addr[1] << 8); - if (Interlocked.Exchange(ref _hopWarned, 1) == 0) + return family switch { - server.Logging.CreateLogger("GenHTTP.Engine.Ioxide.Protocol.ConnectionDriver") - .LogWarning("Thread hop detected: reactor={ReactorThreadId} now={CurrentThreadId} phase={Phase}. " + - "The [ThreadStatic] Request pool assumes reactor affinity; pooling degrades under work-stealing. (warns once)", reactorThreadId, now, phase); - } + 2 => new IPAddress(addr[4..8]), // AF_INET -> sin_addr + 10 => new IPAddress(addr[8..24]), // AF_INET6 -> sin6_addr + _ => null, + }; } - } diff --git a/Engine/Ioxide/Protocol/Http1Driver.cs b/Engine/Ioxide/Protocol/Http1Driver.cs new file mode 100644 index 000000000..a19b8ca24 --- /dev/null +++ b/Engine/Ioxide/Protocol/Http1Driver.cs @@ -0,0 +1,227 @@ +using System.Buffers; +using System.IO.Pipelines; +using System.Net; + +using GenHTTP.Api.Infrastructure; +using GenHTTP.Api.Protocol; + +using GenHTTP.Engine.Shared.Types; + +using Glyph11.Parser; +using Glyph11.Parser.UltraHardened; +using Glyph11.Pico; +using Glyph11.Protocol; + +using Microsoft.Extensions.Logging; + +using Connection = GenHTTP.Api.Protocol.Connection; +using IoConnection = ioxide.TcpConnection; + +namespace GenHTTP.Engine.Ioxide.Protocol; + +/// +/// Serves an HTTP/1.1 connection: parse, handle, respond, repeat until it closes. +/// +/// +/// Reached from once the transport is up and the protocol settled. +/// One of these runs per connection on the reactor thread, and awaited continuations resume inline +/// on that same thread - which is what lets the request pool below go without locking. +/// +/// Unlike HTTP/2 and HTTP/3 there is one request in flight at a time, so a connection owns a +/// single for its lifetime and returns it when the connection ends. +/// +internal static class Http1Driver +{ + private static readonly ParserLimits Limits = ParserLimits.Default; + + // Benchmark switch: set GENHTTP_IOXIDE_PARSER=pico to parse request headers with + // Glyph11.Pico (picohttpparser, native) instead of the hardened managed Glyph11 parser. + // Both fill the same BinaryRequest, so the rest of the pipeline is identical — only the + // header-parsing implementation differs. NOTE: the Pico path does picohttpparser-level + // validation only (no path/token/smuggling hardening); it's for benchmarking, not for + // hardening untrusted traffic. + private static readonly bool UsePico = + string.Equals(Environment.GetEnvironmentVariable("GENHTTP_IOXIDE_PARSER"), "pico", StringComparison.OrdinalIgnoreCase); + + private static readonly ReadOnlyMemory KeepAliveValue = "Keep-Alive"u8.ToArray(); + + // Per-reactor pool of Request objects. Each reactor runs on its own thread and services its + // connections cooperatively, so the stack needs no locking. Reuses the per-connection Request + // allocation, which matters under connection churn (e.g. limited-conn). Mirrors the Internal + // engine's ClientContext pool, adapted for thread-per-core. + [ThreadStatic] + private static Stack? _requestPool; + + private const int MaxPooledRequests = 1024; + + internal static async Task RunAsync(IServer server, IEndPoint endPoint, IDuplexPipe pipe, IoConnection conn, IPAddress? remoteAddress) + { + var reader = pipe.Input; + var writer = pipe.Output; + + var request = RentRequest(); + var into = request.Source; + + // Diagnostic: the [ThreadStatic] Request pool and lock-free pooling assume every continuation + // in this method resumes on the reactor thread that entered it. Capture that thread now — before + // the first await — so we can warn (once) if ioxide ever resumes us on a different thread + // (e.g. under a work-stealing scheduler), which silently degrades the pool. + var reactorThreadId = Environment.CurrentManagedThreadId; + + try + { + var dataRemaining = false; + ReadResult readResult = default; + + while (server.Running) + { + if (!dataRemaining) + { + readResult = await reader.ReadAsync(); + WarnIfThreadHopped(server, reactorThreadId, "after-read"); + } + + dataRemaining = false; + + var buffer = readResult.Buffer; + + if (!TryParseRequest(ref buffer, into)) + { + reader.AdvanceTo(readResult.Buffer.Start, readResult.Buffer.End); + if (readResult.IsCompleted) + { + break; + } + continue; + } + + // Client cert stays null until TLS termination lands; the remote address is read from the socket. + request.Apply(server, endPoint, reader, buffer.Start, remoteAddress, null); + + var keepAlive = await HandleRequestAsync(server, writer, request); + WarnIfThreadHopped(server, reactorThreadId, "after-handle"); + + if (!keepAlive) + { + await writer.FlushAsync(); + break; + } + + await request.DrainAsync(); + request.Reset(); + + if (readResult.IsCompleted) + { + break; + } + + if (reader.TryRead(out var next)) // pipeline mode (more data available) + { + readResult = next; + dataRemaining = true; + } + else + { + await writer.FlushAsync(); + } + } + } + catch + { + // spike: swallow client/protocol faults; teardown happens in finally + } + finally + { + WarnIfThreadHopped(server, reactorThreadId, "before-return"); + + await ConnectionDriver.CloseAsync(pipe, conn); + + ReturnRequest(request); + } + } + + private static bool TryParseRequest(ref ReadOnlySequence buffer, BinaryRequest into) + => UsePico ? TryParseRequestPico(ref buffer, into) : TryParseRequestGlyph11(ref buffer, into); + + // Hardened managed parser (default): full RFC + smuggling validation. + private static bool TryParseRequestGlyph11(ref ReadOnlySequence buffer, BinaryRequest into) + { + if (!UltraHardenedParser.TryExtractFullHeaderValidated(ref buffer, into, Limits, out var bytesRead)) + { + return false; + } + + buffer = buffer.Slice(bytesRead + 1); + return true; + } + + // picohttpparser (native) — single-segment is parsed in place, multi-segment is linearized. + // `consumed` follows the same -1 convention as the managed parser, so the slice is identical. + private static bool TryParseRequestPico(ref ReadOnlySequence buffer, BinaryRequest into) + { + if (!PicoParser.TryParse(buffer, into, out var consumed)) + { + return false; + } + + buffer = buffer.Slice(consumed + 1); + return true; + } + + private static async ValueTask HandleRequestAsync(IServer server, PipeWriter writer, Request request) + { + var header = request.Header; + + var headRequest = header.Method == RequestMethod.Head; + + var connectionHeader = header.Headers.GetEntry(KnownHeaders.Connection); + + var keepAliveRequested = connectionHeader?.Bytes.Span.SequenceEqual(KeepAliveValue.Span) ?? (header.Protocol == HttpProtocol.Http11); + + var response = await server.Handler.HandleAsync(request) ?? throw new InvalidOperationException("The root request handler did not return a response"); + + var closeRequested = response.Mode is Connection.Close or Connection.Upgrade; + + await ResponseWriter.WriteAsync(writer, request, response, keepAliveRequested && !closeRequested, headRequest); + + return keepAliveRequested && !closeRequested; + } + + private static Request RentRequest() + => _requestPool is { } pool && pool.TryPop(out var request) ? request : new Request(); + + private static void ReturnRequest(Request request) + { + request.Reset(); + + var pool = _requestPool ??= new Stack(); + + if (pool.Count < MaxPooledRequests) + { + pool.Push(request); + } + } + + // Set to 1 the first time a continuation is seen resuming off the reactor thread. + private static int _hopWarned; + + // Warns at most once per process if this phase's continuation resumed on a different thread than the + // reactor thread that entered RunAsync. On the fast (affine) path it's a single int compare, so it's + // safe to leave enabled during benchmarks — the one-shot guard keeps it from perturbing throughput. + private static void WarnIfThreadHopped(IServer server, int reactorThreadId, string phase) + { + var now = Environment.CurrentManagedThreadId; + + if (now == reactorThreadId || _hopWarned != 0) + { + return; + } + + if (Interlocked.Exchange(ref _hopWarned, 1) == 0) + { + server.Logging.CreateLogger("GenHTTP.Engine.Ioxide.Protocol.Http1Driver") + .LogWarning("Thread hop detected: reactor={ReactorThreadId} now={CurrentThreadId} phase={Phase}. " + + "The [ThreadStatic] Request pool assumes reactor affinity; pooling degrades under work-stealing. (warns once)", reactorThreadId, now, phase); + } + } +} diff --git a/Engine/Ioxide/Protocol/StatusLine.cs b/Engine/Ioxide/Protocol/StatusLine.cs deleted file mode 100644 index d7cca219a..000000000 --- a/Engine/Ioxide/Protocol/StatusLine.cs +++ /dev/null @@ -1,89 +0,0 @@ -using GenHTTP.Api.Protocol; - -namespace GenHTTP.Engine.Ioxide.Protocol; - -internal static class StatusLine -{ - private static readonly byte[][] Lines = Init(); - - public static byte[] Get(ResponseStatus status) => Lines[(int)status]; - - private static byte[][] Init() - { - var arr = new byte[600][]; - - // 1xx - arr[100] = "HTTP/1.1 100 Continue\r\n"u8.ToArray(); - arr[101] = "HTTP/1.1 101 Switching Protocols\r\n"u8.ToArray(); - arr[102] = "HTTP/1.1 102 Processing\r\n"u8.ToArray(); // WebDAV - - // 2xx - arr[200] = "HTTP/1.1 200 OK\r\n"u8.ToArray(); - arr[201] = "HTTP/1.1 201 Created\r\n"u8.ToArray(); - arr[202] = "HTTP/1.1 202 Accepted\r\n"u8.ToArray(); - arr[203] = "HTTP/1.1 203 Non-Authoritative Information\r\n"u8.ToArray(); - arr[204] = "HTTP/1.1 204 No Content\r\n"u8.ToArray(); - arr[205] = "HTTP/1.1 205 Reset Content\r\n"u8.ToArray(); - arr[206] = "HTTP/1.1 206 Partial Content\r\n"u8.ToArray(); - arr[207] = "HTTP/1.1 207 Multi-Status\r\n"u8.ToArray(); // WebDAV - arr[208] = "HTTP/1.1 208 Already Reported\r\n"u8.ToArray(); // WebDAV - arr[226] = "HTTP/1.1 226 IM Used\r\n"u8.ToArray(); // Delta encoding - - // 3xx - arr[300] = "HTTP/1.1 300 Multiple Choices\r\n"u8.ToArray(); - arr[301] = "HTTP/1.1 301 Moved Permanently\r\n"u8.ToArray(); - arr[302] = "HTTP/1.1 302 Found\r\n"u8.ToArray(); - arr[303] = "HTTP/1.1 303 See Other\r\n"u8.ToArray(); - arr[304] = "HTTP/1.1 304 Not Modified\r\n"u8.ToArray(); - arr[305] = "HTTP/1.1 305 Use Proxy\r\n"u8.ToArray(); - arr[307] = "HTTP/1.1 307 Temporary Redirect\r\n"u8.ToArray(); - arr[308] = "HTTP/1.1 308 Permanent Redirect\r\n"u8.ToArray(); - - // 4xx - arr[400] = "HTTP/1.1 400 Bad Request\r\n"u8.ToArray(); - arr[401] = "HTTP/1.1 401 Unauthorized\r\n"u8.ToArray(); - arr[402] = "HTTP/1.1 402 Payment Required\r\n"u8.ToArray(); - arr[403] = "HTTP/1.1 403 Forbidden\r\n"u8.ToArray(); - arr[404] = "HTTP/1.1 404 Not Found\r\n"u8.ToArray(); - arr[405] = "HTTP/1.1 405 Method Not Allowed\r\n"u8.ToArray(); - arr[406] = "HTTP/1.1 406 Not Acceptable\r\n"u8.ToArray(); - arr[407] = "HTTP/1.1 407 Proxy Authentication Required\r\n"u8.ToArray(); - arr[408] = "HTTP/1.1 408 Request Timeout\r\n"u8.ToArray(); - arr[409] = "HTTP/1.1 409 Conflict\r\n"u8.ToArray(); - arr[410] = "HTTP/1.1 410 Gone\r\n"u8.ToArray(); - arr[411] = "HTTP/1.1 411 Length Required\r\n"u8.ToArray(); - arr[412] = "HTTP/1.1 412 Precondition Failed\r\n"u8.ToArray(); - arr[413] = "HTTP/1.1 413 Payload Too Large\r\n"u8.ToArray(); - arr[414] = "HTTP/1.1 414 URI Too Long\r\n"u8.ToArray(); - arr[415] = "HTTP/1.1 415 Unsupported Media Type\r\n"u8.ToArray(); - arr[416] = "HTTP/1.1 416 Range Not Satisfiable\r\n"u8.ToArray(); - arr[417] = "HTTP/1.1 417 Expectation Failed\r\n"u8.ToArray(); - arr[418] = "HTTP/1.1 418 I'm a Teapot\r\n"u8.ToArray(); - arr[421] = "HTTP/1.1 421 Misdirected Request\r\n"u8.ToArray(); - arr[422] = "HTTP/1.1 422 Unprocessable Entity\r\n"u8.ToArray(); - arr[423] = "HTTP/1.1 423 Locked\r\n"u8.ToArray(); - arr[424] = "HTTP/1.1 424 Failed Dependency\r\n"u8.ToArray(); - arr[425] = "HTTP/1.1 425 Too Early\r\n"u8.ToArray(); - arr[426] = "HTTP/1.1 426 Upgrade Required\r\n"u8.ToArray(); - arr[428] = "HTTP/1.1 428 Precondition Required\r\n"u8.ToArray(); - arr[429] = "HTTP/1.1 429 Too Many Requests\r\n"u8.ToArray(); - arr[431] = "HTTP/1.1 431 Request Header Fields Too Large\r\n"u8.ToArray(); - arr[451] = "HTTP/1.1 451 Unavailable For Legal Reasons\r\n"u8.ToArray(); - - // 5xx - arr[500] = "HTTP/1.1 500 Internal Server Error\r\n"u8.ToArray(); - arr[501] = "HTTP/1.1 501 Not Implemented\r\n"u8.ToArray(); - arr[502] = "HTTP/1.1 502 Bad Gateway\r\n"u8.ToArray(); - arr[503] = "HTTP/1.1 503 Service Unavailable\r\n"u8.ToArray(); - arr[504] = "HTTP/1.1 504 Gateway Timeout\r\n"u8.ToArray(); - arr[505] = "HTTP/1.1 505 HTTP Version Not Supported\r\n"u8.ToArray(); - arr[506] = "HTTP/1.1 506 Variant Also Negotiates\r\n"u8.ToArray(); - arr[507] = "HTTP/1.1 507 Insufficient Storage\r\n"u8.ToArray(); - arr[508] = "HTTP/1.1 508 Loop Detected\r\n"u8.ToArray(); - arr[510] = "HTTP/1.1 510 Not Extended\r\n"u8.ToArray(); - arr[511] = "HTTP/1.1 511 Network Authentication Required\r\n"u8.ToArray(); - - return arr; - } - -} diff --git a/Engine/Shared/GenHTTP.Engine.Shared.csproj b/Engine/Shared/GenHTTP.Engine.Shared.csproj index fc8377e53..53a545973 100644 --- a/Engine/Shared/GenHTTP.Engine.Shared.csproj +++ b/Engine/Shared/GenHTTP.Engine.Shared.csproj @@ -26,6 +26,7 @@ + From 7f7ad756a56d148c8d664b011607eed86a1a436c Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Sun, 16 Aug 2026 14:08:07 +0100 Subject: [PATCH 22/55] docs(ioxide): compact the engine comments The comment blocks had grown to the point of hiding the code they explained - 590 of 2540 lines, with whole paragraphs restating what the next statement says. Trimmed to what is not derivable from reading it: the traps, the RFC references, and the reasons a line is the way it is. Public XML docs keep their summaries. No code changed - the diff is comment-only, verified by comparing both revisions with every comment line stripped. --- Engine/Ioxide/Hosting/IoxideServer.Quic.cs | 32 ++---- Engine/Ioxide/Hosting/IoxideServer.Tls.cs | 20 ++-- Engine/Ioxide/Hosting/IoxideServer.cs | 59 ++++------ Engine/Ioxide/IoxideOptions.cs | 107 ++++-------------- Engine/Ioxide/IoxideProtocols.cs | 34 ++---- Engine/Ioxide/IoxideReactor.cs | 14 +-- Engine/Ioxide/Protocol/ChunkedSink.cs | 7 +- Engine/Ioxide/Protocol/ChunkedWriter.cs | 7 +- Engine/Ioxide/Protocol/ConnectionDriver.cs | 60 ++++------ Engine/Ioxide/Protocol/DateHeader.cs | 6 +- Engine/Ioxide/Protocol/Http1Driver.cs | 49 ++++---- Engine/Ioxide/Protocol/Http2Driver.cs | 11 +- Engine/Ioxide/Protocol/Http3Driver.cs | 20 ++-- .../Multiplexed/MultiplexedKeyValueList.cs | 8 +- .../Multiplexed/MultiplexedRequest.cs | 15 +-- .../Multiplexed/MultiplexedRequestBody.cs | 23 ++-- .../Multiplexed/MultiplexedRequestHeader.cs | 12 +- .../Multiplexed/MultiplexedResponder.cs | 25 ++-- .../Protocol/Multiplexed/MultiplexedSink.cs | 19 ++-- Engine/Ioxide/Protocol/PipeWriterStream.cs | 15 +-- Engine/Ioxide/Protocol/ResponseWriter.cs | 9 +- Engine/Ioxide/Server.cs | 36 +++--- Engine/Ioxide/Tls/IoxideTls.cs | 8 +- 23 files changed, 193 insertions(+), 403 deletions(-) diff --git a/Engine/Ioxide/Hosting/IoxideServer.Quic.cs b/Engine/Ioxide/Hosting/IoxideServer.Quic.cs index a3246cb92..3ad119196 100644 --- a/Engine/Ioxide/Hosting/IoxideServer.Quic.cs +++ b/Engine/Ioxide/Hosting/IoxideServer.Quic.cs @@ -17,13 +17,10 @@ public sealed partial class IoxideServer private IoxideEndPoint? _quicEndPoint; /// - /// Adds the QUIC listener for the endpoint bound with enableQuic. + /// Adds the QUIC listener for the endpoint serving HTTP/3. Needs a secure endpoint - QUIC + /// carries TLS 1.3 and has no cleartext mode - and takes its port, which is what a browser + /// assumes when an Alt-Svc advertisement names none of its own. /// - /// - /// QUIC carries TLS 1.3 and has no cleartext mode, so this needs a secure endpoint - the - /// certificate bound there is the one it serves. The UDP port is the endpoint's own port, which - /// is what a browser assumes when it reads an Alt-Svc advertisement naming no port of its own. - /// private ServerConfig WithQuic(ServerConfig cfg, IoxideEndPoint endPoint) { if (!_secure.TryGetValue(endPoint.Port, out var security)) @@ -58,11 +55,9 @@ private ServerConfig WithQuic(ServerConfig cfg, IoxideEndPoint endPoint) /// The PEM files ngtcp2 loads. Configured, or HTTP/3 does not start. /// /// - /// ngtcp2 takes paths rather than a certificate object, so serving HTTP/3 needs PEM on disk. - /// That is the C layer's contract and this honours it rather than working around it: the engine - /// will not write a private key out on your behalf, because a key it creates is a key it has to - /// choose a location and a lifetime for, and one written to a temporary directory outlives any - /// shutdown that skips cleanup. + /// ngtcp2 takes paths rather than a certificate object, so this honours the C layer's contract + /// rather than working around it: the engine will not write a private key out on your behalf, + /// since one written to a temporary directory outlives any shutdown that skips cleanup. /// private bool TryResolveQuicCertificate(Shared.Infrastructure.SecurityConfiguration security, ushort port, out string certPath, out string keyPath) @@ -90,18 +85,13 @@ private bool TryResolveQuicCertificate(Shared.Infrastructure.SecurityConfigurati } /// - /// Warns when the configured PEM is not the certificate bound to this endpoint. + /// Warns when the configured PEM is not the certificate bound to this endpoint, which would + /// answer as one host over TCP and another over QUIC. A browser following an Alt-Svc header + /// expects the alternative to be valid for the ORIGIN (RFC 7838 3.1) and would refuse it. /// /// - /// These paths exist to hand ngtcp2 a file rather than to give HTTP/3 an identity of its own, - /// and nothing stops them doing the latter: the port would then answer as one host over TCP and - /// another over QUIC. A browser moving from HTTP/1.1 to HTTP/3 by an Alt-Svc header expects the - /// alternative to present a certificate valid for the ORIGIN (RFC 7838 3.1), so it would refuse - /// the upgrade - or worse, not notice. - /// - /// Compared by leaf thumbprint, so a file carrying a fuller chain than the bound - /// certificate is not flagged. A warning rather than a refusal: someone may be deliberately - /// serving a different certificate, and this is not the place to decide they cannot. + /// Compared by leaf thumbprint, so a file carrying a fuller chain is not flagged. A warning + /// rather than a refusal: someone may be serving a different certificate deliberately. /// private void WarnIfNotTheBoundCertificate(string configuredCert, Shared.Infrastructure.SecurityConfiguration security, ushort port) { diff --git a/Engine/Ioxide/Hosting/IoxideServer.Tls.cs b/Engine/Ioxide/Hosting/IoxideServer.Tls.cs index 4fa9d8514..02cbd17f4 100644 --- a/Engine/Ioxide/Hosting/IoxideServer.Tls.cs +++ b/Engine/Ioxide/Hosting/IoxideServer.Tls.cs @@ -15,11 +15,9 @@ public sealed partial class IoxideServer { /// /// The TLS options for every secure port whose provider yields a default (no-SNI) certificate. + /// A provider selecting by SNI (unsupported here) returns none, leaving the port advertised as + /// secure - so secure-upgrade redirects still work - but refusing its handshakes. /// - /// - /// Providers that select by SNI (unsupported here) return none and are skipped - the port stays - /// advertised as secure, so secure-upgrade redirects still work, but its handshakes are refused. - /// private IEnumerable> ResolveTls() { foreach (var (port, security) in _secure) @@ -35,9 +33,8 @@ private IEnumerable> ResolveTls() CertificatePem = certificate.ExportCertificatePem(), KeyPem = ExportKeyPem(certificate), - // Server preference, most preferred first: a client offering both gets HTTP/2, one - // offering only http/1.1 is unaffected, and one offering neither continues without - // an ALPN extension at all. + // Server preference, most preferred first. A client offering neither continues + // without an ALPN extension at all. Alpn = ProtocolsFor(port).HasFlag(IoxideProtocols.Http2) ? ["h2", "http/1.1"] : ["http/1.1"], ClientCaPath = _options.MutualTls.ClientCaPath, @@ -51,13 +48,10 @@ private IEnumerable> ResolveTls() } /// - /// Whether a client offering no certificate is refused on this endpoint. + /// Whether a client offering no certificate is refused on this endpoint: either the engine says + /// so for every endpoint, or the endpoint's own validator does. A validator that only wants to + /// inspect what arrives still gets asked, because the CertificateRequest goes out either way. /// - /// - /// Either the engine says so for every endpoint, or the endpoint's own validator does. A - /// validator that only wants to inspect what arrives leaves RequireCertificate false and - /// still gets asked, because the CertificateRequest goes out either way. - /// private bool RequiresClientCertificate(SecurityConfiguration security) => _options.MutualTls.RequireClientCertificate || security.CertificateValidator?.RequireCertificate == true; diff --git a/Engine/Ioxide/Hosting/IoxideServer.cs b/Engine/Ioxide/Hosting/IoxideServer.cs index c557b2e59..ac30cc4d2 100644 --- a/Engine/Ioxide/Hosting/IoxideServer.cs +++ b/Engine/Ioxide/Hosting/IoxideServer.cs @@ -18,16 +18,10 @@ namespace GenHTTP.Engine.Ioxide.Hosting; /// -/// Hosts an application on ioxide's io_uring reactors. +/// Hosts an application on ioxide's io_uring reactors: one per core, each owning a ring and its +/// connections on its own thread. Protocols are per port; TLS termination and the QUIC listener +/// live in the other halves of this class. /// -/// -/// One reactor per core, each owning a ring and its connections on its own thread. -/// -/// Protocols are per port. HTTP/1.1 and HTTP/2 share a TCP socket - ALPN decides on a secure -/// endpoint, the connection preface on a plaintext one - and HTTP/3 is a UDP socket on the same -/// port number, so one endpoint can serve all three or each can have a port of its own. TLS -/// termination and the QUIC listener live in the other halves of this class. -/// public sealed partial class IoxideServer : IServer { private readonly ServerConfiguration _config; @@ -112,12 +106,10 @@ internal IoxideServer(ServerConfiguration config, IHandler handler, Func e.Port, e => ResolveProtocols(_options, config, e.Port)); - // One QUIC listener: the transport binds a single UDP port for the whole server, so several - // endpoints asking for HTTP/3 would each want their own and only the first could have it. + // The transport binds a single UDP port for the whole server, so only one endpoint can + // serve HTTP/3. var quic = mapped.Where(e => _protocols[e.Port].HasFlag(IoxideProtocols.Http3)).ToList(); if (quic.Count > 1) @@ -129,9 +121,7 @@ internal IoxideServer(ServerConfiguration config, IHandler handler, Func e.Security is not null) .ToDictionary(e => e.Port, e => e.Security!); @@ -142,20 +132,15 @@ internal IoxideServer(ServerConfiguration config, IHandler handler, Func /// What one port serves: the default, its override, and the endpoint's own enableQuic flag. /// - /// - /// A port given only HTTP/3 opens no TCP listener at all, rather than binding one that answers - /// nothing. - /// private static IoxideProtocols ResolveProtocols(IoxideOptions options, ServerConfiguration config, ushort port) { var named = options.ProtocolsByPort.TryGetValue(port, out var configured); var protocols = named ? configured : options.Protocols; - // HTTP/3 from the DEFAULT applies only where it can: QUIC carries TLS 1.3, so a plaintext - // port cannot serve it. Writing Protocols = All then means "everything each port supports" - // rather than an error about the one without a certificate. Named per port it is taken - // literally, and refused loudly below if the port cannot serve it. + // HTTP/3 from the DEFAULT applies only where it can, so Protocols = All means "everything + // each port supports" rather than an error about the plaintext one. Named per port it is + // taken literally, and refused where the port has no certificate. if (!named && protocols.HasFlag(IoxideProtocols.Http3) && config.EndPoints.All(e => e.Port != port || e.Security is null)) { protocols &= ~IoxideProtocols.Http3; @@ -191,10 +176,8 @@ public async ValueTask StartAsync() cfg = _configure(cfg); } - // The endpoint bindings (.Port()/.Bind()) determine the listen ports and dual-stack mode, so - // they always win over whatever the configuration hook may have set. Only the ports serving - // something over TCP are bound: an HTTP/3-only endpoint has a UDP socket and nothing else, - // and a server made entirely of those opens no TCP listener at all. + // Endpoint bindings always win over the configuration hook. Only ports serving something + // over TCP are bound, so an HTTP/3-only endpoint gets a UDP socket and nothing else. var tcpPorts = _protocols.Where(p => (p.Value & IoxideProtocols.Http1AndHttp2) != 0) .Select(p => p.Key) .OrderBy(p => p == _primary.Port ? 0 : 1) @@ -218,10 +201,9 @@ public async ValueTask StartAsync() _threads = new Thread[cfg.ReactorCount]; _reactors = new Reactor[cfg.ReactorCount]; - // Reactors bind their listeners on their own threads (inside Reactor.Run), so StartAsync must - // not return until they're actually accepting - otherwise a client that connects immediately - // (as the test host does) races the bind and gets "connection refused". OnStart fires right - // after the listener is bound, so each reactor signals once it's up. + // Reactors bind their listeners on their own threads, so StartAsync must not return before + // they accept - a client connecting immediately (as the test host does) would otherwise + // race the bind and get "connection refused". var listening = new CountdownEvent(cfg.ReactorCount); for (var i = 0; i < _threads.Length; i++) @@ -262,9 +244,8 @@ public async ValueTask StartAsync() _threads[i].Start(); } - // Block (off the caller) until every reactor reports listening, so the server is accepting - // before StartAsync returns. The timeout is a safety net for a reactor that fails to bind - - // log and continue rather than hang the host forever. + // Off the caller. The timeout is a safety net for a reactor that fails to bind: log and + // continue rather than hang the host forever. if (await Task.Run(() => listening.Wait(TimeSpan.FromSeconds(10)))) { listening.Dispose(); @@ -335,11 +316,9 @@ public async ValueTask DisposeAsync() _logger.LogInformation("Stopping {Count} ioxide reactors ...", reactors.Length); - // Each reactor owns an io_uring ring on its own thread. Signal every reactor to stop, then join - // the threads: each loop exits and Run() disposes its ring on the reactor thread (mandatory for a - // single-issuer / DEFER_TASKRUN ring). Without this the rings leak for the lifetime of the process, - // so a long-lived host - or a test run that spins up hundreds of hosts - eventually exhausts - // io_uring_setup and crashes. Joining runs off the caller so DisposeAsync stays non-blocking. + // Stop, then join: each loop exits and Run() disposes its ring on the reactor thread, which + // a single-issuer / DEFER_TASKRUN ring requires. Skipping it leaks a ring per host until + // io_uring_setup runs out. Off the caller, so DisposeAsync stays non-blocking. await Task.Run(() => { foreach (var reactor in reactors) diff --git a/Engine/Ioxide/IoxideOptions.cs b/Engine/Ioxide/IoxideOptions.cs index 9687c9fa7..15dabaa30 100644 --- a/Engine/Ioxide/IoxideOptions.cs +++ b/Engine/Ioxide/IoxideOptions.cs @@ -1,55 +1,23 @@ namespace GenHTTP.Engine.Ioxide; /// -/// Protocol and TLS options for the ioxide engine. +/// Protocol and TLS options for the ioxide engine. The port, its certificate and whether it asks +/// for a client certificate stay on Bind; which protocols it then serves lives here. /// -/// -/// The port, its certificate and whether it asks for a client certificate stay on Bind, -/// where GenHTTP already puts them. Which protocols that port then serves lives here, because -/// GenHTTP's endpoint model has nowhere to put it. -/// public sealed record IoxideOptions { internal static readonly IoxideOptions Default = new(); /// /// The protocols every endpoint serves, unless says otherwise. + /// An endpoint bound with enableQuic serves HTTP/3 whatever is set here. /// - /// - /// Defaults to HTTP/1.1 alone. Http1AndHttp2 lets the two share a port - ALPN picks on a - /// secure endpoint, the connection preface on a plaintext one - and adding Http3 binds - /// the same port number over UDP as well. - /// - /// An endpoint bound with enableQuic serves HTTP/3 whatever is set here, so code - /// already using that flag keeps working. - /// public IoxideProtocols Protocols { get; init; } = IoxideProtocols.Http1; /// - /// Protocols for one port, overriding . + /// Protocols for one port, overriding - bind the ports, then name the + /// ones that differ: { [8081] = IoxideProtocols.Http2, [8443] = IoxideProtocols.All }. /// - /// - /// This is how endpoints get different protocols: bind the ports, then name the ones that differ. - /// - /// - /// .Bind(IPAddress.Any, 8080) // HTTP/1.1 - /// .Bind(IPAddress.Any, 8081) // HTTP/2 only, h2c - /// .Bind(IPAddress.Any, 8443, certificate) // all three - /// - /// new IoxideOptions - /// { - /// ProtocolsByPort = - /// { - /// [8081] = IoxideProtocols.Http2, - /// [8443] = IoxideProtocols.All, - /// } - /// } - /// - /// - /// A port left out follows . A port given neither HTTP/1.1 nor - /// HTTP/2 still has a TCP listener, since the endpoint is bound, so HTTP/1.1 is served there - /// rather than accepting connections and answering nothing. - /// public Dictionary ProtocolsByPort { get; init; } = []; /// The HTTP/3 endpoint: the certificate QUIC serves, and QPACK. @@ -60,42 +28,24 @@ public sealed record IoxideOptions } /// -/// The HTTP/3 endpoint. +/// The HTTP/3 endpoint. Only consulted when a port serves . /// -/// -/// Only consulted when a port serves . QUIC's certificate and -/// HTTP/3's QPACK are both here because they configure the same endpoint, even though they belong -/// to different layers of it. -/// public sealed record IoxideHttp3Options { /// - /// PEM certificate chain for the HTTP/3 listener, as a path. Required to serve HTTP/3. + /// PEM certificate chain for the HTTP/3 listener, as a path. Required to serve HTTP/3, and + /// should be the certificate bound to the endpoint rather than a second one - it is named + /// separately only because ngtcp2 loads PEM by path, and the engine writes none for you. /// - /// - /// Not a second certificate: this should be the one bound to the endpoint, which is what - /// HTTP/1.1 and HTTP/2 serve there. It is named separately because ngtcp2 loads PEM by path and - /// has no in-memory alternative - unlike OpenSSL, which terminates the TCP protocols and takes - /// the PEM text directly. - /// - /// The engine will not write one out for you. A key it creates is a key it has to choose - /// a location and a lifetime for, and one written to a temporary directory outlives any - /// shutdown that skips cleanup - so an endpoint asking for HTTP/3 without this is a - /// configuration error rather than something to paper over. - /// public string? CertificatePath { get; init; } /// PEM private key for the HTTP/3 listener. Pairs with . public string? KeyPath { get; init; } /// - /// Bytes of QPACK dynamic table advertised to HTTP/3 clients. + /// Bytes of QPACK dynamic table advertised to HTTP/3 clients. 0 keeps every header literal + /// against the static table, which costs bytes but can never stall a stream on a table update. /// - /// - /// 0 keeps every header literal against the static table, which costs bytes but can never stall - /// a stream waiting for a table update. Only browsers advertise a table of their own; every - /// other client measured sends 0, which makes the mechanism inert whatever is set here. - /// public long QpackDynamicTableCapacity { get; init; } /// @@ -106,43 +56,26 @@ public sealed record IoxideHttp3Options } /// -/// Mutual TLS, enforced on every protocol. +/// What client certificates are validated against - by OpenSSL for HTTP/1.1 and HTTP/2, by ngtcp2 +/// for HTTP/3, so a bad chain is refused before any request exists. WHICH endpoints ask for one is +/// decided per endpoint, by the certificateValidator passed to Bind. /// -/// -/// Validation happens where the connection is terminated - by OpenSSL for HTTP/1.1 and HTTP/2, by -/// ngtcp2 for HTTP/3 - so a chain that does not validate is refused before any request exists. -/// -/// WHICH endpoints ask for a certificate is decided per endpoint, by the -/// certificateValidator passed to Bind: an endpoint with one asks, an endpoint -/// without one does not. What lives here is what the offered certificate is checked against, which -/// the whole server shares. -/// public sealed record IoxideMutualTlsOptions { /// - /// PEM bundle of trust anchors that client certificates are validated against, as a path. + /// PEM bundle of trust anchors that client certificates are validated against, as a path. Its + /// subject names are also sent in the CertificateRequest, so a client holding several + /// certificates can pick the right one; sends no such hint. /// - /// - /// The file's subject names are also sent in the CertificateRequest, so a client holding several - /// certificates can pick the one this server accepts rather than guessing. - /// is trusted identically but sends no such hint. - /// public string? ClientCaPath { get; init; } /// The trust anchors as PEM text - the in-memory alternative to . public string? ClientCaPem { get; init; } /// - /// Refuse a client that offers no certificate at all, on every secure endpoint. + /// Refuse a client that offers no certificate, on every secure endpoint; false still asks for + /// one and validates what arrives. Usually left alone, since an endpoint's + /// certificateValidator raises it for that endpoint. The two are ORed. /// - /// - /// False asks for one and validates what arrives, but lets a client offering nothing through - - /// which is what a server serving both a public and a mutually authenticated route wants, since - /// it can read who connected and decide per request. - /// - /// Usually left alone: an endpoint's certificateValidator raises this for that - /// endpoint through RequireCertificate, which is how one port requires a certificate - /// while another stays open. The two are ORed. - /// public bool RequireClientCertificate { get; init; } } diff --git a/Engine/Ioxide/IoxideProtocols.cs b/Engine/Ioxide/IoxideProtocols.cs index 6842d1567..7de96cbda 100644 --- a/Engine/Ioxide/IoxideProtocols.cs +++ b/Engine/Ioxide/IoxideProtocols.cs @@ -1,18 +1,10 @@ namespace GenHTTP.Engine.Ioxide; /// -/// The protocols an endpoint serves. +/// The protocols an endpoint serves. and share the TCP +/// socket, so enabling both turns no client away; is a UDP socket on the same +/// port number, which is what lets one endpoint serve all three. /// -/// -/// and share the TCP socket - which of them a connection -/// gets is settled by ALPN on a secure endpoint and by the connection preface on a plaintext one, so -/// enabling both costs nothing and turns no client away. is a UDP socket on the -/// same port number, independent of either. -/// -/// That independence is what lets one endpoint serve all three: TCP carries HTTP/1.1 and -/// HTTP/2, UDP carries HTTP/3, and a browser told about the third by an Alt-Svc header moves itself -/// across without changing port. -/// [Flags] public enum IoxideProtocols { @@ -21,8 +13,7 @@ public enum IoxideProtocols /// /// HTTP/2 over TCP: by ALPN on a secure endpoint, or by the connection preface (h2c with prior - /// knowledge) on a plaintext one. The Upgrade: dance is not implemented, which is what - /// every deployed h2c client does. + /// knowledge) on a plaintext one. The Upgrade: dance is not implemented. /// Http2 = 2, @@ -36,24 +27,15 @@ public enum IoxideProtocols Http1AndHttp2 = Http1 | Http2, /// - /// HTTP/1.1 over TCP and HTTP/3 over UDP, skipping HTTP/2 entirely. + /// HTTP/1.1 over TCP and HTTP/3 over UDP, skipping HTTP/2. Every client is still served, and + /// HTTP/2's per-connection flow control and HPACK state are not paid for. /// - /// - /// Every client can still be served: one that speaks neither HTTP/2 nor HTTP/3 gets HTTP/1.1, - /// and a browser told about the QUIC port by an Alt-Svc header moves itself there. Worth having - /// when HTTP/2 is not wanted - its flow control and HPACK state cost per connection, and a - /// deployment that has HTTP/3 may have little use for it. - /// Http1AndHttp3 = Http1 | Http3, /// - /// HTTP/2 over TCP and HTTP/3 over UDP, with no HTTP/1.1 at all. + /// HTTP/2 over TCP and HTTP/3 over UDP, with no HTTP/1.1 at all. For somewhere the clients are + /// known - a private API, or gRPC - since anything else is turned away. /// - /// - /// A client that speaks neither is turned away, so this is for somewhere the clients are known - - /// a private API, or gRPC, where HTTP/2 is the floor rather than an upgrade. Note that a browser - /// reaching a plaintext endpoint cannot use h2c, so this pairs with a certificate in practice. - /// Http2AndHttp3 = Http2 | Http3, /// Everything: HTTP/1.1 and HTTP/2 over TCP, HTTP/3 over UDP, one port number. diff --git a/Engine/Ioxide/IoxideReactor.cs b/Engine/Ioxide/IoxideReactor.cs index ce961677a..16caed1a5 100644 --- a/Engine/Ioxide/IoxideReactor.cs +++ b/Engine/Ioxide/IoxideReactor.cs @@ -3,18 +3,10 @@ namespace GenHTTP.Engine.Ioxide; /// -/// Per-reactor access seam. Each ioxide reactor runs on its own thread; the engine binds the -/// current into a [ThreadStatic] slot when that thread starts, so -/// handler code (which runs on the reactor thread) can resolve per-reactor, ring-native services -/// that were registered through the onReactorStart host hook — for example -/// IoxideReactor.Current.GetService<PgPool>(). +/// Per-reactor access seam: resolves the ring-native services registered through the +/// onReactorStart host hook, e.g. IoxideReactor.Current.GetService<PgPool>(). +/// Only valid on a reactor thread, since it relies on continuations resuming inline on one. /// -/// -/// Only valid on a reactor thread (i.e. inside request handling). It relies on awaited -/// continuations resuming inline on the same reactor thread — the affinity the -/// ConnectionDriver thread-hop diagnostic verifies. Under a work-stealing scheduler the -/// slot could point at the wrong reactor. -/// public static class IoxideReactor { [ThreadStatic] diff --git a/Engine/Ioxide/Protocol/ChunkedSink.cs b/Engine/Ioxide/Protocol/ChunkedSink.cs index 627623a28..9f2eda2d7 100644 --- a/Engine/Ioxide/Protocol/ChunkedSink.cs +++ b/Engine/Ioxide/Protocol/ChunkedSink.cs @@ -6,10 +6,9 @@ namespace GenHTTP.Engine.Ioxide.Protocol; /// -/// Response sink for unknown-length content: both the and the -/// route through a , so everything the content -/// writes is HTTP/1.1 chunk-framed. Call after the content is written to -/// emit the terminating chunk. +/// Response sink for unknown-length content: both channels route through a +/// , so everything the content writes is chunk-framed. +/// emits the terminating chunk. /// internal sealed class ChunkedSink : IResponseSink { diff --git a/Engine/Ioxide/Protocol/ChunkedWriter.cs b/Engine/Ioxide/Protocol/ChunkedWriter.cs index 391495c1b..4c3b4c79b 100644 --- a/Engine/Ioxide/Protocol/ChunkedWriter.cs +++ b/Engine/Ioxide/Protocol/ChunkedWriter.cs @@ -4,10 +4,9 @@ namespace GenHTTP.Engine.Ioxide.Protocol; /// -/// Wraps a and frames every as an HTTP/1.1 -/// transfer-encoding chunk (size in hex + CRLF + data + CRLF). Ported from GenHTTP's Internal -/// engine (ChunkedWriter): a fixed 8-hex-digit size header is reserved ahead of the payload so -/// the chunk can be framed in place without a second copy. +/// Frames every as a transfer-encoding chunk (hex size, CRLF, data, CRLF). +/// A fixed 8-digit size header is reserved ahead of the payload, so the chunk is framed in place +/// without a second copy. Ported from the Internal engine. /// internal sealed class ChunkedWriter(PipeWriter writer) : IBufferWriter { diff --git a/Engine/Ioxide/Protocol/ConnectionDriver.cs b/Engine/Ioxide/Protocol/ConnectionDriver.cs index 5565d0137..4459799e5 100644 --- a/Engine/Ioxide/Protocol/ConnectionDriver.cs +++ b/Engine/Ioxide/Protocol/ConnectionDriver.cs @@ -11,24 +11,17 @@ namespace GenHTTP.Engine.Ioxide.Protocol; /// /// Takes an accepted TCP connection, establishes its transport, and hands it to the protocol it -/// turns out to be speaking. +/// turns out to be speaking - or , decided by +/// ALPN on a secure endpoint and by the HTTP/2 connection preface on a plaintext one. HTTP/3 never +/// reaches this: QUIC is a UDP listener and goes straight to . /// -/// -/// Everything arriving over TCP comes through here - HTTP/1.1 and HTTP/2 both, since they share the -/// socket. Which of them a connection is settles two ways: ALPN during the TLS handshake on a secure -/// endpoint, and the HTTP/2 connection preface on a plaintext one. HTTP/3 never reaches this: QUIC -/// is a UDP listener and goes straight to . -/// -/// Once decided, the connection belongs to or -/// for its lifetime, and this only tears it down again. -/// internal static partial class ConnectionDriver { /// - /// Half-close (SHUT_WR = 1) the socket's write side to send FIN. ioxide's refcounted teardown does not - /// FIN a server-initiated close by itself (the reactor's active recv keeps a reference), so an - /// EOF-delimited response (connection-close / upgrade) would otherwise hang the client. The read side - /// stays open so the client's own close is still observed and the reactor reclaims the connection. + /// Half-closes the write side to send FIN. ioxide's refcounted teardown does not FIN a + /// server-initiated close by itself (the reactor's active recv keeps a reference), so an + /// EOF-delimited response would hang the client. The read side stays open, so the client's own + /// close is still observed and the reactor reclaims the connection. /// private const int ShutWrite = 1; @@ -45,8 +38,7 @@ internal static async Task HandleAsync(IServer server, IEndPoint endPoint, IoCon { IDuplexPipe pipe; - // What ALPN settled on, when the transport negotiated anything. Null on a plaintext port, - // and on a TLS port whose client offered nothing we serve. + // Null on a plaintext port, and on a TLS port whose client offered nothing we serve. string? negotiated = null; try @@ -57,9 +49,9 @@ internal static async Task HandleAsync(IServer server, IEndPoint endPoint, IoCon } else if (endPoint.Secure) { - // A secure port with no certificate (an SNI-only provider yielded none) is advertised - // for redirects but cannot handshake - FIN the connection so the client's handshake - // fails fast rather than a plaintext response landing on an https port. + // A secure port with no certificate is advertised for redirects but cannot + // handshake - FIN, so the client fails fast rather than a plaintext response + // landing on an https port. if (!IoxideReactor.Current.GetService().TryFor(conn.ListenerPort, out var service)) { _ = Shutdown(conn.ClientFd, ShutWrite); @@ -81,15 +73,12 @@ internal static async Task HandleAsync(IServer server, IEndPoint endPoint, IoCon return; } - // The peer address is constant for the connection; resolve it once from the socket fd. var remoteAddress = GetPeerAddress(conn.ClientFd); var http2 = protocols.HasFlag(IoxideProtocols.Http2); var http1 = protocols.HasFlag(IoxideProtocols.Http1); - // Only worth peeking when both share the port. On an HTTP/2-only port every connection is - // HTTP/2 by definition, and on an HTTP/1.1-only port the preface would be a malformed - // request line either way. + // Only worth peeking when both share the port: elsewhere the answer is already known. var isHttp2 = http2 && (negotiated == "h2" || (negotiated is null && http1 && await StartsWithPrefaceAsync(pipe.Input))); if (http2 && !http1) @@ -115,9 +104,8 @@ internal static async Task HandleAsync(IServer server, IEndPoint endPoint, IoCon return; } - // The port does not serve HTTP/1.1, and this connection is not HTTP/2 - which on a secure - // port means the client offered no ALPN this endpoint accepts. Close rather than answer it - // with a protocol the endpoint was configured not to speak. + // Not HTTP/2 on a port that does not serve HTTP/1.1 either: close, rather than answer with + // a protocol the endpoint was configured not to speak. if (!http1) { await CloseAsync(pipe, conn); @@ -144,9 +132,8 @@ private static async ValueTask StartsWithPrefaceAsync(PipeReader reader) Span head = stackalloc byte[Preface.Length]; buffer.Slice(0, Preface.Length).CopyTo(head); - // Nothing consumed AND nothing examined: marking these bytes examined would tell the - // pipe we are waiting for more, and whichever protocol reads next would block on data - // that has already arrived. + // Nothing consumed AND nothing examined: marking these examined would tell the pipe + // we want more, and whichever protocol reads next would block on data already here. reader.AdvanceTo(buffer.Start, buffer.Start); return head.SequenceEqual(Preface.Span); @@ -163,13 +150,10 @@ private static async ValueTask StartsWithPrefaceAsync(PipeReader reader) } /// - /// Ends a connection: complete both halves, tear down the transport, FIN, release. + /// Ends a connection: complete both halves, tear down the transport, FIN, release. Completing + /// the writer matters beyond tidiness - connection-close and upgrade responses are delimited by + /// FIN, and without it the client waits for bytes that never come. /// - /// - /// Completing the writer matters beyond tidiness. Length-delimited responses do not need it, but - /// connection-close and upgrade (101) responses are delimited by FIN, and without it the client - /// waits for bytes that never come. - /// internal static async ValueTask CloseAsync(IDuplexPipe pipe, IoConnection conn) { await pipe.Input.CompleteAsync(); @@ -184,10 +168,8 @@ internal static async ValueTask CloseAsync(IDuplexPipe pipe, IoConnection conn) conn.DecRef(); } - // The connected client's remote address, read once per connection straight from the socket fd - // (ioxide exposes the fd but not the peer address). Mirrors the Internal engine's - // Socket.RemoteEndPoint.Address - returned as-is (IPv4-mapped IPv6 on a dual-stack listener), which - // IPAddress.IsLoopback and the rest of the pipeline already handle. + // Straight from the socket fd, since ioxide exposes the fd but not the peer address. Returned + // as-is (IPv4-mapped IPv6 on a dual-stack listener), which the pipeline already handles. private static IPAddress? GetPeerAddress(int fd) { var addr = new byte[128]; // sockaddr_storage diff --git a/Engine/Ioxide/Protocol/DateHeader.cs b/Engine/Ioxide/Protocol/DateHeader.cs index dbd1ba2c3..e89f33dc2 100644 --- a/Engine/Ioxide/Protocol/DateHeader.cs +++ b/Engine/Ioxide/Protocol/DateHeader.cs @@ -3,10 +3,8 @@ namespace GenHTTP.Engine.Ioxide.Protocol; /// -/// Per-reactor cached "Date: ...\r\n" header, refreshed at most once a second. Mirrors GenHTTP's -/// Internal-engine DateHeader but is [ThreadStatic]: each -/// reactor runs on its own thread, so it owns its buffer — no cross-thread races (a shared static -/// would tear under N reactors) and no per-response formatting or allocation. +/// Per-reactor cached "Date: ...\r\n" header, refreshed at most once a second. [ThreadStatic] +/// rather than a shared static, which would tear under N reactors writing the same buffer. /// internal static class DateHeader { diff --git a/Engine/Ioxide/Protocol/Http1Driver.cs b/Engine/Ioxide/Protocol/Http1Driver.cs index a19b8ca24..9168716a6 100644 --- a/Engine/Ioxide/Protocol/Http1Driver.cs +++ b/Engine/Ioxide/Protocol/Http1Driver.cs @@ -20,35 +20,30 @@ namespace GenHTTP.Engine.Ioxide.Protocol; /// -/// Serves an HTTP/1.1 connection: parse, handle, respond, repeat until it closes. +/// Serves an HTTP/1.1 connection: parse, handle, respond, repeat until it closes. Reached from +/// once the transport is up and the protocol settled. /// /// -/// Reached from once the transport is up and the protocol settled. -/// One of these runs per connection on the reactor thread, and awaited continuations resume inline -/// on that same thread - which is what lets the request pool below go without locking. -/// -/// Unlike HTTP/2 and HTTP/3 there is one request in flight at a time, so a connection owns a -/// single for its lifetime and returns it when the connection ends. +/// One of these per connection, on the reactor thread, with awaited continuations resuming inline +/// on that same thread - which is what lets the request pool below go without locking. Unlike +/// HTTP/2 and HTTP/3 there is one request in flight at a time, so a connection owns a single +/// for its lifetime. /// internal static class Http1Driver { private static readonly ParserLimits Limits = ParserLimits.Default; - // Benchmark switch: set GENHTTP_IOXIDE_PARSER=pico to parse request headers with - // Glyph11.Pico (picohttpparser, native) instead of the hardened managed Glyph11 parser. - // Both fill the same BinaryRequest, so the rest of the pipeline is identical — only the - // header-parsing implementation differs. NOTE: the Pico path does picohttpparser-level - // validation only (no path/token/smuggling hardening); it's for benchmarking, not for - // hardening untrusted traffic. + // Benchmark switch: GENHTTP_IOXIDE_PARSER=pico parses headers with Glyph11.Pico + // (picohttpparser, native) instead of the hardened managed parser. Both fill the same + // BinaryRequest. The Pico path does picohttpparser-level validation only (no path/token/ + // smuggling hardening), so it is for benchmarking, not for untrusted traffic. private static readonly bool UsePico = string.Equals(Environment.GetEnvironmentVariable("GENHTTP_IOXIDE_PARSER"), "pico", StringComparison.OrdinalIgnoreCase); private static readonly ReadOnlyMemory KeepAliveValue = "Keep-Alive"u8.ToArray(); - // Per-reactor pool of Request objects. Each reactor runs on its own thread and services its - // connections cooperatively, so the stack needs no locking. Reuses the per-connection Request - // allocation, which matters under connection churn (e.g. limited-conn). Mirrors the Internal - // engine's ClientContext pool, adapted for thread-per-core. + // Per-reactor, so the stack needs no locking. Reuses the per-connection Request allocation, + // which matters under connection churn. [ThreadStatic] private static Stack? _requestPool; @@ -62,10 +57,8 @@ internal static async Task RunAsync(IServer server, IEndPoint endPoint, IDuplexP var request = RentRequest(); var into = request.Source; - // Diagnostic: the [ThreadStatic] Request pool and lock-free pooling assume every continuation - // in this method resumes on the reactor thread that entered it. Capture that thread now — before - // the first await — so we can warn (once) if ioxide ever resumes us on a different thread - // (e.g. under a work-stealing scheduler), which silently degrades the pool. + // Captured before the first await, so the diagnostic below can tell whether continuations + // really did resume on the reactor thread the pool assumes. var reactorThreadId = Environment.CurrentManagedThreadId; try @@ -95,7 +88,7 @@ internal static async Task RunAsync(IServer server, IEndPoint endPoint, IDuplexP continue; } - // Client cert stays null until TLS termination lands; the remote address is read from the socket. + // Client cert stays null until TLS termination exposes one. request.Apply(server, endPoint, reader, buffer.Start, remoteAddress, null); var keepAlive = await HandleRequestAsync(server, writer, request); @@ -143,7 +136,7 @@ internal static async Task RunAsync(IServer server, IEndPoint endPoint, IDuplexP private static bool TryParseRequest(ref ReadOnlySequence buffer, BinaryRequest into) => UsePico ? TryParseRequestPico(ref buffer, into) : TryParseRequestGlyph11(ref buffer, into); - // Hardened managed parser (default): full RFC + smuggling validation. + // Default: full RFC + smuggling validation. private static bool TryParseRequestGlyph11(ref ReadOnlySequence buffer, BinaryRequest into) { if (!UltraHardenedParser.TryExtractFullHeaderValidated(ref buffer, into, Limits, out var bytesRead)) @@ -155,8 +148,8 @@ private static bool TryParseRequestGlyph11(ref ReadOnlySequence buffer, Bi return true; } - // picohttpparser (native) — single-segment is parsed in place, multi-segment is linearized. - // `consumed` follows the same -1 convention as the managed parser, so the slice is identical. + // picohttpparser: single-segment is parsed in place, multi-segment is linearized. `consumed` + // follows the managed parser's -1 convention, so the slice is identical. private static bool TryParseRequestPico(ref ReadOnlySequence buffer, BinaryRequest into) { if (!PicoParser.TryParse(buffer, into, out var consumed)) @@ -202,12 +195,10 @@ private static void ReturnRequest(Request request) } } - // Set to 1 the first time a continuation is seen resuming off the reactor thread. private static int _hopWarned; - // Warns at most once per process if this phase's continuation resumed on a different thread than the - // reactor thread that entered RunAsync. On the fast (affine) path it's a single int compare, so it's - // safe to leave enabled during benchmarks — the one-shot guard keeps it from perturbing throughput. + // Warns once per process if a continuation resumed off the reactor thread. On the affine path + // it is a single int compare, so it can stay enabled during benchmarks. private static void WarnIfThreadHopped(IServer server, int reactorThreadId, string phase) { var now = Environment.CurrentManagedThreadId; diff --git a/Engine/Ioxide/Protocol/Http2Driver.cs b/Engine/Ioxide/Protocol/Http2Driver.cs index b14b9b8b1..d02463859 100644 --- a/Engine/Ioxide/Protocol/Http2Driver.cs +++ b/Engine/Ioxide/Protocol/Http2Driver.cs @@ -17,10 +17,9 @@ namespace GenHTTP.Engine.Ioxide.Protocol; /// request onto GenHTTP's handler chain. /// /// -/// Streamed both ways. A handler starts once the request headers have arrived, pulls the body as it -/// is delivered (paced by flow control, so an upload cannot outrun it) and writes its response into -/// a writer that frames each flush as a DATA frame - so a large download is never assembled in -/// memory and is paced by the peer's window. +/// Streamed both ways: the handler starts at end-of-headers, pulls the body as flow control +/// delivers it, and writes into a writer that frames each flush as a DATA frame - so neither +/// direction is assembled in memory and both are paced by the peer's window. /// internal static class Http2Driver { @@ -29,8 +28,8 @@ internal static class Http2Driver private static readonly Http2Options Options = new() { StreamRequestBodies = true }; /// - /// Serves an HTTP/2 connection over an established transport: a TLS pipe that negotiated "h2" by - /// ALPN, or a plaintext pipe carrying h2c with prior knowledge. + /// Serves the connection over an established transport: a TLS pipe that negotiated "h2", or a + /// plaintext pipe carrying h2c with prior knowledge. /// internal static Task RunAsync(IServer server, IEndPoint endPoint, IDuplexPipe pipe, IPAddress? remoteAddress, bool secure) => new Http2Connection(pipe, Options) diff --git a/Engine/Ioxide/Protocol/Http3Driver.cs b/Engine/Ioxide/Protocol/Http3Driver.cs index ffeb39cf5..8bd63cefc 100644 --- a/Engine/Ioxide/Protocol/Http3Driver.cs +++ b/Engine/Ioxide/Protocol/Http3Driver.cs @@ -15,20 +15,15 @@ namespace GenHTTP.Engine.Ioxide.Protocol; /// each request onto GenHTTP's handler chain. /// /// -/// Streamed both ways, as with HTTP/2. A response is written as it is produced and each flush parks -/// until the peer's window and the connection's send-retention high-water allow more, so serving a -/// large file costs about that high-water in memory rather than the size of the file. -/// -/// nghttp3 brings the parts that are laborious by hand - QPACK with a static-table encoder, -/// stream priorities, GOAWAY draining - and ngtcp2 brings QUIC itself. +/// Streamed both ways, as with HTTP/2: each flush parks until the peer's window and the send +/// retention high-water allow more, so a large file costs about that high-water in memory rather +/// than its own size. /// internal static class Http3Driver { private static readonly ReadOnlyMemory Head = "HEAD"u8.ToArray(); - /// - /// Serves one accepted QUIC connection until it closes. - /// + /// Serves one accepted QUIC connection until it closes. internal static Task RunAsync(IServer server, IEndPoint endPoint, QuicConnection connection, Nghttp3Options options) => new Nghttp3Connection(connection, options) .RunStreamedResponseAsync((request, writer) => DispatchAsync(server, endPoint, request, writer)); @@ -50,10 +45,9 @@ private static async ValueTask DispatchAsync(IServer server, IEndPoint endPoint, var reader = request.BodyReader; - // Always secure: HTTP/3 runs over QUIC, which carries TLS 1.3 and has no cleartext mode. - // The client address stays null - ioxide's QuicConnection tracks the peer address (it has - // to, for path validation) but exposes no way to read it, and a QUIC peer may migrate - // mid-connection anyway. + // Always secure: QUIC carries TLS 1.3 and has no cleartext mode. The client address + // stays null - ioxide's QuicConnection exposes no way to read the peer address, and a + // QUIC peer may migrate mid-connection anyway. await using var mapped = new MultiplexedRequest(server, endPoint, request.Method, request.Path, request.Authority, headers, reader is null ? null : reader.ReadAsync, remoteAddress: null, HttpProtocol.Http3, secure: true); diff --git a/Engine/Ioxide/Protocol/Multiplexed/MultiplexedKeyValueList.cs b/Engine/Ioxide/Protocol/Multiplexed/MultiplexedKeyValueList.cs index c3944f1d4..ea32d39a1 100644 --- a/Engine/Ioxide/Protocol/Multiplexed/MultiplexedKeyValueList.cs +++ b/Engine/Ioxide/Protocol/Multiplexed/MultiplexedKeyValueList.cs @@ -3,12 +3,10 @@ namespace GenHTTP.Engine.Ioxide.Protocol.Multiplexed; /// -/// Header and query lists over fields a multiplexed protocol has already decoded. +/// Header and query lists over fields a multiplexed protocol has already decoded. The shared list +/// wraps Glyph11's parse output and so assumes HTTP/1.1; HPACK and QPACK hand over name/value +/// pairs instead, with no request line and no raw header block to point back at. /// -/// -/// The shared list wraps Glyph11's parse output and so assumes HTTP/1.1. HPACK and QPACK hand over -/// name/value pairs instead, with no request line and no raw header block to point back at. -/// internal sealed class MultiplexedKeyValueList : IRequestHeaders, IRequestQuery { private readonly List<(ReadOnlyMemory Name, ReadOnlyMemory Value)> _entries; diff --git a/Engine/Ioxide/Protocol/Multiplexed/MultiplexedRequest.cs b/Engine/Ioxide/Protocol/Multiplexed/MultiplexedRequest.cs index 2cf0b6fd1..945937741 100644 --- a/Engine/Ioxide/Protocol/Multiplexed/MultiplexedRequest.cs +++ b/Engine/Ioxide/Protocol/Multiplexed/MultiplexedRequest.cs @@ -8,13 +8,12 @@ namespace GenHTTP.Engine.Ioxide.Protocol.Multiplexed; /// -/// An over a request decoded by HPACK or QPACK. +/// An over a request decoded by HPACK or QPACK - not the shared +/// , whose Source assumes an HTTP/1.1 parse off a pipe. /// /// -/// Not the shared , whose Source is a Glyph11 BinaryRequest and therefore -/// assumes an HTTP/1.1 parse off a pipe. Nothing here is pooled: both protocols multiplex, so -/// several of these are live on one connection at once and a per-connection pool would need locking -/// to be safe - which is exactly what the reactor model is trying to avoid. +/// Nothing here is pooled: several are live on one connection at once, so a pool would need the +/// locking the reactor model exists to avoid. /// internal sealed class MultiplexedRequest : IRequest { @@ -76,16 +75,14 @@ internal MultiplexedRequest(IServer server, IEndPoint endPoint, ReadOnlyMemory _response.Status(ResponseStatus.Ok); /// - /// Not supported. Upgrading to a raw byte stream is an HTTP/1.1 mechanism; a multiplexed - /// protocol reaches its streams through the transport rather than through a request. + /// Not supported: upgrading to a raw byte stream is an HTTP/1.1 mechanism. /// public PipeReader Upgrade() => throw new NotSupportedException("Connection upgrades are not available over HTTP/2 or HTTP/3."); public ValueTask DisposeAsync() => new(); - // The query string, which both protocols carry inside :path exactly as HTTP/1.1 carries it - // inside the request target. + // Both protocols carry the query inside :path, as HTTP/1.1 carries it in the request target. private static List<(ReadOnlyMemory Name, ReadOnlyMemory Value)> ParseQuery(ReadOnlyMemory path) { var parameters = new List<(ReadOnlyMemory, ReadOnlyMemory)>(); diff --git a/Engine/Ioxide/Protocol/Multiplexed/MultiplexedRequestBody.cs b/Engine/Ioxide/Protocol/Multiplexed/MultiplexedRequestBody.cs index ecaf1a2b4..77d1869bf 100644 --- a/Engine/Ioxide/Protocol/Multiplexed/MultiplexedRequestBody.cs +++ b/Engine/Ioxide/Protocol/Multiplexed/MultiplexedRequestBody.cs @@ -3,15 +3,13 @@ namespace GenHTTP.Engine.Ioxide.Protocol.Multiplexed; /// -/// A request body pulled from the protocol layer as it arrives. +/// A request body pulled from the protocol layer as it arrives - dispatch happens at +/// end-of-headers, so it is still in flight when the handler starts. Flow control paces it: the +/// window only reopens as chunks are consumed, so an upload cannot outrun the handler. /// /// -/// Both protocols dispatch at end-of-headers under streamed dispatch, so the body is still in flight -/// when the handler starts. Reads are paced by flow control: an upload cannot outrun the handler, -/// because the window only reopens as chunks are consumed. -/// -/// The read delegate abstracts the two body readers, which have the same shape but come from -/// different packages. It returns empty once the request stream has ended. +/// The read delegate abstracts the two body readers, which have the same shape but come from +/// different packages. It returns empty once the request stream has ended. /// internal sealed class MultiplexedRequestBody : IRequestBody { @@ -26,8 +24,8 @@ internal MultiplexedRequestBody(Func>> read) public async ValueTask> AsMemoryAsync() { - // Assembling defeats the point of streaming, so this only runs when a handler asks for the - // whole body - which some do, and which has to keep working. + // Assembling defeats the point of streaming, but a handler asking for the whole body has + // to keep working. var assembled = new MemoryStream(); while (true) @@ -45,9 +43,7 @@ public async ValueTask> AsMemoryAsync() return assembled.ToArray(); } - /// - /// Presents the pull-based reader as a forward-only stream. - /// + /// Presents the pull-based reader as a forward-only stream. private sealed class PullStream : Stream { private readonly Func>> _read; @@ -106,8 +102,7 @@ public override async ValueTask ReadAsync(Memory buffer, Cancellation public override async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) => await ReadAsync(buffer.AsMemory(offset, count), cancellationToken); - // Synchronous reads would have to block the reactor thread waiting for a chunk that only - // arrives when that same thread pumps the connection - a guaranteed deadlock. + // A sync read would block the reactor thread on a chunk only that thread can deliver. public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException("The request body must be read asynchronously."); diff --git a/Engine/Ioxide/Protocol/Multiplexed/MultiplexedRequestHeader.cs b/Engine/Ioxide/Protocol/Multiplexed/MultiplexedRequestHeader.cs index a7ad76981..d1cf16f11 100644 --- a/Engine/Ioxide/Protocol/Multiplexed/MultiplexedRequestHeader.cs +++ b/Engine/Ioxide/Protocol/Multiplexed/MultiplexedRequestHeader.cs @@ -25,8 +25,7 @@ internal MultiplexedRequestHeader(ReadOnlyMemory method, ReadOnlyMemory method, ReadOnlyMemory _target; - // Settled before a byte of the request arrived - by ALPN for HTTP/2, by QUIC plus ALPN for - // HTTP/3 - so there is no version token on the wire to read. + // Settled by ALPN before a byte arrived, so there is no version token on the wire to read. public HttpProtocol Protocol { get; } public ReadOnlyMemory Version { get; } @@ -53,10 +51,8 @@ internal MultiplexedRequestHeader(ReadOnlyMemory method, ReadOnlyMemory _query; /// - /// HTTP/2 and HTTP/3 carry the authority as the :authority pseudo-header, and clients omit Host - /// entirely. RFC 9113 8.3.1 and RFC 9114 4.3.1 have an intermediary translating to HTTP/1.1 - /// construct Host from it, which is what this does: everything above the engine - routing, - /// virtual hosting, redirects - expects a Host header to exist. + /// Constructs Host from :authority, which clients send instead - as RFC 9113 8.3.1 and RFC 9114 + /// 4.3.1 have an intermediary do. Routing, virtual hosting and redirects all expect a Host. /// private static List<(ReadOnlyMemory Name, ReadOnlyMemory Value)> WithHost( List<(ReadOnlyMemory Name, ReadOnlyMemory Value)> headers, ReadOnlyMemory authority) diff --git a/Engine/Ioxide/Protocol/Multiplexed/MultiplexedResponder.cs b/Engine/Ioxide/Protocol/Multiplexed/MultiplexedResponder.cs index cd62fca17..c95e93bd9 100644 --- a/Engine/Ioxide/Protocol/Multiplexed/MultiplexedResponder.cs +++ b/Engine/Ioxide/Protocol/Multiplexed/MultiplexedResponder.cs @@ -6,12 +6,9 @@ namespace GenHTTP.Engine.Ioxide.Protocol.Multiplexed; /// -/// The status and fields of a response, ready to be handed to a protocol layer. +/// The status and fields of a response. Protocol-neutral on purpose: the two drivers want the same +/// thing, but their response types come from different packages, so each builds its own from this. /// -/// -/// Neutral on purpose. HTTP/2 and HTTP/3 want the same thing, but their response types come from -/// different packages, so each driver builds its own from this. -/// internal readonly struct MultiplexedResponseData { internal MultiplexedResponseData(int status, List<(ReadOnlyMemory Name, ReadOnlyMemory Value)> headers) @@ -40,9 +37,7 @@ internal static class MultiplexedResponder private static readonly ReadOnlyMemory ServerValue = "ioxide-genhttp"u8.ToArray(); - /// - /// Builds the field section. Does not touch the content, which is streamed afterwards. - /// + /// Builds the field section. The content is streamed afterwards. internal static MultiplexedResponseData BuildHeaders(IResponse response) { var headers = new List<(ReadOnlyMemory Name, ReadOnlyMemory Value)>(response.Headers.Count + 4); @@ -51,9 +46,8 @@ internal static MultiplexedResponseData BuildHeaders(IResponse response) { var header = response.Headers.GetMemoryEntry(i); - // Connection-specific fields are malformed in HTTP/2 and HTTP/3 (RFC 9113 8.2.2, - // RFC 9114 4.2) - a peer may treat one as a protocol error rather than ignore it. - // Names are passed through as they are: both ioxide layers lowercase as they pack. + // Connection-specific fields are malformed here (RFC 9113 8.2.2, RFC 9114 4.2) and a + // peer may treat one as a protocol error. Casing is left alone: both layers lowercase. if (!IsConnectionSpecific(header.Key.Span)) { headers.Add((header.Key, header.Value)); @@ -75,8 +69,7 @@ internal static MultiplexedResponseData BuildHeaders(IResponse response) } // A streamed response has no length by the time its headers go out, so neither layer - // fills this in - unlike their buffered paths, which know the body up front. Send it - // when the content does know, which is every static file and every fixed page. + // fills this in. Send it whenever the content itself knows. if (content.Length is { } length) { headers.Add((ContentLengthName, Digits(length))); @@ -86,9 +79,7 @@ internal static MultiplexedResponseData BuildHeaders(IResponse response) return new MultiplexedResponseData((int)response.Status, headers); } - /// - /// Streams the content into the protocol's response writer. - /// + /// Streams the content into the protocol's response writer. internal static async ValueTask WriteBodyAsync(IResponse response, IBufferWriter writer, Func flush, bool headRequest) { var content = response.Content; @@ -100,7 +91,7 @@ internal static async ValueTask WriteBodyAsync(IResponse response, IBufferWriter try { - // A HEAD response keeps the headers its GET would have produced and sends no body. + // HEAD keeps the headers its GET would have produced and sends no body. if (!headRequest) { await content.WriteAsync(new MultiplexedSink(writer, flush)); diff --git a/Engine/Ioxide/Protocol/Multiplexed/MultiplexedSink.cs b/Engine/Ioxide/Protocol/Multiplexed/MultiplexedSink.cs index c676ba3ac..2becd5430 100644 --- a/Engine/Ioxide/Protocol/Multiplexed/MultiplexedSink.cs +++ b/Engine/Ioxide/Protocol/Multiplexed/MultiplexedSink.cs @@ -5,16 +5,13 @@ namespace GenHTTP.Engine.Ioxide.Protocol.Multiplexed; /// -/// Writes response content straight into a protocol response writer. +/// Writes response content straight into a protocol response writer, which is itself an +/// - so the buffer channel reaches the wire with nothing between. /// /// -/// Both protocol writers are themselves , so content that writes -/// through the buffer channel goes to the wire with nothing in between. -/// -/// The stream channel flushes on every write, which is what makes a large download bounded: -/// a flush parks until the peer's window and the connection's send retention allow more, so the -/// await is the backpressure. Content that writes through the buffer channel instead is flushed -/// once, when it finishes - fine for a page, and the reason file content should use the stream. +/// The stream channel flushes on every write, and that await is the backpressure that bounds a +/// large download. The buffer channel flushes once at the end - fine for a page, which is why file +/// content should use the stream. /// internal sealed class MultiplexedSink : IResponseSink { @@ -35,8 +32,7 @@ internal MultiplexedSink(IBufferWriter writer, Func flush) public Stream Stream => _stream ??= new FlushingStream(_writer, _flush); /// - /// Adapts the protocol writer to the stream channel, flushing each write so the content is - /// paced by the peer rather than accumulated. + /// Adapts the protocol writer to the stream channel, flushing each write so the peer paces it. /// private sealed class FlushingStream : Stream { @@ -70,8 +66,7 @@ public override long Position public override void Write(ReadOnlySpan buffer) { - // Synchronous write: the bytes are staged, but the flush that paces them cannot happen - // here. Content that writes large bodies should use the async path. + // Staged, but not paced: there is no flush on the sync path. Large bodies want async. _target.Write(buffer); _written += buffer.Length; } diff --git a/Engine/Ioxide/Protocol/PipeWriterStream.cs b/Engine/Ioxide/Protocol/PipeWriterStream.cs index cd867d917..aad8077c2 100644 --- a/Engine/Ioxide/Protocol/PipeWriterStream.cs +++ b/Engine/Ioxide/Protocol/PipeWriterStream.cs @@ -5,11 +5,10 @@ namespace GenHTTP.Engine.Ioxide.Protocol; /// -/// Minimal write-only over an so a response -/// sink's Stream writes through the SAME buffer writer as everything else, preserving ordering. -/// receives the bytes (the raw for fixed-length -/// responses, or a for chunked ones); is the -/// underlying pipe that actually drains to the socket. Mirrors GenHTTP's WritingStream. +/// Write-only over an , so a sink's Stream +/// writes through the same buffer writer as everything else and ordering is preserved. +/// takes the bytes (the raw pipe, or a ); +/// is the pipe that drains to the socket. /// internal sealed class PipeWriterStream(IBufferWriter sink, PipeWriter flush) : Stream { @@ -60,10 +59,8 @@ public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationTo return ValueTask.CompletedTask; } - // Deliberately a no-op: a synchronous flush would block the reactor thread on the pipe's - // IValueTaskSource, but that flush is completed by the very same reactor -> deadlock. The bytes - // written above are already buffered in `sink` and get drained by the end-of-response FlushAsync - // (and async callers can await FlushAsync below), so dropping the sync flush loses nothing. + // No-op on purpose: a sync flush would block the reactor thread on a pipe only that reactor + // completes. The bytes are buffered in `sink` and drain at the end-of-response FlushAsync. public override void Flush() { } public override Task FlushAsync(CancellationToken cancellationToken) => flush.FlushAsync(cancellationToken).AsTask(); diff --git a/Engine/Ioxide/Protocol/ResponseWriter.cs b/Engine/Ioxide/Protocol/ResponseWriter.cs index c67300095..3c6c2be4c 100644 --- a/Engine/Ioxide/Protocol/ResponseWriter.cs +++ b/Engine/Ioxide/Protocol/ResponseWriter.cs @@ -8,10 +8,9 @@ namespace GenHTTP.Engine.Ioxide.Protocol; /// -/// Writes an to a . Status line and body writing -/// stay engine-specific (Ioxide sinks are allocated fresh per response rather than pooled on a -/// per-connection context), but header serialization is shared with the Internal engine via -/// ; only the Server/Date header values differ. +/// Writes an to a . Header serialization is +/// shared with the Internal engine through ; body writing stays +/// engine-specific, since the sinks are allocated per response rather than pooled per connection. /// internal static class ResponseWriter { @@ -42,7 +41,7 @@ private static async ValueTask WriteBodyAsync(PipeWriter writer, IResponse respo if (content.Length is null && response.Mode != Connection.Upgrade) { - // Unknown length: chunk-frame everything the content writes, then terminate. + // Unknown length: chunk-frame everything, then terminate. var sink = new ChunkedSink(writer); await content.WriteAsync(sink); sink.Finish(); diff --git a/Engine/Ioxide/Server.cs b/Engine/Ioxide/Server.cs index 1fdf43b7c..0438d2d38 100644 --- a/Engine/Ioxide/Server.cs +++ b/Engine/Ioxide/Server.cs @@ -12,42 +12,32 @@ namespace GenHTTP.Engine.Ioxide; /// public static class Host { - + /// - /// Optional hook to tune the ioxide runtime (reactor count, ring sizes, recv/write - /// buffers, ...). Receives a config pre-seeded with sensible defaults; return a - /// modified copy, e.g. c => c with { ReactorCount = 16 }. The listen port is - /// always taken from the GenHTTP endpoint binding (.Port()/.Bind()). + /// Tunes the ioxide runtime, e.g. c => c with { ReactorCount = 16 }. Listen ports always + /// come from the GenHTTP endpoint bindings. /// /// - /// Optional hook invoked once per reactor, on that reactor's own thread, before it serves. - /// Use it to register per-reactor (ring-native) services on the supplied - /// — e.g. r => PgPool.Start(r, pgOptions) — which handler code can later resolve via - /// 's reactor seam (IoxideReactor.Current). + /// Registers ring-native services on each reactor's own thread before it serves, e.g. + /// r => PgPool.Start(r, pgOptions). Handlers resolve them via IoxideReactor.Current. /// /// - /// Optional hook to turn an accepted into the duplex pipe the engine - /// serves it over, overriding the built-in transport selection (plain pipe, or TLS termination - /// for endpoints bound with a certificate). A returned pipe implementing + /// Replaces the built-in transport selection. A returned pipe implementing /// is disposed when the connection ends. /// /// - /// Offload TLS record ENCRYPTION to the kernel (kTLS TX) on TLS-terminated endpoints instead of - /// encrypting in OpenSSL. Off by default (OpenSSL both ways). The kernel produces the records on - /// the send path while OpenSSL still drives the handshake; requires the Linux tls module - /// and TLS 1.3. + /// Encrypt TLS records in the kernel (kTLS TX) rather than OpenSSL, which still handshakes. + /// Needs the Linux tls module and TLS 1.3. /// /// - /// Offload TLS record DECRYPTION to the kernel (kTLS RX) on the receive path. Off by default and - /// experimental; it requires (RX shares the ULP handoff TX installs, - /// so ioxide refuses RX alone) and a peer that sends no post-handshake control records. + /// Decrypt in the kernel too. Experimental: needs (RX shares the + /// ULP handoff TX installs) and a peer sending no post-handshake control records. /// /// - /// Protocol and TLS options for the engine: HTTP/2, the HTTP/3 certificate, mutual TLS and - /// QPACK. Endpoint-level settings stay on Bind - the port, its certificate, whether it - /// serves HTTP/3 (enableQuic) and whether it asks for a client certificate. + /// Protocols, the HTTP/3 certificate, mutual TLS and QPACK. Per-endpoint settings stay on + /// Bind. /// public static IServerHost Create(Func? configure = null, Action? onReactorStart = null, Func>? connectionFactory = null, bool kernelTx = false, bool kernelRx = false, IoxideOptions? options = null) => new IoxideServerHost(configure, onReactorStart, connectionFactory, kernelTx, kernelRx, options); - + } diff --git a/Engine/Ioxide/Tls/IoxideTls.cs b/Engine/Ioxide/Tls/IoxideTls.cs index ad06e4875..0acf97b34 100644 --- a/Engine/Ioxide/Tls/IoxideTls.cs +++ b/Engine/Ioxide/Tls/IoxideTls.cs @@ -6,8 +6,8 @@ namespace GenHTTP.Engine.Ioxide; /// -/// TLS helpers for the ioxide engine. Endpoints bound with a certificate are terminated -/// automatically; these helpers remain for hosts that wire a custom connectionFactory. +/// TLS helpers for hosts wiring a custom connectionFactory. Endpoints bound with a +/// certificate are terminated automatically and need none of this. /// public static class IoxideTls { @@ -28,8 +28,8 @@ internal static async ValueTask AcceptAsync(TcpConnection conn, Tls /// /// Terminates TLS and reports what ALPN settled on, which is how a port serving several - /// protocols knows which one this connection speaks. Null means the client offered nothing this - /// port lists, in which case it continues without an ALPN extension and HTTP/1.1 is assumed. + /// protocols knows which one this connection speaks. Null means the client offered nothing the + /// port lists, and HTTP/1.1 is assumed. /// internal static async ValueTask<(IDuplexPipe Pipe, string? Protocol)> AcceptWithAlpnAsync(TcpConnection conn, TlsService service) { From 8db9c0f48a7dcfd9b8d5683c027285c9f2c15a53 Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Sun, 16 Aug 2026 14:54:16 +0100 Subject: [PATCH 23/55] refactor(ioxide): group the Protocol folder by protocol family Fourteen files sat flat in Protocol/, and nothing in the listing said which belonged to which protocol. The dependency graph already answered it: the six response-writing files are reachable only from Http1Driver, and the six Multiplexed ones only from Http2Driver and Http3Driver. Protocol/ ConnectionDriver.cs the TCP entry point, and the only fork between them Http1/ Http1Driver + its response writing and sinks Multiplexed/ Http2Driver, Http3Driver + what the two share Namespaces follow the folders, so the moved types are now under .Protocol.Http1 and .Protocol.Multiplexed. Both nest inside .Protocol, which is how the drivers still reach ConnectionDriver without importing anything. --- Engine/Ioxide/Protocol/ConnectionDriver.cs | 3 +++ Engine/Ioxide/Protocol/{ => Http1}/ChunkedSink.cs | 2 +- Engine/Ioxide/Protocol/{ => Http1}/ChunkedWriter.cs | 2 +- Engine/Ioxide/Protocol/{ => Http1}/DateHeader.cs | 2 +- Engine/Ioxide/Protocol/{ => Http1}/Http1Driver.cs | 2 +- Engine/Ioxide/Protocol/{ => Http1}/IoxideSink.cs | 2 +- Engine/Ioxide/Protocol/{ => Http1}/PipeWriterStream.cs | 2 +- Engine/Ioxide/Protocol/{ => Http1}/ResponseWriter.cs | 2 +- Engine/Ioxide/Protocol/{ => Multiplexed}/Http2Driver.cs | 4 +--- Engine/Ioxide/Protocol/{ => Multiplexed}/Http3Driver.cs | 4 +--- Testing/Acceptance/Engine/Ioxide/PipeWriterStreamTests.cs | 2 +- 11 files changed, 13 insertions(+), 14 deletions(-) rename Engine/Ioxide/Protocol/{ => Http1}/ChunkedSink.cs (94%) rename Engine/Ioxide/Protocol/{ => Http1}/ChunkedWriter.cs (97%) rename Engine/Ioxide/Protocol/{ => Http1}/DateHeader.cs (95%) rename Engine/Ioxide/Protocol/{ => Http1}/Http1Driver.cs (99%) rename Engine/Ioxide/Protocol/{ => Http1}/IoxideSink.cs (86%) rename Engine/Ioxide/Protocol/{ => Http1}/PipeWriterStream.cs (98%) rename Engine/Ioxide/Protocol/{ => Http1}/ResponseWriter.cs (97%) rename Engine/Ioxide/Protocol/{ => Multiplexed}/Http2Driver.cs (97%) rename Engine/Ioxide/Protocol/{ => Multiplexed}/Http3Driver.cs (97%) diff --git a/Engine/Ioxide/Protocol/ConnectionDriver.cs b/Engine/Ioxide/Protocol/ConnectionDriver.cs index 4459799e5..9b8725140 100644 --- a/Engine/Ioxide/Protocol/ConnectionDriver.cs +++ b/Engine/Ioxide/Protocol/ConnectionDriver.cs @@ -5,6 +5,9 @@ using GenHTTP.Api.Infrastructure; +using GenHTTP.Engine.Ioxide.Protocol.Http1; +using GenHTTP.Engine.Ioxide.Protocol.Multiplexed; + using IoConnection = ioxide.TcpConnection; namespace GenHTTP.Engine.Ioxide.Protocol; diff --git a/Engine/Ioxide/Protocol/ChunkedSink.cs b/Engine/Ioxide/Protocol/Http1/ChunkedSink.cs similarity index 94% rename from Engine/Ioxide/Protocol/ChunkedSink.cs rename to Engine/Ioxide/Protocol/Http1/ChunkedSink.cs index 9f2eda2d7..8099ed785 100644 --- a/Engine/Ioxide/Protocol/ChunkedSink.cs +++ b/Engine/Ioxide/Protocol/Http1/ChunkedSink.cs @@ -3,7 +3,7 @@ using GenHTTP.Api.Protocol; -namespace GenHTTP.Engine.Ioxide.Protocol; +namespace GenHTTP.Engine.Ioxide.Protocol.Http1; /// /// Response sink for unknown-length content: both channels route through a diff --git a/Engine/Ioxide/Protocol/ChunkedWriter.cs b/Engine/Ioxide/Protocol/Http1/ChunkedWriter.cs similarity index 97% rename from Engine/Ioxide/Protocol/ChunkedWriter.cs rename to Engine/Ioxide/Protocol/Http1/ChunkedWriter.cs index 4c3b4c79b..f0c715658 100644 --- a/Engine/Ioxide/Protocol/ChunkedWriter.cs +++ b/Engine/Ioxide/Protocol/Http1/ChunkedWriter.cs @@ -1,7 +1,7 @@ using System.Buffers; using System.IO.Pipelines; -namespace GenHTTP.Engine.Ioxide.Protocol; +namespace GenHTTP.Engine.Ioxide.Protocol.Http1; /// /// Frames every as a transfer-encoding chunk (hex size, CRLF, data, CRLF). diff --git a/Engine/Ioxide/Protocol/DateHeader.cs b/Engine/Ioxide/Protocol/Http1/DateHeader.cs similarity index 95% rename from Engine/Ioxide/Protocol/DateHeader.cs rename to Engine/Ioxide/Protocol/Http1/DateHeader.cs index e89f33dc2..f24666698 100644 --- a/Engine/Ioxide/Protocol/DateHeader.cs +++ b/Engine/Ioxide/Protocol/Http1/DateHeader.cs @@ -1,6 +1,6 @@ using System.Runtime.CompilerServices; -namespace GenHTTP.Engine.Ioxide.Protocol; +namespace GenHTTP.Engine.Ioxide.Protocol.Http1; /// /// Per-reactor cached "Date: ...\r\n" header, refreshed at most once a second. [ThreadStatic] diff --git a/Engine/Ioxide/Protocol/Http1Driver.cs b/Engine/Ioxide/Protocol/Http1/Http1Driver.cs similarity index 99% rename from Engine/Ioxide/Protocol/Http1Driver.cs rename to Engine/Ioxide/Protocol/Http1/Http1Driver.cs index 9168716a6..37b5ee611 100644 --- a/Engine/Ioxide/Protocol/Http1Driver.cs +++ b/Engine/Ioxide/Protocol/Http1/Http1Driver.cs @@ -17,7 +17,7 @@ using Connection = GenHTTP.Api.Protocol.Connection; using IoConnection = ioxide.TcpConnection; -namespace GenHTTP.Engine.Ioxide.Protocol; +namespace GenHTTP.Engine.Ioxide.Protocol.Http1; /// /// Serves an HTTP/1.1 connection: parse, handle, respond, repeat until it closes. Reached from diff --git a/Engine/Ioxide/Protocol/IoxideSink.cs b/Engine/Ioxide/Protocol/Http1/IoxideSink.cs similarity index 86% rename from Engine/Ioxide/Protocol/IoxideSink.cs rename to Engine/Ioxide/Protocol/Http1/IoxideSink.cs index b5d09aa7b..9e7c3daa1 100644 --- a/Engine/Ioxide/Protocol/IoxideSink.cs +++ b/Engine/Ioxide/Protocol/Http1/IoxideSink.cs @@ -3,7 +3,7 @@ using GenHTTP.Api.Protocol; -namespace GenHTTP.Engine.Ioxide.Protocol; +namespace GenHTTP.Engine.Ioxide.Protocol.Http1; internal sealed class IoxideSink(PipeWriter writer) : IResponseSink { diff --git a/Engine/Ioxide/Protocol/PipeWriterStream.cs b/Engine/Ioxide/Protocol/Http1/PipeWriterStream.cs similarity index 98% rename from Engine/Ioxide/Protocol/PipeWriterStream.cs rename to Engine/Ioxide/Protocol/Http1/PipeWriterStream.cs index aad8077c2..779ed390e 100644 --- a/Engine/Ioxide/Protocol/PipeWriterStream.cs +++ b/Engine/Ioxide/Protocol/Http1/PipeWriterStream.cs @@ -2,7 +2,7 @@ using System.IO.Pipelines; using System.Runtime.CompilerServices; -namespace GenHTTP.Engine.Ioxide.Protocol; +namespace GenHTTP.Engine.Ioxide.Protocol.Http1; /// /// Write-only over an , so a sink's Stream diff --git a/Engine/Ioxide/Protocol/ResponseWriter.cs b/Engine/Ioxide/Protocol/Http1/ResponseWriter.cs similarity index 97% rename from Engine/Ioxide/Protocol/ResponseWriter.cs rename to Engine/Ioxide/Protocol/Http1/ResponseWriter.cs index 3c6c2be4c..6731bb646 100644 --- a/Engine/Ioxide/Protocol/ResponseWriter.cs +++ b/Engine/Ioxide/Protocol/Http1/ResponseWriter.cs @@ -5,7 +5,7 @@ using GenHTTP.Engine.Shared.Types; -namespace GenHTTP.Engine.Ioxide.Protocol; +namespace GenHTTP.Engine.Ioxide.Protocol.Http1; /// /// Writes an to a . Header serialization is diff --git a/Engine/Ioxide/Protocol/Http2Driver.cs b/Engine/Ioxide/Protocol/Multiplexed/Http2Driver.cs similarity index 97% rename from Engine/Ioxide/Protocol/Http2Driver.cs rename to Engine/Ioxide/Protocol/Multiplexed/Http2Driver.cs index d02463859..5b4ea4e5f 100644 --- a/Engine/Ioxide/Protocol/Http2Driver.cs +++ b/Engine/Ioxide/Protocol/Multiplexed/Http2Driver.cs @@ -8,9 +8,7 @@ using Microsoft.Extensions.Logging; -using GenHTTP.Engine.Ioxide.Protocol.Multiplexed; - -namespace GenHTTP.Engine.Ioxide.Protocol; +namespace GenHTTP.Engine.Ioxide.Protocol.Multiplexed; /// /// Serves an HTTP/2 connection: ioxide.http2 owns framing, HPACK and flow control, this maps each diff --git a/Engine/Ioxide/Protocol/Http3Driver.cs b/Engine/Ioxide/Protocol/Multiplexed/Http3Driver.cs similarity index 97% rename from Engine/Ioxide/Protocol/Http3Driver.cs rename to Engine/Ioxide/Protocol/Multiplexed/Http3Driver.cs index 8bd63cefc..ac13a9ad5 100644 --- a/Engine/Ioxide/Protocol/Http3Driver.cs +++ b/Engine/Ioxide/Protocol/Multiplexed/Http3Driver.cs @@ -6,9 +6,7 @@ using Microsoft.Extensions.Logging; -using GenHTTP.Engine.Ioxide.Protocol.Multiplexed; - -namespace GenHTTP.Engine.Ioxide.Protocol; +namespace GenHTTP.Engine.Ioxide.Protocol.Multiplexed; /// /// Serves an HTTP/3 connection: ngtcp2 carries QUIC, nghttp3 carries HTTP/3 and QPACK, this maps diff --git a/Testing/Acceptance/Engine/Ioxide/PipeWriterStreamTests.cs b/Testing/Acceptance/Engine/Ioxide/PipeWriterStreamTests.cs index 222279c06..fd865d8e0 100644 --- a/Testing/Acceptance/Engine/Ioxide/PipeWriterStreamTests.cs +++ b/Testing/Acceptance/Engine/Ioxide/PipeWriterStreamTests.cs @@ -4,7 +4,7 @@ using System.IO.Pipelines; using System.Text; -using GenHTTP.Engine.Ioxide.Protocol; +using GenHTTP.Engine.Ioxide.Protocol.Http1; namespace GenHTTP.Testing.Acceptance.Engine.Ioxide; From 941a38094e284ca6f65b353a8472ff1c4d637d48 Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Sun, 16 Aug 2026 19:18:34 +0100 Subject: [PATCH 24/55] refactor(ioxide): move kernel TLS into IoxideOptions.Tcp kernelTx/kernelRx were loose booleans on Host.Create, next to the delegates, saying neither what they switch nor where they apply. They are now grouped like Http3 and MutualTls already were: options: new IoxideOptions { Tcp = new IoxideTcpOptions { TxKernelTls = true, RxKernelTls = true }, } Tcp is the honest group for them. kTLS offloads the record layer OpenSSL owns, which terminates HTTP/1.1 and HTTP/2 only - HTTP/3 carries TLS 1.3 inside ngtcp2 and can never use it. The old names said "kernel" without saying kernel WHAT, and sat where nothing marked that boundary. Host.Create drops both parameters; nothing outside the engine passed them. --- Engine/Ioxide/Hosting/IoxideServer.Quic.cs | 30 ++--- Engine/Ioxide/Hosting/IoxideServer.Tls.cs | 4 +- Engine/Ioxide/Hosting/IoxideServer.cs | 125 +++++++++++---------- Engine/Ioxide/Hosting/IoxideServerHost.cs | 8 +- Engine/Ioxide/IoxideOptions.cs | 23 ++++ Engine/Ioxide/Server.cs | 16 +-- 6 files changed, 115 insertions(+), 91 deletions(-) diff --git a/Engine/Ioxide/Hosting/IoxideServer.Quic.cs b/Engine/Ioxide/Hosting/IoxideServer.Quic.cs index 3ad119196..99f5f7070 100644 --- a/Engine/Ioxide/Hosting/IoxideServer.Quic.cs +++ b/Engine/Ioxide/Hosting/IoxideServer.Quic.cs @@ -12,7 +12,7 @@ namespace GenHTTP.Engine.Ioxide.Hosting; /// public sealed partial class IoxideServer { - private QuicEngine? _quic; + private QuicEngine? _quicEngine; private IoxideEndPoint? _quicEndPoint; @@ -21,32 +21,32 @@ public sealed partial class IoxideServer /// carries TLS 1.3 and has no cleartext mode - and takes its port, which is what a browser /// assumes when an Alt-Svc advertisement names none of its own. /// - private ServerConfig WithQuic(ServerConfig cfg, IoxideEndPoint endPoint) + private ServerConfig WithQuic(ServerConfig serverConfig, IoxideEndPoint quicEndPoint) { - if (!_secure.TryGetValue(endPoint.Port, out var security)) + if (!_secure.TryGetValue(quicEndPoint.Port, out var security)) { - _logger.LogWarning("HTTP/3 was requested on port {Port}, which is not bound with a certificate; QUIC has no cleartext mode, so no listener was started.", endPoint.Port); - return cfg; + _logger.LogWarning("HTTP/3 was requested on port {Port}, which is not bound with a certificate; QUIC has no cleartext mode, so no listener was started.", quicEndPoint.Port); + return serverConfig; } - if (!TryResolveQuicCertificate(security, endPoint.Port, out var certPath, out var keyPath)) + if (!TryResolveQuicCertificate(security, quicEndPoint.Port, out var certPath, out var keyPath)) { - return cfg; + return serverConfig; } - _quic = new QuicEngine(certPath, keyPath, alpn: ["h3"], + _quicEngine = new QuicEngine(certPath, keyPath, alpn: ["h3"], clientCaPemPath: _options.MutualTls.ClientCaPath, requireClientCertificate: RequiresClientCertificate(security)); - _quicEndPoint = endPoint; + _quicEndPoint = quicEndPoint; - return cfg with + return serverConfig with { - Udp = cfg.Udp ?? new UdpOptions(), + Udp = serverConfig.Udp ?? new UdpOptions(), Quic = new QuicOptions { - Port = endPoint.Port, - ConnectionFactory = _quic.CreateFactory(), + Port = quicEndPoint.Port, + ConnectionFactory = _quicEngine.CreateFactory(), }, }; } @@ -126,7 +126,7 @@ private void WarnIfNotTheBoundCertificate(string configuredCert, Shared.Infrastr /// private void DisposeQuic() { - _quic?.Dispose(); - _quic = null; + _quicEngine?.Dispose(); + _quicEngine = null; } } diff --git a/Engine/Ioxide/Hosting/IoxideServer.Tls.cs b/Engine/Ioxide/Hosting/IoxideServer.Tls.cs index 02cbd17f4..94235772e 100644 --- a/Engine/Ioxide/Hosting/IoxideServer.Tls.cs +++ b/Engine/Ioxide/Hosting/IoxideServer.Tls.cs @@ -41,8 +41,8 @@ private IEnumerable> ResolveTls() ClientCaPem = _options.MutualTls.ClientCaPem, RequireClientCertificate = RequiresClientCertificate(security), - KernelTx = _kernelTx, - KernelRx = _kernelRx + KernelTx = _options.Tcp.TxKernelTls, + KernelRx = _options.Tcp.RxKernelTls }); } } diff --git a/Engine/Ioxide/Hosting/IoxideServer.cs b/Engine/Ioxide/Hosting/IoxideServer.cs index ac30cc4d2..22c0ad85e 100644 --- a/Engine/Ioxide/Hosting/IoxideServer.cs +++ b/Engine/Ioxide/Hosting/IoxideServer.cs @@ -44,10 +44,6 @@ public sealed partial class IoxideServer : IServer private readonly Func>? _connectionFactory; - private readonly bool _kernelTx; - - private readonly bool _kernelRx; - private readonly IoxideOptions _options; private readonly Nghttp3Options _h3Options; @@ -72,17 +68,18 @@ public sealed partial class IoxideServer : IServer public IHandler Handler { get; } - internal IoxideServer(ServerConfiguration config, IHandler handler, Func? configure = null, - Action? onReactorStart = null, Func>? connectionFactory = null, - bool kernelTx = false, bool kernelRx = false, IoxideOptions? options = null) + internal IoxideServer( + ServerConfiguration config, + IHandler handler, Func? configure = null, + Action? onReactorStart = null, + Func>? connectionFactory = null, + IoxideOptions? options = null) { _config = config; Handler = handler; _configure = configure; _onReactorStart = onReactorStart; _connectionFactory = connectionFactory; - _kernelTx = kernelTx; - _kernelRx = kernelRx; _options = options ?? IoxideOptions.Default; _h3Options = new Nghttp3Options @@ -110,16 +107,16 @@ internal IoxideServer(ServerConfiguration config, IHandler handler, Func _protocols[e.Port].HasFlag(IoxideProtocols.Http3)).ToList(); + var quicEndpoints = mapped.Where(e => _protocols[e.Port].HasFlag(IoxideProtocols.Http3)).ToList(); - if (quic.Count > 1) + if (quicEndpoints.Count > 1) { throw new NotSupportedException( - $"The ioxide engine binds one QUIC listener, but HTTP/3 was requested on ports {string.Join(", ", quic.Select(e => e.Port))}. " + $"The ioxide engine binds one QUIC listener, but HTTP/3 was requested on ports {string.Join(", ", quicEndpoints.Select(e => e.Port))}. " + "Name the protocols per port (ProtocolsByPort) so only one of them serves HTTP/3."); } - _quicRequested = quic.Count == 1 ? quic[0] : null; + _quicRequested = quicEndpoints.Count == 1 ? quicEndpoints[0] : null; // Certificates are resolved per reactor in OnStart, not here - see ResolveTls. _secure = config.EndPoints @@ -129,51 +126,16 @@ internal IoxideServer(ServerConfiguration config, IHandler handler, Func().ToList()); } - /// - /// What one port serves: the default, its override, and the endpoint's own enableQuic flag. - /// - private static IoxideProtocols ResolveProtocols(IoxideOptions options, ServerConfiguration config, ushort port) - { - var named = options.ProtocolsByPort.TryGetValue(port, out var configured); - - var protocols = named ? configured : options.Protocols; - - // HTTP/3 from the DEFAULT applies only where it can, so Protocols = All means "everything - // each port supports" rather than an error about the plaintext one. Named per port it is - // taken literally, and refused where the port has no certificate. - if (!named && protocols.HasFlag(IoxideProtocols.Http3) && config.EndPoints.All(e => e.Port != port || e.Security is null)) - { - protocols &= ~IoxideProtocols.Http3; - } - - if (config.EndPoints.Any(e => e.Port == port && e.EnableQuic)) - { - protocols |= IoxideProtocols.Http3; - } - - if (protocols == 0) - { - throw new NotSupportedException($"Port {port} was given no protocols to serve."); - } - - return protocols; - } - - /// The protocols this port serves. - private IoxideProtocols ProtocolsFor(ushort port) - => _protocols.TryGetValue(port, out var protocols) ? protocols : IoxideProtocols.Http1; - public async ValueTask StartAsync() { await PrepareHandlerAsync(); Running = true; - - var cfg = new ServerConfig { ReactorCount = Environment.ProcessorCount }; - + + var serverConfig = new ServerConfig { ReactorCount = Environment.ProcessorCount }; if (_configure is not null) { - cfg = _configure(cfg); + serverConfig = _configure(serverConfig); } // Endpoint bindings always win over the configuration hook. Only ports serving something @@ -183,10 +145,10 @@ public async ValueTask StartAsync() .OrderBy(p => p == _primary.Port ? 0 : 1) .ToArray(); - cfg = cfg with + serverConfig = serverConfig with { DualStack = _primary.DualStack, - Tcp = tcpPorts.Length == 0 ? null : (cfg.Tcp ?? new TcpOptions()) with + Tcp = tcpPorts.Length == 0 ? null : (serverConfig.Tcp ?? new TcpOptions()) with { Port = tcpPorts[0], ExtraPorts = tcpPorts.Skip(1).ToArray() @@ -195,20 +157,20 @@ public async ValueTask StartAsync() if (_quicRequested is { } quicEndPoint) { - cfg = WithQuic(cfg, quicEndPoint); + serverConfig = WithQuic(serverConfig, quicEndPoint); } - _threads = new Thread[cfg.ReactorCount]; - _reactors = new Reactor[cfg.ReactorCount]; + _threads = new Thread[serverConfig.ReactorCount]; + _reactors = new Reactor[serverConfig.ReactorCount]; // Reactors bind their listeners on their own threads, so StartAsync must not return before // they accept - a client connecting immediately (as the test host does) would otherwise // race the bind and get "connection refused". - var listening = new CountdownEvent(cfg.ReactorCount); + var listening = new CountdownEvent(serverConfig.ReactorCount); for (var i = 0; i < _threads.Length; i++) { - var reactor = new Reactor(i, cfg) + var reactor = new Reactor(i, serverConfig) { OnStart = r => { @@ -229,8 +191,17 @@ public async ValueTask StartAsync() _onReactorStart?.Invoke(r); listening.Signal(); }, - TcpHandle = (_, c) => ConnectionDriver.HandleAsync(this, _endPointByPort[c.ListenerPort], c, _connectionFactory, ProtocolsFor(c.ListenerPort)), - QuicHandle = _quic is not null ? (_, c) => Http3Driver.RunAsync(this, _quicEndPoint!, c, _h3Options) : null + TcpHandle = (_, tcpConnection) => + ConnectionDriver.HandleAsync( + this, + _endPointByPort[tcpConnection.ListenerPort], + tcpConnection, + _connectionFactory, + ProtocolsFor(tcpConnection.ListenerPort)), + + QuicHandle = _quicEngine is not null + ? (_, quicConnection) => Http3Driver.RunAsync(this, _quicEndPoint!, quicConnection, _h3Options) + : null }; _reactors[i] = reactor; @@ -278,6 +249,40 @@ private async ValueTask PrepareHandlerAsync() _logger.LogCritical(e, "Failed to prepare the handler chain"); } } + + /// + /// What one port serves: the default, its override, and the endpoint's own enableQuic flag. + /// + private static IoxideProtocols ResolveProtocols(IoxideOptions options, ServerConfiguration config, ushort port) + { + var named = options.ProtocolsByPort.TryGetValue(port, out var configured); + + var protocols = named ? configured : options.Protocols; + + // HTTP/3 from the DEFAULT applies only where it can, so Protocols = All means "everything + // each port supports" rather than an error about the plaintext one. Named per port it is + // taken literally, and refused where the port has no certificate. + if (!named && protocols.HasFlag(IoxideProtocols.Http3) && config.EndPoints.All(e => e.Port != port || e.Security is null)) + { + protocols &= ~IoxideProtocols.Http3; + } + + if (config.EndPoints.Any(e => e.Port == port && e.EnableQuic)) + { + protocols |= IoxideProtocols.Http3; + } + + if (protocols == 0) + { + throw new NotSupportedException($"Port {port} was given no protocols to serve."); + } + + return protocols; + } + + /// The protocols this port serves. + private IoxideProtocols ProtocolsFor(ushort port) + => _protocols.TryGetValue(port, out var protocols) ? protocols : IoxideProtocols.Http1; private string DescribeSettings() { diff --git a/Engine/Ioxide/Hosting/IoxideServerHost.cs b/Engine/Ioxide/Hosting/IoxideServerHost.cs index 5d1c4a99f..9fd38eece 100644 --- a/Engine/Ioxide/Hosting/IoxideServerHost.cs +++ b/Engine/Ioxide/Hosting/IoxideServerHost.cs @@ -9,10 +9,14 @@ namespace GenHTTP.Engine.Ioxide.Hosting; -public sealed class IoxideServerHost(Func? configure = null, Action? onReactorStart = null, Func>? connectionFactory = null, bool kernelTx = false, bool kernelRx = false, IoxideOptions? options = null) : ServerHost +public sealed class IoxideServerHost( + Func? configure = null, + Action? onReactorStart = null, + Func>? connectionFactory = null, + IoxideOptions? options = null) : ServerHost { protected override IServer Build(ServerConfiguration config, IHandler handler) - => new IoxideServer(config, handler, configure, onReactorStart, connectionFactory, kernelTx, kernelRx, options); + => new IoxideServer(config, handler, configure, onReactorStart, connectionFactory, options); } diff --git a/Engine/Ioxide/IoxideOptions.cs b/Engine/Ioxide/IoxideOptions.cs index 15dabaa30..683aaedae 100644 --- a/Engine/Ioxide/IoxideOptions.cs +++ b/Engine/Ioxide/IoxideOptions.cs @@ -20,6 +20,9 @@ public sealed record IoxideOptions /// public Dictionary ProtocolsByPort { get; init; } = []; + /// The TCP endpoints: how TLS is terminated for HTTP/1.1 and HTTP/2. + public IoxideTcpOptions Tcp { get; init; } = new(); + /// The HTTP/3 endpoint: the certificate QUIC serves, and QPACK. public IoxideHttp3Options Http3 { get; init; } = new(); @@ -27,6 +30,26 @@ public sealed record IoxideOptions public IoxideMutualTlsOptions MutualTls { get; init; } = new(); } +/// +/// The TCP endpoints, where OpenSSL terminates TLS for HTTP/1.1 and HTTP/2. HTTP/3 is not +/// configured here: QUIC carries its own TLS 1.3 inside ngtcp2, so none of this reaches it. +/// +public sealed record IoxideTcpOptions +{ + /// + /// Produce TLS records in the kernel (kTLS) on the send path instead of in OpenSSL, which + /// still drives the handshake. Requires the Linux tls module and TLS 1.3. + /// + public bool TxKernelTls { get; init; } + + /// + /// Decrypt TLS records in the kernel on the receive path. Experimental, and requires + /// - RX shares the ULP handoff TX installs, so ioxide refuses RX + /// alone. The peer must send no post-handshake control records. + /// + public bool RxKernelTls { get; init; } +} + /// /// The HTTP/3 endpoint. Only consulted when a port serves . /// diff --git a/Engine/Ioxide/Server.cs b/Engine/Ioxide/Server.cs index 0438d2d38..f49f56f12 100644 --- a/Engine/Ioxide/Server.cs +++ b/Engine/Ioxide/Server.cs @@ -25,19 +25,11 @@ public static class Host /// Replaces the built-in transport selection. A returned pipe implementing /// is disposed when the connection ends. /// - /// - /// Encrypt TLS records in the kernel (kTLS TX) rather than OpenSSL, which still handshakes. - /// Needs the Linux tls module and TLS 1.3. - /// - /// - /// Decrypt in the kernel too. Experimental: needs (RX shares the - /// ULP handoff TX installs) and a peer sending no post-handshake control records. - /// /// - /// Protocols, the HTTP/3 certificate, mutual TLS and QPACK. Per-endpoint settings stay on - /// Bind. + /// Protocols, kernel TLS, the HTTP/3 certificate, mutual TLS and QPACK. Per-endpoint + /// settings stay on Bind. /// - public static IServerHost Create(Func? configure = null, Action? onReactorStart = null, Func>? connectionFactory = null, bool kernelTx = false, bool kernelRx = false, IoxideOptions? options = null) - => new IoxideServerHost(configure, onReactorStart, connectionFactory, kernelTx, kernelRx, options); + public static IServerHost Create(Func? configure = null, Action? onReactorStart = null, Func>? connectionFactory = null, IoxideOptions? options = null) + => new IoxideServerHost(configure, onReactorStart, connectionFactory, options); } From 7630fe4210ae88098931ac334559b677beafd4fa Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Sun, 16 Aug 2026 20:08:37 +0100 Subject: [PATCH 25/55] docs(playground): show the new IoxideOptions.Tcp group The kernel TLS knobs moved into options and the sample had no example of the group. Shipped off: the tls ULP is absent on most machines, and a sample that needs modprobe to serve anything is not a sample. --- Playground/Program.cs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Playground/Program.cs b/Playground/Program.cs index 2f233bf9a..cad3ebbe1 100644 --- a/Playground/Program.cs +++ b/Playground/Program.cs @@ -108,6 +108,17 @@ await Host.Create( [8444] = IoxideProtocols.Http1, }, + Tcp = new IoxideTcpOptions + { + // Move TLS record encryption from OpenSSL into the kernel on the ports below + // that carry HTTP/1.1 and HTTP/2. Off here because it needs the Linux tls + // module (`modprobe tls`) and TLS 1.3, and a machine without it serves + // nothing. RxKernelTls decrypts there too, and requires this one. + // + // HTTP/3 is unaffected either way - QUIC carries its own TLS inside ngtcp2. + TxKernelTls = false, + }, + MutualTls = new IoxideMutualTlsOptions { // What an offered client certificate is validated against. WHICH ports ask From 6967e4cb0e625db33b421ff1b88d2687f959b62f Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Sun, 16 Aug 2026 20:10:38 +0100 Subject: [PATCH 26/55] docs(playground): show both kernel TLS knobs, with what enabling them needs Measured on a box without the tls ULP: both true leaves 8443 and 8444 answering nothing at all, with no log line - the handshake fails per connection and the driver swallows it as a failed handshake. 8080 and 8443's HTTP/3 keep serving, since neither goes through the OpenSSL record layer. So both ship off, and the comment says how to check for the module first. --- Playground/Program.cs | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/Playground/Program.cs b/Playground/Program.cs index cad3ebbe1..f38f792c0 100644 --- a/Playground/Program.cs +++ b/Playground/Program.cs @@ -110,13 +110,19 @@ await Host.Create( Tcp = new IoxideTcpOptions { - // Move TLS record encryption from OpenSSL into the kernel on the ports below - // that carry HTTP/1.1 and HTTP/2. Off here because it needs the Linux tls - // module (`modprobe tls`) and TLS 1.3, and a machine without it serves - // nothing. RxKernelTls decrypts there too, and requires this one. + // Hand the TLS record layer to the kernel instead of OpenSSL, which still + // performs the handshake: TX encrypts on the way out, RX decrypts on the way + // in and requires TX. Only 8443 and 8444 are affected - 8080-8082 carry no + // TLS, and 8443's HTTP/3 keeps its own TLS 1.3 inside ngtcp2. // - // HTTP/3 is unaffected either way - QUIC carries its own TLS inside ngtcp2. + // Both off here because kTLS needs the Linux tls module and TLS 1.3, and + // where the module is missing the handshake fails per connection with + // nothing logged: the TLS ports simply stop answering. Check first with + // + // cat /proc/sys/net/ipv4/tcp_available_ulp # wanted: tls + // sudo modprobe tls TxKernelTls = false, + RxKernelTls = false, }, MutualTls = new IoxideMutualTlsOptions From a4b1a8e2cf35a3533c22201a0347c1e5dfbbe8e1 Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Sun, 16 Aug 2026 20:22:32 +0100 Subject: [PATCH 27/55] feat(ioxide): configure the reactors through IoxideOptions Tuning the runtime meant reaching for a delegate over ioxide's own record: configure: c => c with { ReactorCount = 2 } which asks the caller to know a type from another package to set one number, and is undiscoverable next to the typed groups the rest of the options use. Reactor = new IoxideReactorOptions { ReactorCount = 2 } ReactorCount, RingEntries, RecvBufferSize, RecvSlots and Incremental are all nullable and pass through untouched when unset, so ioxide keeps owning its own defaults - restating them here would pin a stale copy the day ioxide retunes one. The exception is ReactorCount, which the engine has always overridden: ioxide ships a fixed 12, and one per core is the better guess. configure stays as the escape hatch for what the group does not model, and now runs after it so it still has the last word. --- Engine/Ioxide/Hosting/IoxideServer.cs | 29 ++++++++++++++++- Engine/Ioxide/IoxideOptions.cs | 47 +++++++++++++++++++++++++++ Playground/Program.cs | 27 +++++++-------- 3 files changed, 86 insertions(+), 17 deletions(-) diff --git a/Engine/Ioxide/Hosting/IoxideServer.cs b/Engine/Ioxide/Hosting/IoxideServer.cs index 22c0ad85e..0c4cc7e95 100644 --- a/Engine/Ioxide/Hosting/IoxideServer.cs +++ b/Engine/Ioxide/Hosting/IoxideServer.cs @@ -126,13 +126,40 @@ internal IoxideServer( EndPoints = new IoxideEndPoints(mapped.Cast().ToList()); } + /// + /// ioxide's own defaults with the reactor options applied over them. Only what was set is + /// touched, so an unset knob keeps whatever ioxide currently defaults it to. + /// + private ServerConfig BuildServerConfig() + { + var reactor = _options.Reactor; + + var serverConfig = new ServerConfig(); + + return serverConfig with + { + // The one default the engine overrides: ioxide ships a fixed 12, which is either + // wasteful or a bottleneck depending on the machine it lands on. + ReactorCount = reactor.ReactorCount ?? Environment.ProcessorCount, + + RingEntries = reactor.RingEntries ?? serverConfig.RingEntries, + RecvBufferSize = reactor.RecvBufferSize ?? serverConfig.RecvBufferSize, + RecvSlots = reactor.RecvSlots ?? serverConfig.RecvSlots, + + // Null is meaningful here - it selects the shared ring - so it passes straight through. + Incremental = reactor.Incremental, + }; + } + public async ValueTask StartAsync() { await PrepareHandlerAsync(); Running = true; - var serverConfig = new ServerConfig { ReactorCount = Environment.ProcessorCount }; + var serverConfig = BuildServerConfig(); + + // The escape hatch runs last, so it can reach anything IoxideOptions does not model. if (_configure is not null) { serverConfig = _configure(serverConfig); diff --git a/Engine/Ioxide/IoxideOptions.cs b/Engine/Ioxide/IoxideOptions.cs index 683aaedae..3b01c9d5f 100644 --- a/Engine/Ioxide/IoxideOptions.cs +++ b/Engine/Ioxide/IoxideOptions.cs @@ -1,3 +1,5 @@ +using ioxide; + namespace GenHTTP.Engine.Ioxide; /// @@ -20,6 +22,9 @@ public sealed record IoxideOptions /// public Dictionary ProtocolsByPort { get; init; } = []; + /// The reactors: how many, and the io_uring machinery each one owns. + public IoxideReactorOptions Reactor { get; init; } = new(); + /// The TCP endpoints: how TLS is terminated for HTTP/1.1 and HTTP/2. public IoxideTcpOptions Tcp { get; init; } = new(); @@ -30,6 +35,48 @@ public sealed record IoxideOptions public IoxideMutualTlsOptions MutualTls { get; init; } = new(); } +/// +/// The reactors. Each runs on its own thread, owns an io_uring ring and the connections accepted +/// on it, and shares nothing with the others - so these are per reactor, not per server, and the +/// memory they describe is multiplied by . +/// +/// +/// Every value is optional and left at ioxide's own default when unset, rather than restated here +/// where it would drift the first time ioxide retunes one. +/// +public sealed record IoxideReactorOptions +{ + /// + /// How many reactors to run. Unset means one per core, which is what a server with the machine + /// to itself wants; anything sharing the box - a colocated load generator, a database, sibling + /// containers - wants fewer, or the reactors and everything else fight for the same cores. + /// + public int? ReactorCount { get; init; } + + /// io_uring submission and completion queue depth, per reactor. + public uint? RingEntries { get; init; } + + /// + /// Bytes per buffer in the shared recv ring. Larger reads more per completion and wastes more + /// per idle connection. Unused when is set. + /// + public int? RecvBufferSize { get; init; } + + /// + /// Buffers in the shared recv ring. Running out costs a retry, not a lost byte. Unused when + /// is set. + /// + public int? RecvSlots { get; init; } + + /// + /// Give each connection its own small buffer ring (IOU_PBUF_RING_INC, kernel 6.12+) instead of + /// drawing from the shared one. Setting this IS enabling the mode, and the two shared-ring + /// knobs above then go unused. Reserves MaxConnections x RecvSlots x RecvBufferSize per + /// reactor up front, so it trades memory for not sharing a ring between connections. + /// + public IncrementalOptions? Incremental { get; init; } +} + /// /// The TCP endpoints, where OpenSSL terminates TLS for HTTP/1.1 and HTTP/2. HTTP/3 is not /// configured here: QUIC carries its own TLS 1.3 inside ngtcp2, so none of this reaches it. diff --git a/Playground/Program.cs b/Playground/Program.cs index f38f792c0..4032d0673 100644 --- a/Playground/Program.cs +++ b/Playground/Program.cs @@ -65,10 +65,6 @@ // wrk -t8 -c64 -d8s http://127.0.0.1:8080/ring/asset.bin // wrk -t8 -c64 -d8s http://127.0.0.1:8080/disk/asset.bin -// One reactor per core is the default. A sample does not need the whole machine, and this is not -// where throughput is measured - bench/ is. -const int Reactors = 2; - var staticDir = Environment.GetEnvironmentVariable("GENHTTP_STATIC"); var app = Layout.Create() @@ -94,12 +90,22 @@ var clientCa = WriteClientCertificates(); await Host.Create( - configure: c => c with { ReactorCount = Reactors }, options: new IoxideOptions { // What a port serves unless named below. Protocols = IoxideProtocols.Http1, + Reactor = new IoxideReactorOptions + { + // One per core is the default. Two here because a sample does not need the + // whole machine, and because a load generator run on this same box would + // otherwise be fighting the reactors for every core. + // + // RingEntries, RecvBufferSize, RecvSlots and Incremental are the rest of the + // per-reactor machinery; left unset they keep ioxide's own defaults. + ReactorCount = 2, + }, + ProtocolsByPort = { [8081] = IoxideProtocols.Http2, @@ -110,17 +116,6 @@ await Host.Create( Tcp = new IoxideTcpOptions { - // Hand the TLS record layer to the kernel instead of OpenSSL, which still - // performs the handshake: TX encrypts on the way out, RX decrypts on the way - // in and requires TX. Only 8443 and 8444 are affected - 8080-8082 carry no - // TLS, and 8443's HTTP/3 keeps its own TLS 1.3 inside ngtcp2. - // - // Both off here because kTLS needs the Linux tls module and TLS 1.3, and - // where the module is missing the handshake fails per connection with - // nothing logged: the TLS ports simply stop answering. Check first with - // - // cat /proc/sys/net/ipv4/tcp_available_ulp # wanted: tls - // sudo modprobe tls TxKernelTls = false, RxKernelTls = false, }, From e0cf7d58e83205dfa5ad00bc3727fb811e7f607b Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Sun, 16 Aug 2026 20:35:45 +0100 Subject: [PATCH 28/55] refactor(ioxide): drop the configure hook, options carry their own defaults Host.Create no longer takes Func. Tuning the engine meant reaching for a record from another package through a delegate, which is neither discoverable nor typed to what the engine actually honours - it let you set Port and DualStack too, which the endpoint bindings then overwrote. Everything the hook could usefully reach is now on IoxideOptions. The six TcpOptions knobs it alone could touch move to IoxideTcpOptions alongside the kernel TLS pair: ListenBacklog, WriteSlabSize, WriteOverflow, PoolMax, ZeroCopySend, RecvQueueEntries. UdpOptions is not exposed - the engine wires no raw datagram handler, and QUIC binds its own port. Both groups carry real default values rather than nulls meaning "ask ioxide", so BuildServerConfig is a straight assignment with no probe instance and no coalescing, and the defaults are visible where they are read. --- Engine/Ioxide/Hosting/IoxideServer.cs | 52 +++++++++-------------- Engine/Ioxide/Hosting/IoxideServerHost.cs | 3 +- Engine/Ioxide/IoxideOptions.cs | 51 +++++++++++++++++----- Engine/Ioxide/Server.cs | 12 ++---- Playground/Program.cs | 23 +++++++--- 5 files changed, 83 insertions(+), 58 deletions(-) diff --git a/Engine/Ioxide/Hosting/IoxideServer.cs b/Engine/Ioxide/Hosting/IoxideServer.cs index 0c4cc7e95..9cc5365c6 100644 --- a/Engine/Ioxide/Hosting/IoxideServer.cs +++ b/Engine/Ioxide/Hosting/IoxideServer.cs @@ -38,8 +38,6 @@ public sealed partial class IoxideServer : IServer private readonly Dictionary _protocols; - private readonly Func? _configure; - private readonly Action? _onReactorStart; private readonly Func>? _connectionFactory; @@ -70,14 +68,13 @@ public sealed partial class IoxideServer : IServer internal IoxideServer( ServerConfiguration config, - IHandler handler, Func? configure = null, + IHandler handler, Action? onReactorStart = null, Func>? connectionFactory = null, IoxideOptions? options = null) { _config = config; Handler = handler; - _configure = configure; _onReactorStart = onReactorStart; _connectionFactory = connectionFactory; _options = options ?? IoxideOptions.Default; @@ -127,29 +124,28 @@ internal IoxideServer( } /// - /// ioxide's own defaults with the reactor options applied over them. Only what was set is - /// touched, so an unset knob keeps whatever ioxide currently defaults it to. + /// The engine's configuration, straight from the options. Ports are not set here: StartAsync + /// takes those from the endpoint bindings, which is also where an all-HTTP/3 server drops the + /// TCP listener entirely. /// - private ServerConfig BuildServerConfig() + private ServerConfig BuildServerConfig() => new() { - var reactor = _options.Reactor; - - var serverConfig = new ServerConfig(); + ReactorCount = _options.Reactor.ReactorCount, + RingEntries = _options.Reactor.RingEntries, + RecvBufferSize = _options.Reactor.RecvBufferSize, + RecvSlots = _options.Reactor.RecvSlots, + Incremental = _options.Reactor.Incremental, - return serverConfig with + Tcp = new TcpOptions { - // The one default the engine overrides: ioxide ships a fixed 12, which is either - // wasteful or a bottleneck depending on the machine it lands on. - ReactorCount = reactor.ReactorCount ?? Environment.ProcessorCount, - - RingEntries = reactor.RingEntries ?? serverConfig.RingEntries, - RecvBufferSize = reactor.RecvBufferSize ?? serverConfig.RecvBufferSize, - RecvSlots = reactor.RecvSlots ?? serverConfig.RecvSlots, - - // Null is meaningful here - it selects the shared ring - so it passes straight through. - Incremental = reactor.Incremental, - }; - } + ListenBacklog = _options.Tcp.ListenBacklog, + WriteSlabSize = _options.Tcp.WriteSlabSize, + WriteOverflow = _options.Tcp.WriteOverflow, + PoolMax = _options.Tcp.PoolMax, + ZeroCopySend = _options.Tcp.ZeroCopySend, + RecvQueueEntries = _options.Tcp.RecvQueueEntries, + }, + }; public async ValueTask StartAsync() { @@ -159,14 +155,8 @@ public async ValueTask StartAsync() var serverConfig = BuildServerConfig(); - // The escape hatch runs last, so it can reach anything IoxideOptions does not model. - if (_configure is not null) - { - serverConfig = _configure(serverConfig); - } - - // Endpoint bindings always win over the configuration hook. Only ports serving something - // over TCP are bound, so an HTTP/3-only endpoint gets a UDP socket and nothing else. + // Only ports serving something over TCP are bound, so an HTTP/3-only endpoint gets a UDP + // socket and nothing else. var tcpPorts = _protocols.Where(p => (p.Value & IoxideProtocols.Http1AndHttp2) != 0) .Select(p => p.Key) .OrderBy(p => p == _primary.Port ? 0 : 1) diff --git a/Engine/Ioxide/Hosting/IoxideServerHost.cs b/Engine/Ioxide/Hosting/IoxideServerHost.cs index 9fd38eece..bd4f39044 100644 --- a/Engine/Ioxide/Hosting/IoxideServerHost.cs +++ b/Engine/Ioxide/Hosting/IoxideServerHost.cs @@ -10,13 +10,12 @@ namespace GenHTTP.Engine.Ioxide.Hosting; public sealed class IoxideServerHost( - Func? configure = null, Action? onReactorStart = null, Func>? connectionFactory = null, IoxideOptions? options = null) : ServerHost { protected override IServer Build(ServerConfiguration config, IHandler handler) - => new IoxideServer(config, handler, configure, onReactorStart, connectionFactory, options); + => new IoxideServer(config, handler, onReactorStart, connectionFactory, options); } diff --git a/Engine/Ioxide/IoxideOptions.cs b/Engine/Ioxide/IoxideOptions.cs index 3b01c9d5f..bac00de02 100644 --- a/Engine/Ioxide/IoxideOptions.cs +++ b/Engine/Ioxide/IoxideOptions.cs @@ -40,33 +40,29 @@ public sealed record IoxideOptions /// on it, and shares nothing with the others - so these are per reactor, not per server, and the /// memory they describe is multiplied by . /// -/// -/// Every value is optional and left at ioxide's own default when unset, rather than restated here -/// where it would drift the first time ioxide retunes one. -/// public sealed record IoxideReactorOptions { /// - /// How many reactors to run. Unset means one per core, which is what a server with the machine - /// to itself wants; anything sharing the box - a colocated load generator, a database, sibling - /// containers - wants fewer, or the reactors and everything else fight for the same cores. + /// How many reactors to run. One per core suits a server with the machine to itself; anything + /// sharing the box - a colocated load generator, a database, sibling containers - wants fewer, + /// or the reactors and everything else fight for the same cores. /// - public int? ReactorCount { get; init; } + public int ReactorCount { get; init; } = Environment.ProcessorCount; /// io_uring submission and completion queue depth, per reactor. - public uint? RingEntries { get; init; } + public uint RingEntries { get; init; } = 8192; /// /// Bytes per buffer in the shared recv ring. Larger reads more per completion and wastes more /// per idle connection. Unused when is set. /// - public int? RecvBufferSize { get; init; } + public int RecvBufferSize { get; init; } = 32 * 1024; /// /// Buffers in the shared recv ring. Running out costs a retry, not a lost byte. Unused when /// is set. /// - public int? RecvSlots { get; init; } + public int RecvSlots { get; init; } = 4096; /// /// Give each connection its own small buffer ring (IOU_PBUF_RING_INC, kernel 6.12+) instead of @@ -95,6 +91,39 @@ public sealed record IoxideTcpOptions /// alone. The peer must send no post-handshake control records. /// public bool RxKernelTls { get; init; } + + /// + /// listen() backlog per reactor - the accept queue that absorbs a burst of connections. Every + /// reactor binds its own SO_REUSEPORT listener, so the server absorbs this many per reactor. + /// + public int ListenBacklog { get; init; } = 1024; + + /// + /// Bytes of write buffer per connection. A response that fits leaves in one send; a larger one + /// is handled by . + /// + public int WriteSlabSize { get; init; } = 16 * 1024; + + /// + /// What a response larger than does: grow the slab and keep one + /// send, or chain pooled slabs and flush them with one vectored sendmsg instead of reallocating. + /// + public WriteOverflowStrategy WriteOverflow { get; init; } = WriteOverflowStrategy.Grow; + + /// Connections kept pooled per reactor for reuse rather than freed. + public int PoolMax { get; init; } = 1024; + + /// + /// Send responses with zero-copy (IORING_OP_SEND_ZC) instead of a normal send. Trades the + /// in-kernel payload copy for page pinning and a second completion per send, so it only pays + /// for large responses. kTLS connections always fall back to a plain send. + /// + public bool ZeroCopySend { get; init; } + + /// + /// Depth of the per-connection recv queue, a power of two. Overflow closes the connection. + /// + public int RecvQueueEntries { get; init; } = 64; } /// diff --git a/Engine/Ioxide/Server.cs b/Engine/Ioxide/Server.cs index f49f56f12..d9b594e11 100644 --- a/Engine/Ioxide/Server.cs +++ b/Engine/Ioxide/Server.cs @@ -13,10 +13,6 @@ namespace GenHTTP.Engine.Ioxide; public static class Host { - /// - /// Tunes the ioxide runtime, e.g. c => c with { ReactorCount = 16 }. Listen ports always - /// come from the GenHTTP endpoint bindings. - /// /// /// Registers ring-native services on each reactor's own thread before it serves, e.g. /// r => PgPool.Start(r, pgOptions). Handlers resolve them via IoxideReactor.Current. @@ -26,10 +22,10 @@ public static class Host /// is disposed when the connection ends. /// /// - /// Protocols, kernel TLS, the HTTP/3 certificate, mutual TLS and QPACK. Per-endpoint - /// settings stay on Bind. + /// Everything the engine is tuned by: the reactors, the TCP transport, protocols per port, + /// the HTTP/3 certificate, mutual TLS and QPACK. Ports and certificates stay on Bind. /// - public static IServerHost Create(Func? configure = null, Action? onReactorStart = null, Func>? connectionFactory = null, IoxideOptions? options = null) - => new IoxideServerHost(configure, onReactorStart, connectionFactory, options); + public static IServerHost Create(Action? onReactorStart = null, Func>? connectionFactory = null, IoxideOptions? options = null) + => new IoxideServerHost(onReactorStart, connectionFactory, options); } diff --git a/Playground/Program.cs b/Playground/Program.cs index 4032d0673..e78151c15 100644 --- a/Playground/Program.cs +++ b/Playground/Program.cs @@ -7,6 +7,8 @@ using GenHTTP.Engine.Ioxide; +using ioxide; + using GenHTTP.Modules.Files; using GenHTTP.Modules.IO; using GenHTTP.Modules.Layouting; @@ -97,12 +99,6 @@ await Host.Create( Reactor = new IoxideReactorOptions { - // One per core is the default. Two here because a sample does not need the - // whole machine, and because a load generator run on this same box would - // otherwise be fighting the reactors for every core. - // - // RingEntries, RecvBufferSize, RecvSlots and Incremental are the rest of the - // per-reactor machinery; left unset they keep ioxide's own defaults. ReactorCount = 2, }, @@ -116,8 +112,23 @@ await Host.Create( Tcp = new IoxideTcpOptions { + // Hand the TLS record layer to the kernel instead of OpenSSL, which still + // performs the handshake. Both off because kTLS needs the Linux tls module + // and TLS 1.3, and without it the TLS ports fail every handshake silently: + // + // cat /proc/sys/net/ipv4/tcp_available_ulp # wanted: tls TxKernelTls = false, RxKernelTls = false, + + // The rest of the TCP transport: accept backlog, the per-connection write + // slab and what happens when a response outgrows it, the connection pool, + // zero-copy send and recv queue depth. All shown at their defaults. + ListenBacklog = 1024, + WriteSlabSize = 16 * 1024, + WriteOverflow = WriteOverflowStrategy.Grow, + PoolMax = 1024, + ZeroCopySend = false, + RecvQueueEntries = 64, }, MutualTls = new IoxideMutualTlsOptions From e162d4a2de96e2d3d1d45da93922c0562a2ba126 Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Sun, 16 Aug 2026 20:44:37 +0100 Subject: [PATCH 29/55] refactor(ioxide): remove the connectionFactory seam An extension point nothing called, nothing tested and no sample showed. It let a host replace transport establishment wholesale - and with it the secure-port guard and the mutual TLS wiring - which is not something a caller reaches for by accident, or apparently at all. Removing it takes four hops with it: Host.Create -> IoxideServerHost -> IoxideServer field -> ConnectionDriver parameter, read in one place. What is left is the transport selection the engine actually performs, secure or plain. IoxideTls loses its public surface with it. StartService and AcceptAsync existed only to help write a factory, so the class is now internal and holds the one method that terminates TLS for the endpoints bound with a certificate. --- Engine/Ioxide/Hosting/IoxideServer.cs | 5 ----- Engine/Ioxide/Hosting/IoxideServerHost.cs | 5 +---- Engine/Ioxide/Protocol/ConnectionDriver.cs | 10 +++------ Engine/Ioxide/Server.cs | 10 ++------- Engine/Ioxide/Tls/IoxideTls.cs | 24 ++++++---------------- 5 files changed, 12 insertions(+), 42 deletions(-) diff --git a/Engine/Ioxide/Hosting/IoxideServer.cs b/Engine/Ioxide/Hosting/IoxideServer.cs index 9cc5365c6..085451cbb 100644 --- a/Engine/Ioxide/Hosting/IoxideServer.cs +++ b/Engine/Ioxide/Hosting/IoxideServer.cs @@ -40,8 +40,6 @@ public sealed partial class IoxideServer : IServer private readonly Action? _onReactorStart; - private readonly Func>? _connectionFactory; - private readonly IoxideOptions _options; private readonly Nghttp3Options _h3Options; @@ -70,13 +68,11 @@ internal IoxideServer( ServerConfiguration config, IHandler handler, Action? onReactorStart = null, - Func>? connectionFactory = null, IoxideOptions? options = null) { _config = config; Handler = handler; _onReactorStart = onReactorStart; - _connectionFactory = connectionFactory; _options = options ?? IoxideOptions.Default; _h3Options = new Nghttp3Options @@ -213,7 +209,6 @@ public async ValueTask StartAsync() this, _endPointByPort[tcpConnection.ListenerPort], tcpConnection, - _connectionFactory, ProtocolsFor(tcpConnection.ListenerPort)), QuicHandle = _quicEngine is not null diff --git a/Engine/Ioxide/Hosting/IoxideServerHost.cs b/Engine/Ioxide/Hosting/IoxideServerHost.cs index bd4f39044..fd0d2d14e 100644 --- a/Engine/Ioxide/Hosting/IoxideServerHost.cs +++ b/Engine/Ioxide/Hosting/IoxideServerHost.cs @@ -1,5 +1,3 @@ -using System.IO.Pipelines; - using GenHTTP.Api.Content; using GenHTTP.Api.Infrastructure; using GenHTTP.Engine.Shared.Hosting; @@ -11,11 +9,10 @@ namespace GenHTTP.Engine.Ioxide.Hosting; public sealed class IoxideServerHost( Action? onReactorStart = null, - Func>? connectionFactory = null, IoxideOptions? options = null) : ServerHost { protected override IServer Build(ServerConfiguration config, IHandler handler) - => new IoxideServer(config, handler, onReactorStart, connectionFactory, options); + => new IoxideServer(config, handler, onReactorStart, options); } diff --git a/Engine/Ioxide/Protocol/ConnectionDriver.cs b/Engine/Ioxide/Protocol/ConnectionDriver.cs index 9b8725140..3eec2789f 100644 --- a/Engine/Ioxide/Protocol/ConnectionDriver.cs +++ b/Engine/Ioxide/Protocol/ConnectionDriver.cs @@ -37,7 +37,7 @@ internal static partial class ConnectionDriver private static readonly ReadOnlyMemory Preface = "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"u8.ToArray(); internal static async Task HandleAsync(IServer server, IEndPoint endPoint, IoConnection conn, - Func>? connectionFactory, IoxideProtocols protocols = IoxideProtocols.Http1) + IoxideProtocols protocols = IoxideProtocols.Http1) { IDuplexPipe pipe; @@ -46,11 +46,7 @@ internal static async Task HandleAsync(IServer server, IEndPoint endPoint, IoCon try { - if (connectionFactory is not null) - { - pipe = await connectionFactory(conn); - } - else if (endPoint.Secure) + if (endPoint.Secure) { // A secure port with no certificate is advertised for redirects but cannot // handshake - FIN, so the client fails fast rather than a plaintext response @@ -71,7 +67,7 @@ internal static async Task HandleAsync(IServer server, IEndPoint endPoint, IoCon } catch { - // failed handshake (or factory fault) - release the connection instead of leaking it + // failed handshake - release the connection instead of leaking it conn.DecRef(); return; } diff --git a/Engine/Ioxide/Server.cs b/Engine/Ioxide/Server.cs index d9b594e11..f60efeea3 100644 --- a/Engine/Ioxide/Server.cs +++ b/Engine/Ioxide/Server.cs @@ -1,5 +1,3 @@ -using System.IO.Pipelines; - using GenHTTP.Api.Infrastructure; using GenHTTP.Engine.Ioxide.Hosting; @@ -17,15 +15,11 @@ public static class Host /// Registers ring-native services on each reactor's own thread before it serves, e.g. /// r => PgPool.Start(r, pgOptions). Handlers resolve them via IoxideReactor.Current. /// - /// - /// Replaces the built-in transport selection. A returned pipe implementing - /// is disposed when the connection ends. - /// /// /// Everything the engine is tuned by: the reactors, the TCP transport, protocols per port, /// the HTTP/3 certificate, mutual TLS and QPACK. Ports and certificates stay on Bind. /// - public static IServerHost Create(Action? onReactorStart = null, Func>? connectionFactory = null, IoxideOptions? options = null) - => new IoxideServerHost(onReactorStart, connectionFactory, options); + public static IServerHost Create(Action? onReactorStart = null, IoxideOptions? options = null) + => new IoxideServerHost(onReactorStart, options); } diff --git a/Engine/Ioxide/Tls/IoxideTls.cs b/Engine/Ioxide/Tls/IoxideTls.cs index 0acf97b34..b6ea3b885 100644 --- a/Engine/Ioxide/Tls/IoxideTls.cs +++ b/Engine/Ioxide/Tls/IoxideTls.cs @@ -6,26 +6,10 @@ namespace GenHTTP.Engine.Ioxide; /// -/// TLS helpers for hosts wiring a custom connectionFactory. Endpoints bound with a -/// certificate are terminated automatically and need none of this. +/// TLS termination for the endpoints bound with a certificate. /// -public static class IoxideTls +internal static class IoxideTls { - /// - /// onReactorStart hook: start a ring-native TLS service (OpenSSL context) on this reactor. - /// - public static void StartService(Reactor reactor, TlsOptions options) => TlsService.Start(reactor, options); - - /// - /// connectionFactory helper: TLS-terminate on the current reactor and - /// return the duplex pipe the engine serves over. Requires to have run. - /// - public static async ValueTask AcceptAsync(TcpConnection conn) - => await AcceptAsync(conn, IoxideReactor.Current.GetService()); - - internal static async ValueTask AcceptAsync(TcpConnection conn, TlsService service) - => (await AcceptWithAlpnAsync(conn, service)).Pipe; - /// /// Terminates TLS and reports what ALPN settled on, which is how a port serving several /// protocols knows which one this connection speaks. Null means the client offered nothing the @@ -39,6 +23,10 @@ internal static async ValueTask AcceptAsync(TcpConnection conn, Tls } } +/// +/// The TLS service each secure port owns on this reactor. One per port, since ALPN and the client +/// CA differ per endpoint; resolved by the listener port a connection arrived on. +/// internal sealed class TlsRegistry { private readonly Dictionary _byPort = []; From d430395615502acb8471a9676074221b5471dddf Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Sun, 16 Aug 2026 21:14:31 +0100 Subject: [PATCH 30/55] refactor(ioxide): keep the h3 options with the rest of the QUIC state Nghttp3Options is not a duplicate of IoxideHttp3Options - it is ngtcp2's own record, and this is the one place the caller's settings cross into it. Holding it is deliberate: QuicHandle runs per accepted connection and neither value ever changes, so building it there would allocate per connection. What was wrong is where it lived. It sat in the constructor and in the main partial, built unconditionally, while _quicEngine and _quicEndPoint sat in the QUIC half and were set in WithQuic. Now all three are together, built when a QUIC listener actually starts and dropped with it - so a server serving no HTTP/3 never constructs it at all. --- Engine/Ioxide/Hosting/IoxideServer.Quic.cs | 13 +++++++++++++ Engine/Ioxide/Hosting/IoxideServer.cs | 11 +---------- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/Engine/Ioxide/Hosting/IoxideServer.Quic.cs b/Engine/Ioxide/Hosting/IoxideServer.Quic.cs index 99f5f7070..5638ec181 100644 --- a/Engine/Ioxide/Hosting/IoxideServer.Quic.cs +++ b/Engine/Ioxide/Hosting/IoxideServer.Quic.cs @@ -1,6 +1,7 @@ using System.Security.Cryptography.X509Certificates; using ioxide; +using ioxide.nghttp3; using ioxide.ngtcp2; using Microsoft.Extensions.Logging; @@ -16,6 +17,8 @@ public sealed partial class IoxideServer private IoxideEndPoint? _quicEndPoint; + private Nghttp3Options? _h3Options; + /// /// Adds the QUIC listener for the endpoint serving HTTP/3. Needs a secure endpoint - QUIC /// carries TLS 1.3 and has no cleartext mode - and takes its port, which is what a browser @@ -40,6 +43,15 @@ private ServerConfig WithQuic(ServerConfig serverConfig, IoxideEndPoint quicEndP _quicEndPoint = quicEndPoint; + // Built once here, not in the QuicHandle below - that runs per accepted connection, and + // these two never change. Nghttp3Options is ngtcp2's own record; IoxideHttp3Options is + // what the caller sets, and this is where the two meet. + _h3Options = new Nghttp3Options + { + QpackDynamicTableCapacity = _options.Http3.QpackDynamicTableCapacity, + QpackBlockedStreams = _options.Http3.QpackBlockedStreams, + }; + return serverConfig with { Udp = serverConfig.Udp ?? new UdpOptions(), @@ -128,5 +140,6 @@ private void DisposeQuic() { _quicEngine?.Dispose(); _quicEngine = null; + _h3Options = null; } } diff --git a/Engine/Ioxide/Hosting/IoxideServer.cs b/Engine/Ioxide/Hosting/IoxideServer.cs index 085451cbb..7e865e4d3 100644 --- a/Engine/Ioxide/Hosting/IoxideServer.cs +++ b/Engine/Ioxide/Hosting/IoxideServer.cs @@ -10,7 +10,6 @@ using GenHTTP.Engine.Shared.Types; using ioxide; -using ioxide.nghttp3; using ioxide.tls; using Microsoft.Extensions.Logging; @@ -42,8 +41,6 @@ public sealed partial class IoxideServer : IServer private readonly IoxideOptions _options; - private readonly Nghttp3Options _h3Options; - private readonly ILogger _logger; private Thread[]? _threads; @@ -75,12 +72,6 @@ internal IoxideServer( _onReactorStart = onReactorStart; _options = options ?? IoxideOptions.Default; - _h3Options = new Nghttp3Options - { - QpackDynamicTableCapacity = _options.Http3.QpackDynamicTableCapacity, - QpackBlockedStreams = _options.Http3.QpackBlockedStreams, - }; - _logger = config.Logging.CreateLogger(); var mapped = config.EndPoints @@ -212,7 +203,7 @@ public async ValueTask StartAsync() ProtocolsFor(tcpConnection.ListenerPort)), QuicHandle = _quicEngine is not null - ? (_, quicConnection) => Http3Driver.RunAsync(this, _quicEndPoint!, quicConnection, _h3Options) + ? (_, quicConnection) => Http3Driver.RunAsync(this, _quicEndPoint!, quicConnection, _h3Options!) : null }; From aa3fdf73ee320ff9ad3f7315516f79e4a23d553e Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Sun, 16 Aug 2026 21:28:32 +0100 Subject: [PATCH 31/55] refactor(ioxide): give the TCP listener its own partial, matching QUIC IoxideServer.Quic.cs owned the QUIC listener while the TCP one was inlined in StartAsync, so the two transports read as different kinds of thing when they are the same kind. The TCP half now sits in IoxideServer.Tcp.cs behind WithTcp, the mirror of WithQuic, and StartAsync says what it does: var serverConfig = WithTcp(BuildServerConfig()); if (_quicRequested is { } quicEndPoint) { serverConfig = WithQuic(serverConfig, quicEndPoint); } BuildServerConfig keeps only what is not a listener - the reactors, and DualStack, which applies to the TCP listener and the UDP socket alike. IoxideServer.Tls.cs becomes IoxideServer.Tcp.Tls.cs: it configures OpenSSL, which terminates the TCP protocols only, and QUIC's TLS is ngtcp2's business in the other file. RequiresClientCertificate is the one thing both ask, and now says so. --- ...eServer.Tls.cs => IoxideServer.Tcp.Tls.cs} | 4 ++ Engine/Ioxide/Hosting/IoxideServer.Tcp.cs | 49 +++++++++++++++++++ Engine/Ioxide/Hosting/IoxideServer.cs | 36 +++----------- 3 files changed, 59 insertions(+), 30 deletions(-) rename Engine/Ioxide/Hosting/{IoxideServer.Tls.cs => IoxideServer.Tcp.Tls.cs} (94%) create mode 100644 Engine/Ioxide/Hosting/IoxideServer.Tcp.cs diff --git a/Engine/Ioxide/Hosting/IoxideServer.Tls.cs b/Engine/Ioxide/Hosting/IoxideServer.Tcp.Tls.cs similarity index 94% rename from Engine/Ioxide/Hosting/IoxideServer.Tls.cs rename to Engine/Ioxide/Hosting/IoxideServer.Tcp.Tls.cs index 94235772e..772d9b010 100644 --- a/Engine/Ioxide/Hosting/IoxideServer.Tls.cs +++ b/Engine/Ioxide/Hosting/IoxideServer.Tcp.Tls.cs @@ -52,6 +52,10 @@ private IEnumerable> ResolveTls() /// so for every endpoint, or the endpoint's own validator does. A validator that only wants to /// inspect what arrives still gets asked, because the CertificateRequest goes out either way. /// + /// + /// Shared with the QUIC half, which asks the same question of ngtcp2 - mutual TLS is the one + /// setting that means the same thing on both transports. + /// private bool RequiresClientCertificate(SecurityConfiguration security) => _options.MutualTls.RequireClientCertificate || security.CertificateValidator?.RequireCertificate == true; diff --git a/Engine/Ioxide/Hosting/IoxideServer.Tcp.cs b/Engine/Ioxide/Hosting/IoxideServer.Tcp.cs new file mode 100644 index 000000000..b1e11f93a --- /dev/null +++ b/Engine/Ioxide/Hosting/IoxideServer.Tcp.cs @@ -0,0 +1,49 @@ +using ioxide; + +namespace GenHTTP.Engine.Ioxide.Hosting; + +/// +/// The TCP listener that carries HTTP/1.1 and HTTP/2, alongside the QUIC one. +/// +public sealed partial class IoxideServer +{ + /// + /// Adds the TCP listener, or none at all when no endpoint serves anything over TCP - an + /// HTTP/3-only server gets a UDP socket and nothing else, rather than a listener that accepts + /// connections it would answer with nothing. + /// + /// + /// One listener bound to several ports rather than one per port: the connection carries the + /// port it arrived on, which is what lets a single handler serve endpoints with different + /// protocols. The transport tuning comes from the options; the ports come from the bindings and + /// are not the caller's to set here. + /// + private ServerConfig WithTcp(ServerConfig serverConfig) + { + var tcpPorts = _protocols.Where(p => (p.Value & IoxideProtocols.Http1AndHttp2) != 0) + .Select(p => p.Key) + .OrderBy(p => p == _primary.Port ? 0 : 1) + .ToArray(); + + if (tcpPorts.Length == 0) + { + return serverConfig with { Tcp = null }; + } + + return serverConfig with + { + Tcp = new TcpOptions + { + Port = tcpPorts[0], + ExtraPorts = tcpPorts.Skip(1).ToArray(), + + ListenBacklog = _options.Tcp.ListenBacklog, + WriteSlabSize = _options.Tcp.WriteSlabSize, + WriteOverflow = _options.Tcp.WriteOverflow, + PoolMax = _options.Tcp.PoolMax, + ZeroCopySend = _options.Tcp.ZeroCopySend, + RecvQueueEntries = _options.Tcp.RecvQueueEntries, + }, + }; + } +} diff --git a/Engine/Ioxide/Hosting/IoxideServer.cs b/Engine/Ioxide/Hosting/IoxideServer.cs index 7e865e4d3..e145f16d8 100644 --- a/Engine/Ioxide/Hosting/IoxideServer.cs +++ b/Engine/Ioxide/Hosting/IoxideServer.cs @@ -111,9 +111,8 @@ internal IoxideServer( } /// - /// The engine's configuration, straight from the options. Ports are not set here: StartAsync - /// takes those from the endpoint bindings, which is also where an all-HTTP/3 server drops the - /// TCP listener entirely. + /// The engine-wide configuration, straight from the options. The listeners are added on top by + /// WithTcp and WithQuic, which take their ports from the endpoint bindings. /// private ServerConfig BuildServerConfig() => new() { @@ -123,15 +122,9 @@ internal IoxideServer( RecvSlots = _options.Reactor.RecvSlots, Incremental = _options.Reactor.Incremental, - Tcp = new TcpOptions - { - ListenBacklog = _options.Tcp.ListenBacklog, - WriteSlabSize = _options.Tcp.WriteSlabSize, - WriteOverflow = _options.Tcp.WriteOverflow, - PoolMax = _options.Tcp.PoolMax, - ZeroCopySend = _options.Tcp.ZeroCopySend, - RecvQueueEntries = _options.Tcp.RecvQueueEntries, - }, + // Server-wide rather than per transport: it applies to the TCP listener and the UDP socket + // alike, which is why the engine binds every endpoint with one mode. + DualStack = _primary.DualStack, }; public async ValueTask StartAsync() @@ -140,24 +133,7 @@ public async ValueTask StartAsync() Running = true; - var serverConfig = BuildServerConfig(); - - // Only ports serving something over TCP are bound, so an HTTP/3-only endpoint gets a UDP - // socket and nothing else. - var tcpPorts = _protocols.Where(p => (p.Value & IoxideProtocols.Http1AndHttp2) != 0) - .Select(p => p.Key) - .OrderBy(p => p == _primary.Port ? 0 : 1) - .ToArray(); - - serverConfig = serverConfig with - { - DualStack = _primary.DualStack, - Tcp = tcpPorts.Length == 0 ? null : (serverConfig.Tcp ?? new TcpOptions()) with - { - Port = tcpPorts[0], - ExtraPorts = tcpPorts.Skip(1).ToArray() - } - }; + var serverConfig = WithTcp(BuildServerConfig()); if (_quicRequested is { } quicEndPoint) { From 0b3cb42e47ada6db03f5463d864c35aec9e9fa70 Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Sun, 16 Aug 2026 21:54:12 +0100 Subject: [PATCH 32/55] fix(ioxide): start from no listeners, so an HTTP/3-only server cannot inherit one WithTcp already returned Tcp = null when no endpoint served HTTP/1.1 or HTTP/2, and an HTTP/3-only server does come up with no TCP listener - verified: zero TCP sockets, UDP on the bound port, 8080 untouched. But it only worked because WithTcp runs unconditionally. ioxide's ServerConfig defaults Tcp to a live listener on 8080, so guarding the call the way WithQuic is guarded - the obvious thing for someone tidying the asymmetry - would leave that default in place and bind 8080 for a protocol the server does not speak. Nothing said so at the call site. BuildServerConfig now sets Tcp = null explicitly. No listeners is the baseline, WithTcp and WithQuic only ever add, and skipping either is inert rather than wrong. Udp needs no such treatment: its Ports default to empty, which opens nothing. --- Engine/Ioxide/Hosting/IoxideServer.Tcp.cs | 3 ++- Engine/Ioxide/Hosting/IoxideServer.cs | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/Engine/Ioxide/Hosting/IoxideServer.Tcp.cs b/Engine/Ioxide/Hosting/IoxideServer.Tcp.cs index b1e11f93a..295d90ba9 100644 --- a/Engine/Ioxide/Hosting/IoxideServer.Tcp.cs +++ b/Engine/Ioxide/Hosting/IoxideServer.Tcp.cs @@ -25,9 +25,10 @@ private ServerConfig WithTcp(ServerConfig serverConfig) .OrderBy(p => p == _primary.Port ? 0 : 1) .ToArray(); + // Nothing to add: no endpoint serves HTTP/1.1 or HTTP/2, so this server is HTTP/3 only. if (tcpPorts.Length == 0) { - return serverConfig with { Tcp = null }; + return serverConfig; } return serverConfig with diff --git a/Engine/Ioxide/Hosting/IoxideServer.cs b/Engine/Ioxide/Hosting/IoxideServer.cs index e145f16d8..f07e8ed59 100644 --- a/Engine/Ioxide/Hosting/IoxideServer.cs +++ b/Engine/Ioxide/Hosting/IoxideServer.cs @@ -125,6 +125,11 @@ internal IoxideServer( // Server-wide rather than per transport: it applies to the TCP listener and the UDP socket // alike, which is why the engine binds every endpoint with one mode. DualStack = _primary.DualStack, + + // No listeners yet - WithTcp and WithQuic add the ones the bindings ask for. Explicitly + // null because ioxide's own default is a live listener on 8080, which an HTTP/3-only server + // would otherwise inherit and bind for a protocol it does not serve. + Tcp = null, }; public async ValueTask StartAsync() From 51505b6afd6f5b9ca2ddc3d8d6e6ef0225a52acd Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Sun, 16 Aug 2026 22:02:11 +0100 Subject: [PATCH 33/55] refactor(ioxide): resolve the TCP listener in the constructor, like QUIC _quicRequested said in the constructor whether a QUIC listener was wanted, while the TCP side worked it out inside WithTcp at start time. Same question, two different shapes, and only one of them visible at the call site. _tcpRequested now sits beside it, resolved from the same _protocols map, and StartAsync acts on both the same way: if (_tcpRequested.Length > 0) serverConfig = WithTcp(serverConfig); if (_quicRequested is { } ep) serverConfig = WithQuic(serverConfig, ep); Guarding WithTcp is only safe because Tcp = null is now the baseline; before that it would have left ioxide's default listener on 8080 in place. _extraPorts goes with it - assigned in the constructor, never read, and reaching for what _tcpRequested actually computes. --- Engine/Ioxide/Hosting/IoxideServer.Tcp.cs | 51 ++++++++--------------- Engine/Ioxide/Hosting/IoxideServer.cs | 22 +++++++--- 2 files changed, 35 insertions(+), 38 deletions(-) diff --git a/Engine/Ioxide/Hosting/IoxideServer.Tcp.cs b/Engine/Ioxide/Hosting/IoxideServer.Tcp.cs index 295d90ba9..469b31926 100644 --- a/Engine/Ioxide/Hosting/IoxideServer.Tcp.cs +++ b/Engine/Ioxide/Hosting/IoxideServer.Tcp.cs @@ -8,43 +8,28 @@ namespace GenHTTP.Engine.Ioxide.Hosting; public sealed partial class IoxideServer { /// - /// Adds the TCP listener, or none at all when no endpoint serves anything over TCP - an - /// HTTP/3-only server gets a UDP socket and nothing else, rather than a listener that accepts - /// connections it would answer with nothing. + /// Adds the TCP listener for the ports resolved into _tcpRequested. Only called when + /// there are any - an HTTP/3-only server gets a UDP socket and no TCP listener at all. /// /// - /// One listener bound to several ports rather than one per port: the connection carries the - /// port it arrived on, which is what lets a single handler serve endpoints with different - /// protocols. The transport tuning comes from the options; the ports come from the bindings and - /// are not the caller's to set here. + /// One listener bound to several ports rather than one per port: a connection carries the port + /// it arrived on, which is what lets a single handler serve endpoints speaking different + /// protocols. The tuning comes from the options; the ports come from the bindings and are not + /// the caller's to set. /// - private ServerConfig WithTcp(ServerConfig serverConfig) + private ServerConfig WithTcp(ServerConfig serverConfig) => serverConfig with { - var tcpPorts = _protocols.Where(p => (p.Value & IoxideProtocols.Http1AndHttp2) != 0) - .Select(p => p.Key) - .OrderBy(p => p == _primary.Port ? 0 : 1) - .ToArray(); - - // Nothing to add: no endpoint serves HTTP/1.1 or HTTP/2, so this server is HTTP/3 only. - if (tcpPorts.Length == 0) - { - return serverConfig; - } - - return serverConfig with + Tcp = new TcpOptions { - Tcp = new TcpOptions - { - Port = tcpPorts[0], - ExtraPorts = tcpPorts.Skip(1).ToArray(), + Port = _tcpRequested[0], + ExtraPorts = _tcpRequested[1..], - ListenBacklog = _options.Tcp.ListenBacklog, - WriteSlabSize = _options.Tcp.WriteSlabSize, - WriteOverflow = _options.Tcp.WriteOverflow, - PoolMax = _options.Tcp.PoolMax, - ZeroCopySend = _options.Tcp.ZeroCopySend, - RecvQueueEntries = _options.Tcp.RecvQueueEntries, - }, - }; - } + ListenBacklog = _options.Tcp.ListenBacklog, + WriteSlabSize = _options.Tcp.WriteSlabSize, + WriteOverflow = _options.Tcp.WriteOverflow, + PoolMax = _options.Tcp.PoolMax, + ZeroCopySend = _options.Tcp.ZeroCopySend, + RecvQueueEntries = _options.Tcp.RecvQueueEntries, + }, + }; } diff --git a/Engine/Ioxide/Hosting/IoxideServer.cs b/Engine/Ioxide/Hosting/IoxideServer.cs index f07e8ed59..59cee6b05 100644 --- a/Engine/Ioxide/Hosting/IoxideServer.cs +++ b/Engine/Ioxide/Hosting/IoxideServer.cs @@ -31,7 +31,7 @@ public sealed partial class IoxideServer : IServer private readonly Dictionary _secure; - private readonly ushort[] _extraPorts; + private readonly ushort[] _tcpRequested; private readonly IoxideEndPoint? _quicRequested; @@ -80,7 +80,6 @@ internal IoxideServer( _primary = mapped[0]; _endPointByPort = mapped.ToDictionary(e => e.Port); - _extraPorts = mapped.Skip(1).Select(e => e.Port).ToArray(); if (mapped.Any(e => e.DualStack != _primary.DualStack)) { @@ -89,8 +88,16 @@ internal IoxideServer( _protocols = mapped.ToDictionary(e => e.Port, e => ResolveProtocols(_options, config, e.Port)); - // The transport binds a single UDP port for the whole server, so only one endpoint can - // serve HTTP/3. + // Which endpoints want which listener, decided here so StartAsync only has to act on it. + // The ports serving HTTP/1.1 or HTTP/2 share one TCP listener, primary first - it becomes + // TcpOptions.Port and the rest its ExtraPorts. + _tcpRequested = _protocols.Where(p => (p.Value & IoxideProtocols.Http1AndHttp2) != 0) + .Select(p => p.Key) + .OrderBy(p => p == _primary.Port ? 0 : 1) + .ToArray(); + + // QUIC is the other way round: the transport binds a single UDP port for the whole server, + // so only one endpoint can serve HTTP/3. var quicEndpoints = mapped.Where(e => _protocols[e.Port].HasFlag(IoxideProtocols.Http3)).ToList(); if (quicEndpoints.Count > 1) @@ -138,7 +145,12 @@ public async ValueTask StartAsync() Running = true; - var serverConfig = WithTcp(BuildServerConfig()); + var serverConfig = BuildServerConfig(); + + if (_tcpRequested.Length > 0) + { + serverConfig = WithTcp(serverConfig); + } if (_quicRequested is { } quicEndPoint) { From 5900d87345f261f8344d543454bd98623032e1b8 Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Sun, 16 Aug 2026 22:56:26 +0100 Subject: [PATCH 34/55] refactor(ioxide): let each transport resolve itself out of the constructor The constructor did the endpoint mapping, the dual-stack check, the protocol resolution, both listener decisions and the QUIC arity refusal - so reading how HTTP/3 is admitted meant reading the whole thing, in a file that is not about QUIC. Each piece moves to where the rest of its subject already lives: MapEndPoints core mapping, and the dual-stack agreement ResolveTcpPorts Tcp which ports share the TCP listener ResolveQuicEndPoint Quic which endpoint gets QUIC, and why only one What is left is assignment in dependency order, and the ordering constraint is now stated rather than implied: both resolvers read _protocols, and the TCP one reads _primary. MapEndPoints checks mapped[0] rather than _primary, so it no longer depends on a field being assigned first - the check moved with the mapping it validates. --- Engine/Ioxide/Hosting/IoxideServer.Quic.cs | 19 ++++++++ Engine/Ioxide/Hosting/IoxideServer.Tcp.cs | 22 ++++++++-- Engine/Ioxide/Hosting/IoxideServer.cs | 51 ++++++++++------------ 3 files changed, 60 insertions(+), 32 deletions(-) diff --git a/Engine/Ioxide/Hosting/IoxideServer.Quic.cs b/Engine/Ioxide/Hosting/IoxideServer.Quic.cs index 5638ec181..1b5eb085e 100644 --- a/Engine/Ioxide/Hosting/IoxideServer.Quic.cs +++ b/Engine/Ioxide/Hosting/IoxideServer.Quic.cs @@ -19,6 +19,25 @@ public sealed partial class IoxideServer private Nghttp3Options? _h3Options; + /// + /// The endpoint that wants a QUIC listener, or none. At most one: the transport binds a single + /// UDP port for the whole server, so several endpoints asking for HTTP/3 would each want their + /// own and only the first could have it - refused here rather than silently honouring one. + /// + private IoxideEndPoint? ResolveQuicEndPoint(List mapped) + { + var quicEndPoints = mapped.Where(e => _protocols[e.Port].HasFlag(IoxideProtocols.Http3)).ToList(); + + if (quicEndPoints.Count > 1) + { + throw new NotSupportedException( + $"The ioxide engine binds one QUIC listener, but HTTP/3 was requested on ports {string.Join(", ", quicEndPoints.Select(e => e.Port))}. " + + "Name the protocols per port (ProtocolsByPort) so only one of them serves HTTP/3."); + } + + return quicEndPoints.Count == 1 ? quicEndPoints[0] : null; + } + /// /// Adds the QUIC listener for the endpoint serving HTTP/3. Needs a secure endpoint - QUIC /// carries TLS 1.3 and has no cleartext mode - and takes its port, which is what a browser diff --git a/Engine/Ioxide/Hosting/IoxideServer.Tcp.cs b/Engine/Ioxide/Hosting/IoxideServer.Tcp.cs index 469b31926..f48985702 100644 --- a/Engine/Ioxide/Hosting/IoxideServer.Tcp.cs +++ b/Engine/Ioxide/Hosting/IoxideServer.Tcp.cs @@ -8,14 +8,28 @@ namespace GenHTTP.Engine.Ioxide.Hosting; public sealed partial class IoxideServer { /// - /// Adds the TCP listener for the ports resolved into _tcpRequested. Only called when - /// there are any - an HTTP/3-only server gets a UDP socket and no TCP listener at all. + /// The ports that want a TCP listener: those serving HTTP/1.1 or HTTP/2, primary first - it + /// becomes TcpOptions.Port and the rest its ExtraPorts. Empty means this server + /// serves HTTP/3 only and opens no TCP listener at all. /// /// /// One listener bound to several ports rather than one per port: a connection carries the port /// it arrived on, which is what lets a single handler serve endpoints speaking different - /// protocols. The tuning comes from the options; the ports come from the bindings and are not - /// the caller's to set. + /// protocols. + /// + private ushort[] ResolveTcpPorts() + => _protocols.Where(p => (p.Value & IoxideProtocols.Http1AndHttp2) != 0) + .Select(p => p.Key) + .OrderBy(p => p == _primary.Port ? 0 : 1) + .ToArray(); + + /// + /// Adds the TCP listener for the ports resolved into _tcpRequested. Only called when + /// there are any - an HTTP/3-only server gets a UDP socket and no TCP listener at all. + /// + /// + /// The tuning comes from the options; the ports come from the bindings and are not the + /// caller's to set. /// private ServerConfig WithTcp(ServerConfig serverConfig) => serverConfig with { diff --git a/Engine/Ioxide/Hosting/IoxideServer.cs b/Engine/Ioxide/Hosting/IoxideServer.cs index 59cee6b05..f16d7d022 100644 --- a/Engine/Ioxide/Hosting/IoxideServer.cs +++ b/Engine/Ioxide/Hosting/IoxideServer.cs @@ -74,40 +74,17 @@ internal IoxideServer( _logger = config.Logging.CreateLogger(); - var mapped = config.EndPoints - .Select(e => new IoxideEndPoint(e.Address, e.Port, e.DualStack, e.Security != null)) - .ToList(); + var mapped = MapEndPoints(config); _primary = mapped[0]; _endPointByPort = mapped.ToDictionary(e => e.Port); - if (mapped.Any(e => e.DualStack != _primary.DualStack)) - { - throw new NotSupportedException("The ioxide engine binds all endpoints with one dual-stack mode."); - } - _protocols = mapped.ToDictionary(e => e.Port, e => ResolveProtocols(_options, config, e.Port)); - // Which endpoints want which listener, decided here so StartAsync only has to act on it. - // The ports serving HTTP/1.1 or HTTP/2 share one TCP listener, primary first - it becomes - // TcpOptions.Port and the rest its ExtraPorts. - _tcpRequested = _protocols.Where(p => (p.Value & IoxideProtocols.Http1AndHttp2) != 0) - .Select(p => p.Key) - .OrderBy(p => p == _primary.Port ? 0 : 1) - .ToArray(); - - // QUIC is the other way round: the transport binds a single UDP port for the whole server, - // so only one endpoint can serve HTTP/3. - var quicEndpoints = mapped.Where(e => _protocols[e.Port].HasFlag(IoxideProtocols.Http3)).ToList(); - - if (quicEndpoints.Count > 1) - { - throw new NotSupportedException( - $"The ioxide engine binds one QUIC listener, but HTTP/3 was requested on ports {string.Join(", ", quicEndpoints.Select(e => e.Port))}. " - + "Name the protocols per port (ProtocolsByPort) so only one of them serves HTTP/3."); - } - - _quicRequested = quicEndpoints.Count == 1 ? quicEndpoints[0] : null; + // Which endpoints want which listener, settled here so StartAsync only has to act on it. + // Order matters: both read _protocols, and the TCP one reads _primary as well. + _tcpRequested = ResolveTcpPorts(); + _quicRequested = ResolveQuicEndPoint(mapped); // Certificates are resolved per reactor in OnStart, not here - see ResolveTls. _secure = config.EndPoints @@ -117,6 +94,24 @@ internal IoxideServer( EndPoints = new IoxideEndPoints(mapped.Cast().ToList()); } + /// + /// GenHTTP's endpoints as the engine's own, which is also where the one thing every endpoint + /// must agree on is checked: ioxide binds the whole server with a single dual-stack mode. + /// + private static List MapEndPoints(ServerConfiguration config) + { + var mapped = config.EndPoints + .Select(e => new IoxideEndPoint(e.Address, e.Port, e.DualStack, e.Security != null)) + .ToList(); + + if (mapped.Any(e => e.DualStack != mapped[0].DualStack)) + { + throw new NotSupportedException("The ioxide engine binds all endpoints with one dual-stack mode."); + } + + return mapped; + } + /// /// The engine-wide configuration, straight from the options. The listeners are added on top by /// WithTcp and WithQuic, which take their ports from the endpoint bindings. From 0b6112573545ae53a31cf095b5b099e2fee784fa Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Sun, 16 Aug 2026 23:03:13 +0100 Subject: [PATCH 35/55] refactor(ioxide): Infrastructure/Endpoints, matching the Internal engine Hosting/ held the server, its host and the endpoint types in one flat folder, under a name no other engine uses. It now mirrors Engine/Internal: Infrastructure/ IoxideServer.cs .Tcp.cs .Tcp.Tls.cs .Quic.cs IoxideServerHost.cs Endpoints/ EndPoint.cs EndPointCollection.cs IoxideEndPoint and IoxideEndPoints lose the prefix the namespace already carries, and become EndPoint and EndPointCollection - the names the Internal engine gives the same two things. Both are internal now, as they are there: neither was ever reachable except through IEndPoint and IEndPointCollection. Namespaces follow the folders, so the server files move to GenHTTP.Engine.Ioxide.Infrastructure and the endpoint types sit one below. --- Engine/Ioxide/GenHTTP.Engine.Ioxide.csproj | 2 +- Engine/Ioxide/Hosting/IoxideEndPoint.cs | 30 ------------------- .../Infrastructure/Endpoints/EndPoint.cs | 22 ++++++++++++++ .../Endpoints/EndPointCollection.cs | 19 ++++++++++++ .../IoxideServer.Quic.cs | 10 ++++--- .../IoxideServer.Tcp.Tls.cs | 2 +- .../IoxideServer.Tcp.cs | 2 +- .../IoxideServer.cs | 16 +++++----- .../IoxideServerHost.cs | 2 +- Engine/Ioxide/Server.cs | 2 +- 10 files changed, 61 insertions(+), 46 deletions(-) delete mode 100644 Engine/Ioxide/Hosting/IoxideEndPoint.cs create mode 100644 Engine/Ioxide/Infrastructure/Endpoints/EndPoint.cs create mode 100644 Engine/Ioxide/Infrastructure/Endpoints/EndPointCollection.cs rename Engine/Ioxide/{Hosting => Infrastructure}/IoxideServer.Quic.cs (95%) rename Engine/Ioxide/{Hosting => Infrastructure}/IoxideServer.Tcp.Tls.cs (98%) rename Engine/Ioxide/{Hosting => Infrastructure}/IoxideServer.Tcp.cs (97%) rename Engine/Ioxide/{Hosting => Infrastructure}/IoxideServer.cs (95%) rename Engine/Ioxide/{Hosting => Infrastructure}/IoxideServerHost.cs (90%) diff --git a/Engine/Ioxide/GenHTTP.Engine.Ioxide.csproj b/Engine/Ioxide/GenHTTP.Engine.Ioxide.csproj index 562e204ee..1ebdab7cf 100644 --- a/Engine/Ioxide/GenHTTP.Engine.Ioxide.csproj +++ b/Engine/Ioxide/GenHTTP.Engine.Ioxide.csproj @@ -2,7 +2,7 @@ - net11.0 + net11.0;net10.0 true diff --git a/Engine/Ioxide/Hosting/IoxideEndPoint.cs b/Engine/Ioxide/Hosting/IoxideEndPoint.cs deleted file mode 100644 index 41bc717c9..000000000 --- a/Engine/Ioxide/Hosting/IoxideEndPoint.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System.Collections; -using System.Net; - -using GenHTTP.Api.Infrastructure; - -namespace GenHTTP.Engine.Ioxide.Hosting; - -public sealed class IoxideEndPoint(IPAddress? address, ushort port, bool dualStack, bool secure) : IEndPoint -{ - public IPAddress? Address => address; - - public ushort Port => port; - - public bool DualStack => dualStack; - - public bool Secure => secure; - - public void Dispose() { } -} - -internal sealed class IoxideEndPoints(IReadOnlyList eps) : IEndPointCollection -{ - public IEndPoint this[int i] => eps[i]; - - public int Count => eps.Count; - - public IEnumerator GetEnumerator() => eps.GetEnumerator(); - - IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); -} diff --git a/Engine/Ioxide/Infrastructure/Endpoints/EndPoint.cs b/Engine/Ioxide/Infrastructure/Endpoints/EndPoint.cs new file mode 100644 index 000000000..b24925445 --- /dev/null +++ b/Engine/Ioxide/Infrastructure/Endpoints/EndPoint.cs @@ -0,0 +1,22 @@ +using System.Net; + +using GenHTTP.Api.Infrastructure; + +namespace GenHTTP.Engine.Ioxide.Infrastructure.Endpoints; + +/// +/// One bound endpoint, as the engine sees it. Nothing to dispose: the listener belongs to the +/// reactors, which bind it themselves and tear it down with their rings. +/// +internal sealed class EndPoint(IPAddress? address, ushort port, bool dualStack, bool secure) : IEndPoint +{ + public IPAddress? Address => address; + + public ushort Port => port; + + public bool DualStack => dualStack; + + public bool Secure => secure; + + public void Dispose() { } +} diff --git a/Engine/Ioxide/Infrastructure/Endpoints/EndPointCollection.cs b/Engine/Ioxide/Infrastructure/Endpoints/EndPointCollection.cs new file mode 100644 index 000000000..526602aac --- /dev/null +++ b/Engine/Ioxide/Infrastructure/Endpoints/EndPointCollection.cs @@ -0,0 +1,19 @@ +using System.Collections; + +using GenHTTP.Api.Infrastructure; + +namespace GenHTTP.Engine.Ioxide.Infrastructure.Endpoints; + +/// +/// The endpoints a server is listening on, as reports them. +/// +internal sealed class EndPointCollection(IReadOnlyList endPoints) : IEndPointCollection +{ + public IEndPoint this[int index] => endPoints[index]; + + public int Count => endPoints.Count; + + public IEnumerator GetEnumerator() => endPoints.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} diff --git a/Engine/Ioxide/Hosting/IoxideServer.Quic.cs b/Engine/Ioxide/Infrastructure/IoxideServer.Quic.cs similarity index 95% rename from Engine/Ioxide/Hosting/IoxideServer.Quic.cs rename to Engine/Ioxide/Infrastructure/IoxideServer.Quic.cs index 1b5eb085e..67ed63096 100644 --- a/Engine/Ioxide/Hosting/IoxideServer.Quic.cs +++ b/Engine/Ioxide/Infrastructure/IoxideServer.Quic.cs @@ -1,12 +1,14 @@ using System.Security.Cryptography.X509Certificates; +using GenHTTP.Engine.Ioxide.Infrastructure.Endpoints; + using ioxide; using ioxide.nghttp3; using ioxide.ngtcp2; using Microsoft.Extensions.Logging; -namespace GenHTTP.Engine.Ioxide.Hosting; +namespace GenHTTP.Engine.Ioxide.Infrastructure; /// /// The QUIC listener that carries HTTP/3, alongside the TCP one. @@ -15,7 +17,7 @@ public sealed partial class IoxideServer { private QuicEngine? _quicEngine; - private IoxideEndPoint? _quicEndPoint; + private EndPoint? _quicEndPoint; private Nghttp3Options? _h3Options; @@ -24,7 +26,7 @@ public sealed partial class IoxideServer /// UDP port for the whole server, so several endpoints asking for HTTP/3 would each want their /// own and only the first could have it - refused here rather than silently honouring one. /// - private IoxideEndPoint? ResolveQuicEndPoint(List mapped) + private EndPoint? ResolveQuicEndPoint(List mapped) { var quicEndPoints = mapped.Where(e => _protocols[e.Port].HasFlag(IoxideProtocols.Http3)).ToList(); @@ -43,7 +45,7 @@ public sealed partial class IoxideServer /// carries TLS 1.3 and has no cleartext mode - and takes its port, which is what a browser /// assumes when an Alt-Svc advertisement names none of its own. /// - private ServerConfig WithQuic(ServerConfig serverConfig, IoxideEndPoint quicEndPoint) + private ServerConfig WithQuic(ServerConfig serverConfig, EndPoint quicEndPoint) { if (!_secure.TryGetValue(quicEndPoint.Port, out var security)) { diff --git a/Engine/Ioxide/Hosting/IoxideServer.Tcp.Tls.cs b/Engine/Ioxide/Infrastructure/IoxideServer.Tcp.Tls.cs similarity index 98% rename from Engine/Ioxide/Hosting/IoxideServer.Tcp.Tls.cs rename to Engine/Ioxide/Infrastructure/IoxideServer.Tcp.Tls.cs index 772d9b010..89a3229ac 100644 --- a/Engine/Ioxide/Hosting/IoxideServer.Tcp.Tls.cs +++ b/Engine/Ioxide/Infrastructure/IoxideServer.Tcp.Tls.cs @@ -6,7 +6,7 @@ using Microsoft.Extensions.Logging; -namespace GenHTTP.Engine.Ioxide.Hosting; +namespace GenHTTP.Engine.Ioxide.Infrastructure; /// /// TLS termination for the TCP endpoints - HTTP/1.1 and HTTP/2 both ride this. diff --git a/Engine/Ioxide/Hosting/IoxideServer.Tcp.cs b/Engine/Ioxide/Infrastructure/IoxideServer.Tcp.cs similarity index 97% rename from Engine/Ioxide/Hosting/IoxideServer.Tcp.cs rename to Engine/Ioxide/Infrastructure/IoxideServer.Tcp.cs index f48985702..cefbcd949 100644 --- a/Engine/Ioxide/Hosting/IoxideServer.Tcp.cs +++ b/Engine/Ioxide/Infrastructure/IoxideServer.Tcp.cs @@ -1,6 +1,6 @@ using ioxide; -namespace GenHTTP.Engine.Ioxide.Hosting; +namespace GenHTTP.Engine.Ioxide.Infrastructure; /// /// The TCP listener that carries HTTP/1.1 and HTTP/2, alongside the QUIC one. diff --git a/Engine/Ioxide/Hosting/IoxideServer.cs b/Engine/Ioxide/Infrastructure/IoxideServer.cs similarity index 95% rename from Engine/Ioxide/Hosting/IoxideServer.cs rename to Engine/Ioxide/Infrastructure/IoxideServer.cs index f16d7d022..b8ab71125 100644 --- a/Engine/Ioxide/Hosting/IoxideServer.cs +++ b/Engine/Ioxide/Infrastructure/IoxideServer.cs @@ -4,6 +4,8 @@ using GenHTTP.Api.Content; using GenHTTP.Api.Infrastructure; +using GenHTTP.Engine.Ioxide.Infrastructure.Endpoints; + using GenHTTP.Engine.Ioxide.Protocol; using GenHTTP.Engine.Ioxide.Protocol.Multiplexed; using GenHTTP.Engine.Shared.Infrastructure; @@ -14,7 +16,7 @@ using Microsoft.Extensions.Logging; -namespace GenHTTP.Engine.Ioxide.Hosting; +namespace GenHTTP.Engine.Ioxide.Infrastructure; /// /// Hosts an application on ioxide's io_uring reactors: one per core, each owning a ring and its @@ -25,15 +27,15 @@ public sealed partial class IoxideServer : IServer { private readonly ServerConfiguration _config; - private readonly IoxideEndPoint _primary; + private readonly EndPoint _primary; - private readonly Dictionary _endPointByPort; + private readonly Dictionary _endPointByPort; private readonly Dictionary _secure; private readonly ushort[] _tcpRequested; - private readonly IoxideEndPoint? _quicRequested; + private readonly EndPoint? _quicRequested; private readonly Dictionary _protocols; @@ -91,17 +93,17 @@ internal IoxideServer( .Where(e => e.Security is not null) .ToDictionary(e => e.Port, e => e.Security!); - EndPoints = new IoxideEndPoints(mapped.Cast().ToList()); + EndPoints = new EndPointCollection(mapped.Cast().ToList()); } /// /// GenHTTP's endpoints as the engine's own, which is also where the one thing every endpoint /// must agree on is checked: ioxide binds the whole server with a single dual-stack mode. /// - private static List MapEndPoints(ServerConfiguration config) + private static List MapEndPoints(ServerConfiguration config) { var mapped = config.EndPoints - .Select(e => new IoxideEndPoint(e.Address, e.Port, e.DualStack, e.Security != null)) + .Select(e => new EndPoint(e.Address, e.Port, e.DualStack, e.Security != null)) .ToList(); if (mapped.Any(e => e.DualStack != mapped[0].DualStack)) diff --git a/Engine/Ioxide/Hosting/IoxideServerHost.cs b/Engine/Ioxide/Infrastructure/IoxideServerHost.cs similarity index 90% rename from Engine/Ioxide/Hosting/IoxideServerHost.cs rename to Engine/Ioxide/Infrastructure/IoxideServerHost.cs index fd0d2d14e..53720c328 100644 --- a/Engine/Ioxide/Hosting/IoxideServerHost.cs +++ b/Engine/Ioxide/Infrastructure/IoxideServerHost.cs @@ -5,7 +5,7 @@ using ioxide; -namespace GenHTTP.Engine.Ioxide.Hosting; +namespace GenHTTP.Engine.Ioxide.Infrastructure; public sealed class IoxideServerHost( Action? onReactorStart = null, diff --git a/Engine/Ioxide/Server.cs b/Engine/Ioxide/Server.cs index f60efeea3..4cfc3e489 100644 --- a/Engine/Ioxide/Server.cs +++ b/Engine/Ioxide/Server.cs @@ -1,6 +1,6 @@ using GenHTTP.Api.Infrastructure; -using GenHTTP.Engine.Ioxide.Hosting; +using GenHTTP.Engine.Ioxide.Infrastructure; using ioxide; namespace GenHTTP.Engine.Ioxide; From 636d9ce56b46c0ca1de43326cd1f0cc6e7cc8a47 Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Sun, 16 Aug 2026 23:09:26 +0100 Subject: [PATCH 36/55] refactor(ioxide): Server.cs holds class Host, so name it Host.cs Matches Engine/Internal/Host.cs and Engine/Kestrel/Host.cs, which hold the same entry point under the same name. --- Engine/Ioxide/{Server.cs => Host.cs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename Engine/Ioxide/{Server.cs => Host.cs} (100%) diff --git a/Engine/Ioxide/Server.cs b/Engine/Ioxide/Host.cs similarity index 100% rename from Engine/Ioxide/Server.cs rename to Engine/Ioxide/Host.cs From 859fab487d3c4294dd26c8027d7e7b77ab64c1a0 Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Sun, 16 Aug 2026 23:26:14 +0100 Subject: [PATCH 37/55] refactor(ioxide): drop the Ioxide prefix from the infrastructure types IoxideServer and IoxideServerHost sat in GenHTTP.Engine.Ioxide.Infrastructure, where the namespace already says whose they are. They are now Server and ServerHost, and the files match. ServerHost shadows the GenHTTP base class it derives from, so the base is qualified as Shared.Hosting.ServerHost - the same shape Server.Quic.cs already uses for Shared.Infrastructure.SecurityConfiguration. The sibling engines avoid this by prefixing instead (ThreadedServerHost, KestrelServerHost). --- Engine/Ioxide/Host.cs | 2 +- .../Ioxide/Infrastructure/IoxideServerHost.cs | 18 ------------- .../{IoxideServer.Quic.cs => Server.Quic.cs} | 2 +- ...ideServer.Tcp.Tls.cs => Server.Tcp.Tls.cs} | 2 +- .../{IoxideServer.Tcp.cs => Server.Tcp.cs} | 2 +- .../{IoxideServer.cs => Server.cs} | 8 +++--- Engine/Ioxide/Infrastructure/ServerHost.cs | 25 +++++++++++++++++++ 7 files changed, 33 insertions(+), 26 deletions(-) delete mode 100644 Engine/Ioxide/Infrastructure/IoxideServerHost.cs rename Engine/Ioxide/Infrastructure/{IoxideServer.Quic.cs => Server.Quic.cs} (99%) rename Engine/Ioxide/Infrastructure/{IoxideServer.Tcp.Tls.cs => Server.Tcp.Tls.cs} (98%) rename Engine/Ioxide/Infrastructure/{IoxideServer.Tcp.cs => Server.Tcp.cs} (97%) rename Engine/Ioxide/Infrastructure/{IoxideServer.cs => Server.cs} (97%) create mode 100644 Engine/Ioxide/Infrastructure/ServerHost.cs diff --git a/Engine/Ioxide/Host.cs b/Engine/Ioxide/Host.cs index 4cfc3e489..97a78e6ef 100644 --- a/Engine/Ioxide/Host.cs +++ b/Engine/Ioxide/Host.cs @@ -20,6 +20,6 @@ public static class Host /// the HTTP/3 certificate, mutual TLS and QPACK. Ports and certificates stay on Bind. /// public static IServerHost Create(Action? onReactorStart = null, IoxideOptions? options = null) - => new IoxideServerHost(onReactorStart, options); + => new ServerHost(onReactorStart, options); } diff --git a/Engine/Ioxide/Infrastructure/IoxideServerHost.cs b/Engine/Ioxide/Infrastructure/IoxideServerHost.cs deleted file mode 100644 index 53720c328..000000000 --- a/Engine/Ioxide/Infrastructure/IoxideServerHost.cs +++ /dev/null @@ -1,18 +0,0 @@ -using GenHTTP.Api.Content; -using GenHTTP.Api.Infrastructure; -using GenHTTP.Engine.Shared.Hosting; -using GenHTTP.Engine.Shared.Infrastructure; - -using ioxide; - -namespace GenHTTP.Engine.Ioxide.Infrastructure; - -public sealed class IoxideServerHost( - Action? onReactorStart = null, - IoxideOptions? options = null) : ServerHost -{ - - protected override IServer Build(ServerConfiguration config, IHandler handler) - => new IoxideServer(config, handler, onReactorStart, options); - -} diff --git a/Engine/Ioxide/Infrastructure/IoxideServer.Quic.cs b/Engine/Ioxide/Infrastructure/Server.Quic.cs similarity index 99% rename from Engine/Ioxide/Infrastructure/IoxideServer.Quic.cs rename to Engine/Ioxide/Infrastructure/Server.Quic.cs index 67ed63096..4cfc8730e 100644 --- a/Engine/Ioxide/Infrastructure/IoxideServer.Quic.cs +++ b/Engine/Ioxide/Infrastructure/Server.Quic.cs @@ -13,7 +13,7 @@ namespace GenHTTP.Engine.Ioxide.Infrastructure; /// /// The QUIC listener that carries HTTP/3, alongside the TCP one. /// -public sealed partial class IoxideServer +public sealed partial class Server { private QuicEngine? _quicEngine; diff --git a/Engine/Ioxide/Infrastructure/IoxideServer.Tcp.Tls.cs b/Engine/Ioxide/Infrastructure/Server.Tcp.Tls.cs similarity index 98% rename from Engine/Ioxide/Infrastructure/IoxideServer.Tcp.Tls.cs rename to Engine/Ioxide/Infrastructure/Server.Tcp.Tls.cs index 89a3229ac..c120524ee 100644 --- a/Engine/Ioxide/Infrastructure/IoxideServer.Tcp.Tls.cs +++ b/Engine/Ioxide/Infrastructure/Server.Tcp.Tls.cs @@ -11,7 +11,7 @@ namespace GenHTTP.Engine.Ioxide.Infrastructure; /// /// TLS termination for the TCP endpoints - HTTP/1.1 and HTTP/2 both ride this. /// -public sealed partial class IoxideServer +public sealed partial class Server { /// /// The TLS options for every secure port whose provider yields a default (no-SNI) certificate. diff --git a/Engine/Ioxide/Infrastructure/IoxideServer.Tcp.cs b/Engine/Ioxide/Infrastructure/Server.Tcp.cs similarity index 97% rename from Engine/Ioxide/Infrastructure/IoxideServer.Tcp.cs rename to Engine/Ioxide/Infrastructure/Server.Tcp.cs index cefbcd949..81fb33a29 100644 --- a/Engine/Ioxide/Infrastructure/IoxideServer.Tcp.cs +++ b/Engine/Ioxide/Infrastructure/Server.Tcp.cs @@ -5,7 +5,7 @@ namespace GenHTTP.Engine.Ioxide.Infrastructure; /// /// The TCP listener that carries HTTP/1.1 and HTTP/2, alongside the QUIC one. /// -public sealed partial class IoxideServer +public sealed partial class Server { /// /// The ports that want a TCP listener: those serving HTTP/1.1 or HTTP/2, primary first - it diff --git a/Engine/Ioxide/Infrastructure/IoxideServer.cs b/Engine/Ioxide/Infrastructure/Server.cs similarity index 97% rename from Engine/Ioxide/Infrastructure/IoxideServer.cs rename to Engine/Ioxide/Infrastructure/Server.cs index b8ab71125..e44fd9874 100644 --- a/Engine/Ioxide/Infrastructure/IoxideServer.cs +++ b/Engine/Ioxide/Infrastructure/Server.cs @@ -23,7 +23,7 @@ namespace GenHTTP.Engine.Ioxide.Infrastructure; /// connections on its own thread. Protocols are per port; TLS termination and the QUIC listener /// live in the other halves of this class. /// -public sealed partial class IoxideServer : IServer +public sealed partial class Server : IServer { private readonly ServerConfiguration _config; @@ -49,7 +49,7 @@ public sealed partial class IoxideServer : IServer private Reactor[]? _reactors; - public string Version { get; } = typeof(IoxideServer).Assembly.GetName().Version?.ToString() ?? "0.1"; + public string Version { get; } = typeof(Server).Assembly.GetName().Version?.ToString() ?? "0.1"; public bool Running { get; private set; } @@ -63,7 +63,7 @@ public sealed partial class IoxideServer : IServer public IHandler Handler { get; } - internal IoxideServer( + internal Server( ServerConfiguration config, IHandler handler, Action? onReactorStart = null, @@ -74,7 +74,7 @@ internal IoxideServer( _onReactorStart = onReactorStart; _options = options ?? IoxideOptions.Default; - _logger = config.Logging.CreateLogger(); + _logger = config.Logging.CreateLogger(); var mapped = MapEndPoints(config); diff --git a/Engine/Ioxide/Infrastructure/ServerHost.cs b/Engine/Ioxide/Infrastructure/ServerHost.cs new file mode 100644 index 000000000..67633d7a6 --- /dev/null +++ b/Engine/Ioxide/Infrastructure/ServerHost.cs @@ -0,0 +1,25 @@ +using GenHTTP.Api.Content; +using GenHTTP.Api.Infrastructure; +using GenHTTP.Engine.Shared.Infrastructure; + +using ioxide; + +namespace GenHTTP.Engine.Ioxide.Infrastructure; + +/// +/// Builds the engine's once the host has collected the bindings, the handler +/// and the concerns. +/// +/// +/// The base class is GenHTTP's, named the same and qualified here because this one shadows it +/// inside this namespace. +/// +public sealed class ServerHost( + Action? onReactorStart = null, + IoxideOptions? options = null) : Shared.Hosting.ServerHost +{ + + protected override IServer Build(ServerConfiguration config, IHandler handler) + => new Server(config, handler, onReactorStart, options); + +} From 414561d8b5b6a700162ed266691bd64aaea14c16 Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Mon, 17 Aug 2026 00:03:37 +0100 Subject: [PATCH 38/55] refactor(ioxide): EngineOptions, and drop the prefix from the option groups IoxideOptions and its four groups all carried a prefix the namespace already supplies. They are now EngineOptions, ReactorOptions, Http3Options, MutualTlsOptions - and TcpTransportOptions. That last one is not TcpOptions on purpose. ioxide has a TcpOptions of its own, and reaching ours means importing both namespaces: WriteOverflow and Incremental are ioxide's types, so anyone tuning the TCP transport writes `using ioxide;` and gets CS0104 on a name that ordinary. The Playground proved it before the rename was a minute old. Where the engine builds ioxide's, it now says ioxide.TcpOptions outright rather than relying on which namespace wins. --- .../{IoxideOptions.cs => EngineOptions.cs} | 20 +++++++++---------- Engine/Ioxide/Host.cs | 2 +- Engine/Ioxide/Infrastructure/Server.Quic.cs | 4 ++-- Engine/Ioxide/Infrastructure/Server.Tcp.cs | 7 ++++--- Engine/Ioxide/Infrastructure/Server.cs | 8 ++++---- Engine/Ioxide/Infrastructure/ServerHost.cs | 2 +- Playground/Program.cs | 10 +++++----- 7 files changed, 27 insertions(+), 26 deletions(-) rename Engine/Ioxide/{IoxideOptions.cs => EngineOptions.cs} (93%) diff --git a/Engine/Ioxide/IoxideOptions.cs b/Engine/Ioxide/EngineOptions.cs similarity index 93% rename from Engine/Ioxide/IoxideOptions.cs rename to Engine/Ioxide/EngineOptions.cs index bac00de02..6d76de501 100644 --- a/Engine/Ioxide/IoxideOptions.cs +++ b/Engine/Ioxide/EngineOptions.cs @@ -6,9 +6,9 @@ namespace GenHTTP.Engine.Ioxide; /// Protocol and TLS options for the ioxide engine. The port, its certificate and whether it asks /// for a client certificate stay on Bind; which protocols it then serves lives here. /// -public sealed record IoxideOptions +public sealed record EngineOptions { - internal static readonly IoxideOptions Default = new(); + internal static readonly EngineOptions Default = new(); /// /// The protocols every endpoint serves, unless says otherwise. @@ -23,16 +23,16 @@ public sealed record IoxideOptions public Dictionary ProtocolsByPort { get; init; } = []; /// The reactors: how many, and the io_uring machinery each one owns. - public IoxideReactorOptions Reactor { get; init; } = new(); + public ReactorOptions Reactor { get; init; } = new(); /// The TCP endpoints: how TLS is terminated for HTTP/1.1 and HTTP/2. - public IoxideTcpOptions Tcp { get; init; } = new(); + public TcpTransportOptions Tcp { get; init; } = new(); /// The HTTP/3 endpoint: the certificate QUIC serves, and QPACK. - public IoxideHttp3Options Http3 { get; init; } = new(); + public Http3Options Http3 { get; init; } = new(); /// Client certificates: what they are validated against, and whether one is required. - public IoxideMutualTlsOptions MutualTls { get; init; } = new(); + public MutualTlsOptions MutualTls { get; init; } = new(); } /// @@ -40,7 +40,7 @@ public sealed record IoxideOptions /// on it, and shares nothing with the others - so these are per reactor, not per server, and the /// memory they describe is multiplied by . /// -public sealed record IoxideReactorOptions +public sealed record ReactorOptions { /// /// How many reactors to run. One per core suits a server with the machine to itself; anything @@ -77,7 +77,7 @@ public sealed record IoxideReactorOptions /// The TCP endpoints, where OpenSSL terminates TLS for HTTP/1.1 and HTTP/2. HTTP/3 is not /// configured here: QUIC carries its own TLS 1.3 inside ngtcp2, so none of this reaches it. /// -public sealed record IoxideTcpOptions +public sealed record TcpTransportOptions { /// /// Produce TLS records in the kernel (kTLS) on the send path instead of in OpenSSL, which @@ -129,7 +129,7 @@ public sealed record IoxideTcpOptions /// /// The HTTP/3 endpoint. Only consulted when a port serves . /// -public sealed record IoxideHttp3Options +public sealed record Http3Options { /// /// PEM certificate chain for the HTTP/3 listener, as a path. Required to serve HTTP/3, and @@ -159,7 +159,7 @@ public sealed record IoxideHttp3Options /// for HTTP/3, so a bad chain is refused before any request exists. WHICH endpoints ask for one is /// decided per endpoint, by the certificateValidator passed to Bind. /// -public sealed record IoxideMutualTlsOptions +public sealed record MutualTlsOptions { /// /// PEM bundle of trust anchors that client certificates are validated against, as a path. Its diff --git a/Engine/Ioxide/Host.cs b/Engine/Ioxide/Host.cs index 97a78e6ef..3f845d9e5 100644 --- a/Engine/Ioxide/Host.cs +++ b/Engine/Ioxide/Host.cs @@ -19,7 +19,7 @@ public static class Host /// Everything the engine is tuned by: the reactors, the TCP transport, protocols per port, /// the HTTP/3 certificate, mutual TLS and QPACK. Ports and certificates stay on Bind. /// - public static IServerHost Create(Action? onReactorStart = null, IoxideOptions? options = null) + public static IServerHost Create(Action? onReactorStart = null, EngineOptions? options = null) => new ServerHost(onReactorStart, options); } diff --git a/Engine/Ioxide/Infrastructure/Server.Quic.cs b/Engine/Ioxide/Infrastructure/Server.Quic.cs index 4cfc8730e..b716865c3 100644 --- a/Engine/Ioxide/Infrastructure/Server.Quic.cs +++ b/Engine/Ioxide/Infrastructure/Server.Quic.cs @@ -65,7 +65,7 @@ private ServerConfig WithQuic(ServerConfig serverConfig, EndPoint quicEndPoint) _quicEndPoint = quicEndPoint; // Built once here, not in the QuicHandle below - that runs per accepted connection, and - // these two never change. Nghttp3Options is ngtcp2's own record; IoxideHttp3Options is + // these two never change. Nghttp3Options is ngtcp2's own record; Http3Options is // what the caller sets, and this is where the two meet. _h3Options = new Nghttp3Options { @@ -101,7 +101,7 @@ private bool TryResolveQuicCertificate(Shared.Infrastructure.SecurityConfigurati { throw new InvalidOperationException( $"Port {port} serves HTTP/3, which needs a PEM certificate and key on disk - ngtcp2 loads them by path. " - + "Set IoxideOptions.Http3.CertificatePath and Http3.KeyPath to the same certificate bound to that endpoint."); + + "Set EngineOptions.Http3.CertificatePath and Http3.KeyPath to the same certificate bound to that endpoint."); } if (!File.Exists(configuredCert) || !File.Exists(configuredKey)) diff --git a/Engine/Ioxide/Infrastructure/Server.Tcp.cs b/Engine/Ioxide/Infrastructure/Server.Tcp.cs index 81fb33a29..d1b181a2d 100644 --- a/Engine/Ioxide/Infrastructure/Server.Tcp.cs +++ b/Engine/Ioxide/Infrastructure/Server.Tcp.cs @@ -9,8 +9,8 @@ public sealed partial class Server { /// /// The ports that want a TCP listener: those serving HTTP/1.1 or HTTP/2, primary first - it - /// becomes TcpOptions.Port and the rest its ExtraPorts. Empty means this server - /// serves HTTP/3 only and opens no TCP listener at all. + /// becomes ioxide's TcpOptions.Port and the rest its ExtraPorts. Empty means this + /// server serves HTTP/3 only and opens no TCP listener at all. /// /// /// One listener bound to several ports rather than one per port: a connection carries the port @@ -33,7 +33,8 @@ private ushort[] ResolveTcpPorts() /// private ServerConfig WithTcp(ServerConfig serverConfig) => serverConfig with { - Tcp = new TcpOptions + // ioxide's, not ours - the engine's own TcpTransportOptions is what feeds it below. + Tcp = new ioxide.TcpOptions { Port = _tcpRequested[0], ExtraPorts = _tcpRequested[1..], diff --git a/Engine/Ioxide/Infrastructure/Server.cs b/Engine/Ioxide/Infrastructure/Server.cs index e44fd9874..25cfc9799 100644 --- a/Engine/Ioxide/Infrastructure/Server.cs +++ b/Engine/Ioxide/Infrastructure/Server.cs @@ -41,7 +41,7 @@ public sealed partial class Server : IServer private readonly Action? _onReactorStart; - private readonly IoxideOptions _options; + private readonly EngineOptions _options; private readonly ILogger _logger; @@ -67,12 +67,12 @@ internal Server( ServerConfiguration config, IHandler handler, Action? onReactorStart = null, - IoxideOptions? options = null) + EngineOptions? options = null) { _config = config; Handler = handler; _onReactorStart = onReactorStart; - _options = options ?? IoxideOptions.Default; + _options = options ?? EngineOptions.Default; _logger = config.Logging.CreateLogger(); @@ -246,7 +246,7 @@ private async ValueTask PrepareHandlerAsync() /// /// What one port serves: the default, its override, and the endpoint's own enableQuic flag. /// - private static IoxideProtocols ResolveProtocols(IoxideOptions options, ServerConfiguration config, ushort port) + private static IoxideProtocols ResolveProtocols(EngineOptions options, ServerConfiguration config, ushort port) { var named = options.ProtocolsByPort.TryGetValue(port, out var configured); diff --git a/Engine/Ioxide/Infrastructure/ServerHost.cs b/Engine/Ioxide/Infrastructure/ServerHost.cs index 67633d7a6..987c156c7 100644 --- a/Engine/Ioxide/Infrastructure/ServerHost.cs +++ b/Engine/Ioxide/Infrastructure/ServerHost.cs @@ -16,7 +16,7 @@ namespace GenHTTP.Engine.Ioxide.Infrastructure; /// public sealed class ServerHost( Action? onReactorStart = null, - IoxideOptions? options = null) : Shared.Hosting.ServerHost + EngineOptions? options = null) : Shared.Hosting.ServerHost { protected override IServer Build(ServerConfiguration config, IHandler handler) diff --git a/Playground/Program.cs b/Playground/Program.cs index e78151c15..7ab4601aa 100644 --- a/Playground/Program.cs +++ b/Playground/Program.cs @@ -92,12 +92,12 @@ var clientCa = WriteClientCertificates(); await Host.Create( - options: new IoxideOptions + options: new EngineOptions { // What a port serves unless named below. Protocols = IoxideProtocols.Http1, - Reactor = new IoxideReactorOptions + Reactor = new ReactorOptions { ReactorCount = 2, }, @@ -110,7 +110,7 @@ await Host.Create( [8444] = IoxideProtocols.Http1, }, - Tcp = new IoxideTcpOptions + Tcp = new TcpTransportOptions { // Hand the TLS record layer to the kernel instead of OpenSSL, which still // performs the handshake. Both off because kTLS needs the Linux tls module @@ -131,7 +131,7 @@ await Host.Create( RecvQueueEntries = 64, }, - MutualTls = new IoxideMutualTlsOptions + MutualTls = new MutualTlsOptions { // What an offered client certificate is validated against. WHICH ports ask // for one is decided per endpoint, by the validator passed to Bind - so 8443 @@ -139,7 +139,7 @@ await Host.Create( ClientCaPath = clientCa, }, - Http3 = new IoxideHttp3Options + Http3 = new Http3Options { // Bytes of QPACK dynamic table offered to HTTP/3 clients. 0 keeps every // header literal, which costs bytes but can never stall a stream on a table From c1c9afba50cb737ec3abe7edae32cdcb14df5359 Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Mon, 17 Aug 2026 00:33:06 +0100 Subject: [PATCH 39/55] refactor(ioxide): put the TLS handshake with its caller, drop the Tls folder IoxideTls was a folder holding one four-line method with one caller, under the prefix every other type has now shed. Its two halves belonged in different places, which is why neither fit where it was: AcceptWithAlpnAsync is per-connection work on the reactor thread, and establishing a connection's transport is exactly what ConnectionDriver is for. It is now a private AcceptTlsAsync there, beside the plaintext branch it is the alternative to. TlsRegistry joins Server.Tcp.Tls.cs, where ResolveTls produces what fills it. Not Server.Tcp.Tls for the handshake: that file is a partial of Server holding startup configuration - instance members that run once per reactor in OnStart. Terminating a connection has no server, and putting it there would have the connection driver calling into Server for a transport primitive. --- .../Ioxide/Infrastructure/Server.Tcp.Tls.cs | 15 ++++++++ Engine/Ioxide/Infrastructure/Server.cs | 1 + Engine/Ioxide/Protocol/ConnectionDriver.cs | 17 ++++++++- Engine/Ioxide/Tls/IoxideTls.cs | 37 ------------------- 4 files changed, 32 insertions(+), 38 deletions(-) delete mode 100644 Engine/Ioxide/Tls/IoxideTls.cs diff --git a/Engine/Ioxide/Infrastructure/Server.Tcp.Tls.cs b/Engine/Ioxide/Infrastructure/Server.Tcp.Tls.cs index c120524ee..3826eb907 100644 --- a/Engine/Ioxide/Infrastructure/Server.Tcp.Tls.cs +++ b/Engine/Ioxide/Infrastructure/Server.Tcp.Tls.cs @@ -71,3 +71,18 @@ private static string ExportKeyPem(X509Certificate2 certificate) ?? certificate.GetECDsaPrivateKey()?.ExportPkcs8PrivateKeyPem() ?? throw new InvalidOperationException("The certificate carries no exportable RSA or ECDSA private key."); } + +/// +/// The TLS service each secure port owns on this reactor, keyed by the port a connection arrived +/// on. One per port, since ALPN and the client CA differ per endpoint; filled from +/// when the reactor starts, and read per connection by the +/// connection driver. +/// +internal sealed class TlsRegistry +{ + private readonly Dictionary _byPort = []; + + public void Add(ushort port, TlsService service) => _byPort[port] = service; + + public bool TryFor(ushort port, out TlsService service) => _byPort.TryGetValue(port, out service!); +} diff --git a/Engine/Ioxide/Infrastructure/Server.cs b/Engine/Ioxide/Infrastructure/Server.cs index 25cfc9799..09db57256 100644 --- a/Engine/Ioxide/Infrastructure/Server.cs +++ b/Engine/Ioxide/Infrastructure/Server.cs @@ -183,6 +183,7 @@ public async ValueTask StartAsync() } _onReactorStart?.Invoke(r); + listening.Signal(); }, TcpHandle = (_, tcpConnection) => diff --git a/Engine/Ioxide/Protocol/ConnectionDriver.cs b/Engine/Ioxide/Protocol/ConnectionDriver.cs index 3eec2789f..61eae443c 100644 --- a/Engine/Ioxide/Protocol/ConnectionDriver.cs +++ b/Engine/Ioxide/Protocol/ConnectionDriver.cs @@ -5,9 +5,12 @@ using GenHTTP.Api.Infrastructure; +using GenHTTP.Engine.Ioxide.Infrastructure; using GenHTTP.Engine.Ioxide.Protocol.Http1; using GenHTTP.Engine.Ioxide.Protocol.Multiplexed; +using ioxide.tls; + using IoConnection = ioxide.TcpConnection; namespace GenHTTP.Engine.Ioxide.Protocol; @@ -58,7 +61,7 @@ internal static async Task HandleAsync(IServer server, IEndPoint endPoint, IoCon return; } - (pipe, negotiated) = await IoxideTls.AcceptWithAlpnAsync(conn, service); + (pipe, negotiated) = await AcceptTlsAsync(conn, service); } else { @@ -115,6 +118,18 @@ internal static async Task HandleAsync(IServer server, IEndPoint endPoint, IoCon await Http1Driver.RunAsync(server, endPoint, pipe, conn, remoteAddress); } + /// + /// Terminates TLS and reports what ALPN settled on, which is how a port serving several + /// protocols knows which one this connection speaks. Null means the client offered nothing the + /// port lists, and HTTP/1.1 is assumed. + /// + private static async ValueTask<(IDuplexPipe Pipe, string? Protocol)> AcceptTlsAsync(IoConnection conn, TlsService service) + { + var session = await service.AcceptAsync(conn); + + return (new TlsConnectionDualPipe(conn, session), session.NegotiatedAlpn); + } + /// /// Peeks for the HTTP/2 connection preface without consuming it, so a plaintext client using /// prior knowledge (h2c) is recognised and the same bytes are handed to the HTTP/2 layer. diff --git a/Engine/Ioxide/Tls/IoxideTls.cs b/Engine/Ioxide/Tls/IoxideTls.cs deleted file mode 100644 index b6ea3b885..000000000 --- a/Engine/Ioxide/Tls/IoxideTls.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System.IO.Pipelines; - -using ioxide; -using ioxide.tls; - -namespace GenHTTP.Engine.Ioxide; - -/// -/// TLS termination for the endpoints bound with a certificate. -/// -internal static class IoxideTls -{ - /// - /// Terminates TLS and reports what ALPN settled on, which is how a port serving several - /// protocols knows which one this connection speaks. Null means the client offered nothing the - /// port lists, and HTTP/1.1 is assumed. - /// - internal static async ValueTask<(IDuplexPipe Pipe, string? Protocol)> AcceptWithAlpnAsync(TcpConnection conn, TlsService service) - { - var session = await service.AcceptAsync(conn); - - return (new TlsConnectionDualPipe(conn, session), session.NegotiatedAlpn); - } -} - -/// -/// The TLS service each secure port owns on this reactor. One per port, since ALPN and the client -/// CA differ per endpoint; resolved by the listener port a connection arrived on. -/// -internal sealed class TlsRegistry -{ - private readonly Dictionary _byPort = []; - - public void Add(ushort port, TlsService service) => _byPort[port] = service; - - public bool TryFor(ushort port, out TlsService service) => _byPort.TryGetValue(port, out service!); -} From 3f83dc8f523cf94c7964cd598c5d130e67c13bdd Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Mon, 17 Aug 2026 10:51:33 +0100 Subject: [PATCH 40/55] refactor(ioxide): name the TCP field for what it holds, and stop storing QUIC twice _tcpRequested reads like a boolean - it was named for symmetry with _quicRequested - but it holds the ports WithTcp binds, so it is _tcpPorts. The symmetry it was named for turned out to be a duplicate anyway. _quicRequested was resolved in the constructor and _quicEndPoint assigned the same value again inside WithQuic, so the two always agreed and only one was needed. The field now lives with the rest of the QUIC state, is readonly, and WithQuic reads it instead of being handed it - the mirror of WithTcp reading _tcpPorts. if (_tcpPorts.Length > 0) serverConfig = WithTcp(serverConfig); if (_quicEndPoint is not null) serverConfig = WithQuic(serverConfig); --- Engine/Ioxide/Infrastructure/Server.Quic.cs | 9 +++++---- Engine/Ioxide/Infrastructure/Server.Tcp.cs | 6 +++--- Engine/Ioxide/Infrastructure/Server.cs | 14 ++++++-------- 3 files changed, 14 insertions(+), 15 deletions(-) diff --git a/Engine/Ioxide/Infrastructure/Server.Quic.cs b/Engine/Ioxide/Infrastructure/Server.Quic.cs index b716865c3..4d59ad37a 100644 --- a/Engine/Ioxide/Infrastructure/Server.Quic.cs +++ b/Engine/Ioxide/Infrastructure/Server.Quic.cs @@ -17,7 +17,8 @@ public sealed partial class Server { private QuicEngine? _quicEngine; - private EndPoint? _quicEndPoint; + /// The endpoint serving HTTP/3, or null. Resolved in the constructor. + private readonly EndPoint? _quicEndPoint; private Nghttp3Options? _h3Options; @@ -45,8 +46,10 @@ public sealed partial class Server /// carries TLS 1.3 and has no cleartext mode - and takes its port, which is what a browser /// assumes when an Alt-Svc advertisement names none of its own. /// - private ServerConfig WithQuic(ServerConfig serverConfig, EndPoint quicEndPoint) + private ServerConfig WithQuic(ServerConfig serverConfig) { + var quicEndPoint = _quicEndPoint!; + if (!_secure.TryGetValue(quicEndPoint.Port, out var security)) { _logger.LogWarning("HTTP/3 was requested on port {Port}, which is not bound with a certificate; QUIC has no cleartext mode, so no listener was started.", quicEndPoint.Port); @@ -62,8 +65,6 @@ private ServerConfig WithQuic(ServerConfig serverConfig, EndPoint quicEndPoint) clientCaPemPath: _options.MutualTls.ClientCaPath, requireClientCertificate: RequiresClientCertificate(security)); - _quicEndPoint = quicEndPoint; - // Built once here, not in the QuicHandle below - that runs per accepted connection, and // these two never change. Nghttp3Options is ngtcp2's own record; Http3Options is // what the caller sets, and this is where the two meet. diff --git a/Engine/Ioxide/Infrastructure/Server.Tcp.cs b/Engine/Ioxide/Infrastructure/Server.Tcp.cs index d1b181a2d..a4ba7121b 100644 --- a/Engine/Ioxide/Infrastructure/Server.Tcp.cs +++ b/Engine/Ioxide/Infrastructure/Server.Tcp.cs @@ -24,7 +24,7 @@ private ushort[] ResolveTcpPorts() .ToArray(); /// - /// Adds the TCP listener for the ports resolved into _tcpRequested. Only called when + /// Adds the TCP listener for the ports resolved into _tcpPorts. Only called when /// there are any - an HTTP/3-only server gets a UDP socket and no TCP listener at all. /// /// @@ -36,8 +36,8 @@ private ServerConfig WithTcp(ServerConfig serverConfig) => serverConfig with // ioxide's, not ours - the engine's own TcpTransportOptions is what feeds it below. Tcp = new ioxide.TcpOptions { - Port = _tcpRequested[0], - ExtraPorts = _tcpRequested[1..], + Port = _tcpPorts[0], + ExtraPorts = _tcpPorts[1..], ListenBacklog = _options.Tcp.ListenBacklog, WriteSlabSize = _options.Tcp.WriteSlabSize, diff --git a/Engine/Ioxide/Infrastructure/Server.cs b/Engine/Ioxide/Infrastructure/Server.cs index 09db57256..7ff7a5ae1 100644 --- a/Engine/Ioxide/Infrastructure/Server.cs +++ b/Engine/Ioxide/Infrastructure/Server.cs @@ -33,9 +33,7 @@ public sealed partial class Server : IServer private readonly Dictionary _secure; - private readonly ushort[] _tcpRequested; - - private readonly EndPoint? _quicRequested; + private readonly ushort[] _tcpPorts; private readonly Dictionary _protocols; @@ -85,8 +83,8 @@ internal Server( // Which endpoints want which listener, settled here so StartAsync only has to act on it. // Order matters: both read _protocols, and the TCP one reads _primary as well. - _tcpRequested = ResolveTcpPorts(); - _quicRequested = ResolveQuicEndPoint(mapped); + _tcpPorts = ResolveTcpPorts(); + _quicEndPoint = ResolveQuicEndPoint(mapped); // Certificates are resolved per reactor in OnStart, not here - see ResolveTls. _secure = config.EndPoints @@ -144,14 +142,14 @@ public async ValueTask StartAsync() var serverConfig = BuildServerConfig(); - if (_tcpRequested.Length > 0) + if (_tcpPorts.Length > 0) { serverConfig = WithTcp(serverConfig); } - if (_quicRequested is { } quicEndPoint) + if (_quicEndPoint is not null) { - serverConfig = WithQuic(serverConfig, quicEndPoint); + serverConfig = WithQuic(serverConfig); } _threads = new Thread[serverConfig.ReactorCount]; From 20ac31cd3f1ef5537cd952086fc036a928d6cf7a Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Mon, 17 Aug 2026 11:01:19 +0100 Subject: [PATCH 41/55] docs(ioxide): say what the dual-stack check is for, and name what disagreed The check reads as an arbitrary refusal without the mismatch behind it: GenHTTP takes dual-stack per endpoint on Bind, ioxide takes one flag for the whole server, and the engine honours the first endpoint's. Endpoints that disagree would otherwise be served a mode they did not ask for, silently. The comparison value is hoisted, so it reads as all-against-the-first rather than something pairwise, and the message now names the first endpoint, the mode taken from it, and the ports that wanted the other: The ioxide engine binds every endpoint with one dual-stack mode, taken from the first one bound (port 8080, DualStack = True). These ask for the other: 8081, 8082. --- Engine/Ioxide/Infrastructure/Server.Tcp.cs | 2 +- Engine/Ioxide/Infrastructure/Server.cs | 30 +++++++++++++++------- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/Engine/Ioxide/Infrastructure/Server.Tcp.cs b/Engine/Ioxide/Infrastructure/Server.Tcp.cs index a4ba7121b..70dd9e109 100644 --- a/Engine/Ioxide/Infrastructure/Server.Tcp.cs +++ b/Engine/Ioxide/Infrastructure/Server.Tcp.cs @@ -34,7 +34,7 @@ private ushort[] ResolveTcpPorts() private ServerConfig WithTcp(ServerConfig serverConfig) => serverConfig with { // ioxide's, not ours - the engine's own TcpTransportOptions is what feeds it below. - Tcp = new ioxide.TcpOptions + Tcp = new TcpOptions { Port = _tcpPorts[0], ExtraPorts = _tcpPorts[1..], diff --git a/Engine/Ioxide/Infrastructure/Server.cs b/Engine/Ioxide/Infrastructure/Server.cs index 7ff7a5ae1..7b94153f0 100644 --- a/Engine/Ioxide/Infrastructure/Server.cs +++ b/Engine/Ioxide/Infrastructure/Server.cs @@ -74,39 +74,51 @@ internal Server( _logger = config.Logging.CreateLogger(); - var mapped = MapEndPoints(config); + var mappedEndpoints = MapEndPoints(config); - _primary = mapped[0]; - _endPointByPort = mapped.ToDictionary(e => e.Port); + _primary = mappedEndpoints[0]; + _endPointByPort = mappedEndpoints.ToDictionary(e => e.Port); - _protocols = mapped.ToDictionary(e => e.Port, e => ResolveProtocols(_options, config, e.Port)); + _protocols = mappedEndpoints.ToDictionary(e => e.Port, e => ResolveProtocols(_options, config, e.Port)); // Which endpoints want which listener, settled here so StartAsync only has to act on it. // Order matters: both read _protocols, and the TCP one reads _primary as well. _tcpPorts = ResolveTcpPorts(); - _quicEndPoint = ResolveQuicEndPoint(mapped); + _quicEndPoint = ResolveQuicEndPoint(mappedEndpoints); // Certificates are resolved per reactor in OnStart, not here - see ResolveTls. _secure = config.EndPoints .Where(e => e.Security is not null) .ToDictionary(e => e.Port, e => e.Security!); - EndPoints = new EndPointCollection(mapped.Cast().ToList()); + EndPoints = new EndPointCollection(mappedEndpoints.Cast().ToList()); } /// /// GenHTTP's endpoints as the engine's own, which is also where the one thing every endpoint - /// must agree on is checked: ioxide binds the whole server with a single dual-stack mode. + /// must agree on is checked. /// + /// + /// GenHTTP takes dual-stack per endpoint, on Bind; ioxide takes one flag for the whole + /// server, deciding whether listeners are IPv6 on :: with V6ONLY off or plain IPv4 on 0.0.0.0. + /// The engine can only honour one, and honours the first endpoint's - so endpoints that + /// disagree are refused here rather than silently served the first one's mode. + /// private static List MapEndPoints(ServerConfiguration config) { var mapped = config.EndPoints .Select(e => new EndPoint(e.Address, e.Port, e.DualStack, e.Security != null)) .ToList(); - if (mapped.Any(e => e.DualStack != mapped[0].DualStack)) + var dualStack = mapped[0].DualStack; + + if (mapped.Any(e => e.DualStack != dualStack)) { - throw new NotSupportedException("The ioxide engine binds all endpoints with one dual-stack mode."); + var disagreeing = mapped.Where(e => e.DualStack != dualStack).Select(e => e.Port); + + throw new NotSupportedException( + $"The ioxide engine binds every endpoint with one dual-stack mode, taken from the first one bound " + + $"(port {mapped[0].Port}, DualStack = {dualStack}). These ask for the other: {string.Join(", ", disagreeing)}."); } return mapped; From 30d2ad5ae087bafc2dcd3eedd8d1c2f794381ec7 Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Mon, 17 Aug 2026 11:28:38 +0100 Subject: [PATCH 42/55] refactor(ioxide): let an endpoint carry its own security EndPoint held a `secure` bool while the server kept a second table with the SecurityConfiguration behind it, keyed by port - two representations of one fact, one of them carrying the payload. The endpoint now holds the configuration and Secure is derived from it, so _secure is gone. WithQuic is the clearest gain: it already held the endpoint and was looking its own security up by port. It reads quicEndPoint.Security now. Not SecureEndPoint/InsecureEndPoint as the Internal engine has them - there the subclasses do the work (SecureEndPoint owns the SslStream handshake and the validation callback), here the endpoint is passive and TLS happens in the connection driver, so subclassing would add two types with no behaviour. --- Engine/Ioxide/EngineOptions.cs | 6 ++-- .../Infrastructure/Endpoints/EndPoint.cs | 18 +++++++--- Engine/Ioxide/Infrastructure/Server.Quic.cs | 4 +-- .../Ioxide/Infrastructure/Server.Tcp.Tls.cs | 17 ++++++--- Engine/Ioxide/Infrastructure/Server.Tcp.cs | 2 +- Engine/Ioxide/Infrastructure/Server.cs | 35 ++++++++----------- Engine/Ioxide/Protocol/ConnectionDriver.cs | 6 ++-- .../{IoxideProtocols.cs => Protocols.cs} | 2 +- 8 files changed, 50 insertions(+), 40 deletions(-) rename Engine/Ioxide/{IoxideProtocols.cs => Protocols.cs} (98%) diff --git a/Engine/Ioxide/EngineOptions.cs b/Engine/Ioxide/EngineOptions.cs index 6d76de501..eeb759e04 100644 --- a/Engine/Ioxide/EngineOptions.cs +++ b/Engine/Ioxide/EngineOptions.cs @@ -14,13 +14,13 @@ public sealed record EngineOptions /// The protocols every endpoint serves, unless says otherwise. /// An endpoint bound with enableQuic serves HTTP/3 whatever is set here. /// - public IoxideProtocols Protocols { get; init; } = IoxideProtocols.Http1; + public Protocols Protocols { get; init; } = Protocols.Http1; /// /// Protocols for one port, overriding - bind the ports, then name the /// ones that differ: { [8081] = IoxideProtocols.Http2, [8443] = IoxideProtocols.All }. /// - public Dictionary ProtocolsByPort { get; init; } = []; + public Dictionary ProtocolsByPort { get; init; } = []; /// The reactors: how many, and the io_uring machinery each one owns. public ReactorOptions Reactor { get; init; } = new(); @@ -127,7 +127,7 @@ public sealed record TcpTransportOptions } /// -/// The HTTP/3 endpoint. Only consulted when a port serves . +/// The HTTP/3 endpoint. Only consulted when a port serves . /// public sealed record Http3Options { diff --git a/Engine/Ioxide/Infrastructure/Endpoints/EndPoint.cs b/Engine/Ioxide/Infrastructure/Endpoints/EndPoint.cs index b24925445..0cea7b329 100644 --- a/Engine/Ioxide/Infrastructure/Endpoints/EndPoint.cs +++ b/Engine/Ioxide/Infrastructure/Endpoints/EndPoint.cs @@ -2,13 +2,20 @@ using GenHTTP.Api.Infrastructure; +using GenHTTP.Engine.Shared.Infrastructure; + namespace GenHTTP.Engine.Ioxide.Infrastructure.Endpoints; /// -/// One bound endpoint, as the engine sees it. Nothing to dispose: the listener belongs to the -/// reactors, which bind it themselves and tear it down with their rings. +/// One bound endpoint, as the engine sees it: where it listens, and what secures it. /// -internal sealed class EndPoint(IPAddress? address, ushort port, bool dualStack, bool secure) : IEndPoint +/// +/// carries the binding's certificate provider, protocols and client +/// certificate validator, so an endpoint answers for its own TLS rather than the server keeping a +/// second table keyed by port. Nothing to dispose: the listener belongs to the reactors, which bind +/// it themselves and tear it down with their rings. +/// +internal sealed class EndPoint(IPAddress? address, ushort port, bool dualStack, SecurityConfiguration? security) : IEndPoint { public IPAddress? Address => address; @@ -16,7 +23,10 @@ internal sealed class EndPoint(IPAddress? address, ushort port, bool dualStack, public bool DualStack => dualStack; - public bool Secure => secure; + /// How this endpoint is secured, or null for a plaintext one. + public SecurityConfiguration? Security => security; + + public bool Secure => security is not null; public void Dispose() { } } diff --git a/Engine/Ioxide/Infrastructure/Server.Quic.cs b/Engine/Ioxide/Infrastructure/Server.Quic.cs index 4d59ad37a..1f7eaba55 100644 --- a/Engine/Ioxide/Infrastructure/Server.Quic.cs +++ b/Engine/Ioxide/Infrastructure/Server.Quic.cs @@ -29,7 +29,7 @@ public sealed partial class Server /// private EndPoint? ResolveQuicEndPoint(List mapped) { - var quicEndPoints = mapped.Where(e => _protocols[e.Port].HasFlag(IoxideProtocols.Http3)).ToList(); + var quicEndPoints = mapped.Where(e => _protocols[e.Port].HasFlag(Protocols.Http3)).ToList(); if (quicEndPoints.Count > 1) { @@ -50,7 +50,7 @@ private ServerConfig WithQuic(ServerConfig serverConfig) { var quicEndPoint = _quicEndPoint!; - if (!_secure.TryGetValue(quicEndPoint.Port, out var security)) + if (quicEndPoint.Security is not { } security) { _logger.LogWarning("HTTP/3 was requested on port {Port}, which is not bound with a certificate; QUIC has no cleartext mode, so no listener was started.", quicEndPoint.Port); return serverConfig; diff --git a/Engine/Ioxide/Infrastructure/Server.Tcp.Tls.cs b/Engine/Ioxide/Infrastructure/Server.Tcp.Tls.cs index 3826eb907..433016c1c 100644 --- a/Engine/Ioxide/Infrastructure/Server.Tcp.Tls.cs +++ b/Engine/Ioxide/Infrastructure/Server.Tcp.Tls.cs @@ -1,5 +1,6 @@ using System.Security.Cryptography.X509Certificates; +using GenHTTP.Engine.Ioxide.Infrastructure.Endpoints; using GenHTTP.Engine.Shared.Infrastructure; using ioxide.tls; @@ -20,9 +21,11 @@ public sealed partial class Server /// private IEnumerable> ResolveTls() { - foreach (var (port, security) in _secure) + foreach (var endPoint in SecureEndPoints) { - if (security.CertificateProvider.Provide(null) is not { } certificate) + var port = endPoint.Port; + + if (endPoint.Security!.CertificateProvider.Provide(null) is not { } certificate) { _logger.LogWarning("No default certificate for secure port {Port}; handshakes there will be refused (SNI selection is unsupported).", port); continue; @@ -35,11 +38,11 @@ private IEnumerable> ResolveTls() // Server preference, most preferred first. A client offering neither continues // without an ALPN extension at all. - Alpn = ProtocolsFor(port).HasFlag(IoxideProtocols.Http2) ? ["h2", "http/1.1"] : ["http/1.1"], + Alpn = ProtocolsFor(port).HasFlag(Protocols.Http2) ? ["h2", "http/1.1"] : ["http/1.1"], ClientCaPath = _options.MutualTls.ClientCaPath, ClientCaPem = _options.MutualTls.ClientCaPem, - RequireClientCertificate = RequiresClientCertificate(security), + RequireClientCertificate = RequiresClientCertificate(endPoint.Security), KernelTx = _options.Tcp.TxKernelTls, KernelRx = _options.Tcp.RxKernelTls @@ -64,7 +67,11 @@ private bool RequiresClientCertificate(SecurityConfiguration security) /// private bool MutualTlsConfigured => _options.MutualTls.ClientCaPath is not null || _options.MutualTls.ClientCaPem is not null - || _options.MutualTls.RequireClientCertificate || _secure.Values.Any(s => s.CertificateValidator is not null); + || _options.MutualTls.RequireClientCertificate + || SecureEndPoints.Any(e => e.Security!.CertificateValidator is not null); + + /// The endpoints bound with a certificate - the ones TLS applies to. + private IEnumerable SecureEndPoints => _endPointByPort.Values.Where(e => e.Security is not null); private static string ExportKeyPem(X509Certificate2 certificate) => certificate.GetRSAPrivateKey()?.ExportPkcs8PrivateKeyPem() diff --git a/Engine/Ioxide/Infrastructure/Server.Tcp.cs b/Engine/Ioxide/Infrastructure/Server.Tcp.cs index 70dd9e109..d11131cc7 100644 --- a/Engine/Ioxide/Infrastructure/Server.Tcp.cs +++ b/Engine/Ioxide/Infrastructure/Server.Tcp.cs @@ -18,7 +18,7 @@ public sealed partial class Server /// protocols. /// private ushort[] ResolveTcpPorts() - => _protocols.Where(p => (p.Value & IoxideProtocols.Http1AndHttp2) != 0) + => _protocols.Where(p => (p.Value & Protocols.Http1AndHttp2) != 0) .Select(p => p.Key) .OrderBy(p => p == _primary.Port ? 0 : 1) .ToArray(); diff --git a/Engine/Ioxide/Infrastructure/Server.cs b/Engine/Ioxide/Infrastructure/Server.cs index 7b94153f0..edd57d7a2 100644 --- a/Engine/Ioxide/Infrastructure/Server.cs +++ b/Engine/Ioxide/Infrastructure/Server.cs @@ -31,11 +31,9 @@ public sealed partial class Server : IServer private readonly Dictionary _endPointByPort; - private readonly Dictionary _secure; - private readonly ushort[] _tcpPorts; - private readonly Dictionary _protocols; + private readonly Dictionary _protocols; private readonly Action? _onReactorStart; @@ -86,11 +84,6 @@ internal Server( _tcpPorts = ResolveTcpPorts(); _quicEndPoint = ResolveQuicEndPoint(mappedEndpoints); - // Certificates are resolved per reactor in OnStart, not here - see ResolveTls. - _secure = config.EndPoints - .Where(e => e.Security is not null) - .ToDictionary(e => e.Port, e => e.Security!); - EndPoints = new EndPointCollection(mappedEndpoints.Cast().ToList()); } @@ -107,7 +100,7 @@ internal Server( private static List MapEndPoints(ServerConfiguration config) { var mapped = config.EndPoints - .Select(e => new EndPoint(e.Address, e.Port, e.DualStack, e.Security != null)) + .Select(e => new EndPoint(e.Address, e.Port, e.DualStack, e.Security)) .ToList(); var dualStack = mapped[0].DualStack; @@ -180,7 +173,7 @@ public async ValueTask StartAsync() { IoxideReactor.Bind(r); - if (_secure.Count > 0) + if (SecureEndPoints.Any()) { var registry = new TlsRegistry(); @@ -257,7 +250,7 @@ private async ValueTask PrepareHandlerAsync() /// /// What one port serves: the default, its override, and the endpoint's own enableQuic flag. /// - private static IoxideProtocols ResolveProtocols(EngineOptions options, ServerConfiguration config, ushort port) + private static Protocols ResolveProtocols(EngineOptions options, ServerConfiguration config, ushort port) { var named = options.ProtocolsByPort.TryGetValue(port, out var configured); @@ -266,14 +259,14 @@ private static IoxideProtocols ResolveProtocols(EngineOptions options, ServerCon // HTTP/3 from the DEFAULT applies only where it can, so Protocols = All means "everything // each port supports" rather than an error about the plaintext one. Named per port it is // taken literally, and refused where the port has no certificate. - if (!named && protocols.HasFlag(IoxideProtocols.Http3) && config.EndPoints.All(e => e.Port != port || e.Security is null)) + if (!named && protocols.HasFlag(Protocols.Http3) && config.EndPoints.All(e => e.Port != port || e.Security is null)) { - protocols &= ~IoxideProtocols.Http3; + protocols &= ~Protocols.Http3; } if (config.EndPoints.Any(e => e.Port == port && e.EnableQuic)) { - protocols |= IoxideProtocols.Http3; + protocols |= Protocols.Http3; } if (protocols == 0) @@ -285,25 +278,25 @@ private static IoxideProtocols ResolveProtocols(EngineOptions options, ServerCon } /// The protocols this port serves. - private IoxideProtocols ProtocolsFor(ushort port) - => _protocols.TryGetValue(port, out var protocols) ? protocols : IoxideProtocols.Http1; + private Protocols ProtocolsFor(ushort port) + => _protocols.TryGetValue(port, out var protocols) ? protocols : Protocols.Http1; private string DescribeSettings() { var protocols = string.Join(" ", _protocols.OrderBy(p => p.Key).Select(p => $"{p.Key}:{Describe(p.Value)}")); - return $"ioxide, {protocols}, TLS on {_secure.Count}" + return $"ioxide, {protocols}, TLS on {SecureEndPoints.Count()}" + (MutualTlsConfigured ? ", mTLS" : string.Empty) + $", DualStack: {_primary.DualStack}, Reactors: {_reactors?.Length ?? 0}"; } - private static string Describe(IoxideProtocols protocols) + private static string Describe(Protocols protocols) { var names = new List(3); - if (protocols.HasFlag(IoxideProtocols.Http1)) names.Add("h1"); - if (protocols.HasFlag(IoxideProtocols.Http2)) names.Add("h2"); - if (protocols.HasFlag(IoxideProtocols.Http3)) names.Add("h3"); + if (protocols.HasFlag(Protocols.Http1)) names.Add("h1"); + if (protocols.HasFlag(Protocols.Http2)) names.Add("h2"); + if (protocols.HasFlag(Protocols.Http3)) names.Add("h3"); return string.Join("+", names); } diff --git a/Engine/Ioxide/Protocol/ConnectionDriver.cs b/Engine/Ioxide/Protocol/ConnectionDriver.cs index 61eae443c..efad5248a 100644 --- a/Engine/Ioxide/Protocol/ConnectionDriver.cs +++ b/Engine/Ioxide/Protocol/ConnectionDriver.cs @@ -40,7 +40,7 @@ internal static partial class ConnectionDriver private static readonly ReadOnlyMemory Preface = "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"u8.ToArray(); internal static async Task HandleAsync(IServer server, IEndPoint endPoint, IoConnection conn, - IoxideProtocols protocols = IoxideProtocols.Http1) + Protocols protocols = Protocols.Http1) { IDuplexPipe pipe; @@ -77,8 +77,8 @@ internal static async Task HandleAsync(IServer server, IEndPoint endPoint, IoCon var remoteAddress = GetPeerAddress(conn.ClientFd); - var http2 = protocols.HasFlag(IoxideProtocols.Http2); - var http1 = protocols.HasFlag(IoxideProtocols.Http1); + var http2 = protocols.HasFlag(Protocols.Http2); + var http1 = protocols.HasFlag(Protocols.Http1); // Only worth peeking when both share the port: elsewhere the answer is already known. var isHttp2 = http2 && (negotiated == "h2" || (negotiated is null && http1 && await StartsWithPrefaceAsync(pipe.Input))); diff --git a/Engine/Ioxide/IoxideProtocols.cs b/Engine/Ioxide/Protocols.cs similarity index 98% rename from Engine/Ioxide/IoxideProtocols.cs rename to Engine/Ioxide/Protocols.cs index 7de96cbda..735bfd464 100644 --- a/Engine/Ioxide/IoxideProtocols.cs +++ b/Engine/Ioxide/Protocols.cs @@ -6,7 +6,7 @@ namespace GenHTTP.Engine.Ioxide; /// port number, which is what lets one endpoint serve all three. /// [Flags] -public enum IoxideProtocols +public enum Protocols { /// HTTP/1.1 over TCP. Http1 = 1, From d483324275659441525f26f228a54cee8a74edbb Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Mon, 17 Aug 2026 12:00:44 +0100 Subject: [PATCH 43/55] fix(ioxide): finish the prefix rename where it was left half done 859fab48 dropped the Ioxide prefix from the infrastructure types, but two callers still named the old one. Playground/Program.cs set Protocols and ProtocolsByPort against IoxideProtocols, so the playground did not compile at all from that commit onward - the rename was verified against the engine, which built fine, and not against the sample that consumes it. The ProtocolsByPort summary carried the same stale name in its example. Nothing reads a doc comment, so it built either way and pointed at a type that no longer exists. --- Engine/Ioxide/EngineOptions.cs | 2 +- Playground/Program.cs | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Engine/Ioxide/EngineOptions.cs b/Engine/Ioxide/EngineOptions.cs index eeb759e04..452450eab 100644 --- a/Engine/Ioxide/EngineOptions.cs +++ b/Engine/Ioxide/EngineOptions.cs @@ -18,7 +18,7 @@ public sealed record EngineOptions /// /// Protocols for one port, overriding - bind the ports, then name the - /// ones that differ: { [8081] = IoxideProtocols.Http2, [8443] = IoxideProtocols.All }. + /// ones that differ: { [8081] = Protocols.Http2, [8443] = Protocols.All }. /// public Dictionary ProtocolsByPort { get; init; } = []; diff --git a/Playground/Program.cs b/Playground/Program.cs index 7ab4601aa..7f6732ffa 100644 --- a/Playground/Program.cs +++ b/Playground/Program.cs @@ -95,7 +95,7 @@ await Host.Create( options: new EngineOptions { // What a port serves unless named below. - Protocols = IoxideProtocols.Http1, + Protocols = Protocols.Http1, Reactor = new ReactorOptions { @@ -104,10 +104,10 @@ await Host.Create( ProtocolsByPort = { - [8081] = IoxideProtocols.Http2, - [8082] = IoxideProtocols.Http1AndHttp2, - [8443] = IoxideProtocols.All, - [8444] = IoxideProtocols.Http1, + [8081] = Protocols.Http2, + [8082] = Protocols.Http1AndHttp2, + [8443] = Protocols.All, + [8444] = Protocols.Http1, }, Tcp = new TcpTransportOptions From aa9ee125a4b04f6509563e956a20120fe87665ef Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Mon, 17 Aug 2026 12:00:44 +0100 Subject: [PATCH 44/55] refactor(ioxide): drop _primary, the endpoints already hold the first one _primary was a second reference to an endpoint the port table already had, kept only because nothing else remembered which one came first. Same shape as the _secure bool this series just removed: one fact stored twice, and in principle able to disagree. The endpoints are now an array in bind order, and _endPointByPort is derived from it in the constructor rather than built alongside it, so the two cannot drift. The first endpoint is the first element, which is all _primary ever meant. The array also serves the places that were filtering the dictionary's values - SecureEndPoints and the HTTP/3 resolution - and ResolveQuicEndPoint no longer takes the mapped list as a parameter, since it can read the field. DualStack comes out as its own field. It is one mode for the whole server, and reading it off _primary made it look like the first endpoint's opinion when MapEndPoints has already refused any endpoint that disagrees. --- Engine/Ioxide/Infrastructure/Server.Quic.cs | 4 +- .../Ioxide/Infrastructure/Server.Tcp.Tls.cs | 2 +- Engine/Ioxide/Infrastructure/Server.Tcp.cs | 2 +- Engine/Ioxide/Infrastructure/Server.cs | 37 ++++++++++++------- 4 files changed, 27 insertions(+), 18 deletions(-) diff --git a/Engine/Ioxide/Infrastructure/Server.Quic.cs b/Engine/Ioxide/Infrastructure/Server.Quic.cs index 1f7eaba55..3b143bd38 100644 --- a/Engine/Ioxide/Infrastructure/Server.Quic.cs +++ b/Engine/Ioxide/Infrastructure/Server.Quic.cs @@ -27,9 +27,9 @@ public sealed partial class Server /// UDP port for the whole server, so several endpoints asking for HTTP/3 would each want their /// own and only the first could have it - refused here rather than silently honouring one. /// - private EndPoint? ResolveQuicEndPoint(List mapped) + private EndPoint? ResolveQuicEndPoint() { - var quicEndPoints = mapped.Where(e => _protocols[e.Port].HasFlag(Protocols.Http3)).ToList(); + var quicEndPoints = _endPoints.Where(e => _protocols[e.Port].HasFlag(Protocols.Http3)).ToList(); if (quicEndPoints.Count > 1) { diff --git a/Engine/Ioxide/Infrastructure/Server.Tcp.Tls.cs b/Engine/Ioxide/Infrastructure/Server.Tcp.Tls.cs index 433016c1c..6b6abce22 100644 --- a/Engine/Ioxide/Infrastructure/Server.Tcp.Tls.cs +++ b/Engine/Ioxide/Infrastructure/Server.Tcp.Tls.cs @@ -71,7 +71,7 @@ private bool MutualTlsConfigured || SecureEndPoints.Any(e => e.Security!.CertificateValidator is not null); /// The endpoints bound with a certificate - the ones TLS applies to. - private IEnumerable SecureEndPoints => _endPointByPort.Values.Where(e => e.Security is not null); + private IEnumerable SecureEndPoints => _endPoints.Where(e => e.Security is not null); private static string ExportKeyPem(X509Certificate2 certificate) => certificate.GetRSAPrivateKey()?.ExportPkcs8PrivateKeyPem() diff --git a/Engine/Ioxide/Infrastructure/Server.Tcp.cs b/Engine/Ioxide/Infrastructure/Server.Tcp.cs index d11131cc7..732330d13 100644 --- a/Engine/Ioxide/Infrastructure/Server.Tcp.cs +++ b/Engine/Ioxide/Infrastructure/Server.Tcp.cs @@ -20,7 +20,7 @@ public sealed partial class Server private ushort[] ResolveTcpPorts() => _protocols.Where(p => (p.Value & Protocols.Http1AndHttp2) != 0) .Select(p => p.Key) - .OrderBy(p => p == _primary.Port ? 0 : 1) + .OrderBy(p => p == _endPoints[0].Port ? 0 : 1) .ToArray(); /// diff --git a/Engine/Ioxide/Infrastructure/Server.cs b/Engine/Ioxide/Infrastructure/Server.cs index edd57d7a2..be4a271bb 100644 --- a/Engine/Ioxide/Infrastructure/Server.cs +++ b/Engine/Ioxide/Infrastructure/Server.cs @@ -27,10 +27,18 @@ public sealed partial class Server : IServer { private readonly ServerConfiguration _config; - private readonly EndPoint _primary; + /// Every endpoint, in the order it was bound. The first one names the server. + private readonly EndPoint[] _endPoints; + /// The same endpoints by listener port, which is how a connection finds its own. private readonly Dictionary _endPointByPort; + /// + /// One mode for the whole server, since that is all ioxide takes - see , + /// which refuses endpoints that disagree. + /// + private readonly bool _dualStack; + private readonly ushort[] _tcpPorts; private readonly Dictionary _protocols; @@ -72,19 +80,18 @@ internal Server( _logger = config.Logging.CreateLogger(); - var mappedEndpoints = MapEndPoints(config); - - _primary = mappedEndpoints[0]; - _endPointByPort = mappedEndpoints.ToDictionary(e => e.Port); + _endPoints = MapEndPoints(config); + _endPointByPort = _endPoints.ToDictionary(e => e.Port); + _dualStack = _endPoints[0].DualStack; - _protocols = mappedEndpoints.ToDictionary(e => e.Port, e => ResolveProtocols(_options, config, e.Port)); + _protocols = _endPoints.ToDictionary(e => e.Port, e => ResolveProtocols(_options, config, e.Port)); // Which endpoints want which listener, settled here so StartAsync only has to act on it. - // Order matters: both read _protocols, and the TCP one reads _primary as well. + // Order matters: both read _protocols, and the TCP one reads _endPoints as well. _tcpPorts = ResolveTcpPorts(); - _quicEndPoint = ResolveQuicEndPoint(mappedEndpoints); + _quicEndPoint = ResolveQuicEndPoint(); - EndPoints = new EndPointCollection(mappedEndpoints.Cast().ToList()); + EndPoints = new EndPointCollection(_endPoints); } /// @@ -97,14 +104,16 @@ internal Server( /// The engine can only honour one, and honours the first endpoint's - so endpoints that /// disagree are refused here rather than silently served the first one's mode. /// - private static List MapEndPoints(ServerConfiguration config) + private static EndPoint[] MapEndPoints(ServerConfiguration config) { var mapped = config.EndPoints .Select(e => new EndPoint(e.Address, e.Port, e.DualStack, e.Security)) - .ToList(); + .ToArray(); var dualStack = mapped[0].DualStack; + // No disagreement between DualStack capability among endpoints is supported as of today + // To support that user can simply have two different IServerHost instances running. if (mapped.Any(e => e.DualStack != dualStack)) { var disagreeing = mapped.Where(e => e.DualStack != dualStack).Select(e => e.Port); @@ -131,7 +140,7 @@ private static List MapEndPoints(ServerConfiguration config) // Server-wide rather than per transport: it applies to the TCP listener and the UDP socket // alike, which is why the engine binds every endpoint with one mode. - DualStack = _primary.DualStack, + DualStack = _dualStack, // No listeners yet - WithTcp and WithQuic add the ones the bindings ask for. Explicitly // null because ioxide's own default is a live listener on 8080, which an HTTP/3-only server @@ -225,7 +234,7 @@ public async ValueTask StartAsync() if (_logger.IsEnabled(LogLevel.Information)) { - _logger.LogInformation("Listening on {Address}:{Port} ({Settings})", _primary.Address, _primary.Port, DescribeSettings()); + _logger.LogInformation("Listening on {Address}:{Port} ({Settings})", _endPoints[0].Address, _endPoints[0].Port, DescribeSettings()); } } @@ -287,7 +296,7 @@ private string DescribeSettings() return $"ioxide, {protocols}, TLS on {SecureEndPoints.Count()}" + (MutualTlsConfigured ? ", mTLS" : string.Empty) - + $", DualStack: {_primary.DualStack}, Reactors: {_reactors?.Length ?? 0}"; + + $", DualStack: {_dualStack}, Reactors: {_reactors?.Length ?? 0}"; } private static string Describe(Protocols protocols) From 430e98cd399c9ab0e59317a6f5a6fcdcaf4d4735 Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Mon, 17 Aug 2026 14:55:04 +0100 Subject: [PATCH 45/55] refactor(ioxide): name the two injected records, and follow the house layout _config and _options named the kind of thing rather than which one. The server takes a ServerConfiguration from GenHTTP and an EngineOptions of its own, and a read of either had to be traced back to its field to tell the two apart. They are _serverConfiguration and _engineOptions now, with the constructor parameter matching. The file also picks up the layout the rest of GenHTTP uses: a Get-/Setters region over the interface members and a Constructors region around the constructor. MapEndPoints and BuildServerConfig move below StartAsync, both being setup detail the constructor calls once - sitting between the constructor and StartAsync they put half a file between the class and the thing it actually does. --- Engine/Ioxide/Infrastructure/Server.Quic.cs | 8 +- .../Ioxide/Infrastructure/Server.Tcp.Tls.cs | 14 +- Engine/Ioxide/Infrastructure/Server.Tcp.cs | 12 +- Engine/Ioxide/Infrastructure/Server.cs | 136 +++++++++--------- 4 files changed, 89 insertions(+), 81 deletions(-) diff --git a/Engine/Ioxide/Infrastructure/Server.Quic.cs b/Engine/Ioxide/Infrastructure/Server.Quic.cs index 3b143bd38..e279041bf 100644 --- a/Engine/Ioxide/Infrastructure/Server.Quic.cs +++ b/Engine/Ioxide/Infrastructure/Server.Quic.cs @@ -62,7 +62,7 @@ private ServerConfig WithQuic(ServerConfig serverConfig) } _quicEngine = new QuicEngine(certPath, keyPath, alpn: ["h3"], - clientCaPemPath: _options.MutualTls.ClientCaPath, + clientCaPemPath: _engineOptions.MutualTls.ClientCaPath, requireClientCertificate: RequiresClientCertificate(security)); // Built once here, not in the QuicHandle below - that runs per accepted connection, and @@ -70,8 +70,8 @@ private ServerConfig WithQuic(ServerConfig serverConfig) // what the caller sets, and this is where the two meet. _h3Options = new Nghttp3Options { - QpackDynamicTableCapacity = _options.Http3.QpackDynamicTableCapacity, - QpackBlockedStreams = _options.Http3.QpackBlockedStreams, + QpackDynamicTableCapacity = _engineOptions.Http3.QpackDynamicTableCapacity, + QpackBlockedStreams = _engineOptions.Http3.QpackBlockedStreams, }; return serverConfig with @@ -98,7 +98,7 @@ private bool TryResolveQuicCertificate(Shared.Infrastructure.SecurityConfigurati { certPath = keyPath = string.Empty; - if (_options.Http3.CertificatePath is not { } configuredCert || _options.Http3.KeyPath is not { } configuredKey) + if (_engineOptions.Http3.CertificatePath is not { } configuredCert || _engineOptions.Http3.KeyPath is not { } configuredKey) { throw new InvalidOperationException( $"Port {port} serves HTTP/3, which needs a PEM certificate and key on disk - ngtcp2 loads them by path. " diff --git a/Engine/Ioxide/Infrastructure/Server.Tcp.Tls.cs b/Engine/Ioxide/Infrastructure/Server.Tcp.Tls.cs index 6b6abce22..ec1b7d209 100644 --- a/Engine/Ioxide/Infrastructure/Server.Tcp.Tls.cs +++ b/Engine/Ioxide/Infrastructure/Server.Tcp.Tls.cs @@ -40,12 +40,12 @@ private IEnumerable> ResolveTls() // without an ALPN extension at all. Alpn = ProtocolsFor(port).HasFlag(Protocols.Http2) ? ["h2", "http/1.1"] : ["http/1.1"], - ClientCaPath = _options.MutualTls.ClientCaPath, - ClientCaPem = _options.MutualTls.ClientCaPem, + ClientCaPath = _engineOptions.MutualTls.ClientCaPath, + ClientCaPem = _engineOptions.MutualTls.ClientCaPem, RequireClientCertificate = RequiresClientCertificate(endPoint.Security), - KernelTx = _options.Tcp.TxKernelTls, - KernelRx = _options.Tcp.RxKernelTls + KernelTx = _engineOptions.Tcp.TxKernelTls, + KernelRx = _engineOptions.Tcp.RxKernelTls }); } } @@ -60,14 +60,14 @@ private IEnumerable> ResolveTls() /// setting that means the same thing on both transports. /// private bool RequiresClientCertificate(SecurityConfiguration security) - => _options.MutualTls.RequireClientCertificate || security.CertificateValidator?.RequireCertificate == true; + => _engineOptions.MutualTls.RequireClientCertificate || security.CertificateValidator?.RequireCertificate == true; /// /// Whether any endpoint asks for a client certificate at all. /// private bool MutualTlsConfigured - => _options.MutualTls.ClientCaPath is not null || _options.MutualTls.ClientCaPem is not null - || _options.MutualTls.RequireClientCertificate + => _engineOptions.MutualTls.ClientCaPath is not null || _engineOptions.MutualTls.ClientCaPem is not null + || _engineOptions.MutualTls.RequireClientCertificate || SecureEndPoints.Any(e => e.Security!.CertificateValidator is not null); /// The endpoints bound with a certificate - the ones TLS applies to. diff --git a/Engine/Ioxide/Infrastructure/Server.Tcp.cs b/Engine/Ioxide/Infrastructure/Server.Tcp.cs index 732330d13..7b627c0b0 100644 --- a/Engine/Ioxide/Infrastructure/Server.Tcp.cs +++ b/Engine/Ioxide/Infrastructure/Server.Tcp.cs @@ -39,12 +39,12 @@ private ServerConfig WithTcp(ServerConfig serverConfig) => serverConfig with Port = _tcpPorts[0], ExtraPorts = _tcpPorts[1..], - ListenBacklog = _options.Tcp.ListenBacklog, - WriteSlabSize = _options.Tcp.WriteSlabSize, - WriteOverflow = _options.Tcp.WriteOverflow, - PoolMax = _options.Tcp.PoolMax, - ZeroCopySend = _options.Tcp.ZeroCopySend, - RecvQueueEntries = _options.Tcp.RecvQueueEntries, + ListenBacklog = _engineOptions.Tcp.ListenBacklog, + WriteSlabSize = _engineOptions.Tcp.WriteSlabSize, + WriteOverflow = _engineOptions.Tcp.WriteOverflow, + PoolMax = _engineOptions.Tcp.PoolMax, + ZeroCopySend = _engineOptions.Tcp.ZeroCopySend, + RecvQueueEntries = _engineOptions.Tcp.RecvQueueEntries, }, }; } diff --git a/Engine/Ioxide/Infrastructure/Server.cs b/Engine/Ioxide/Infrastructure/Server.cs index be4a271bb..934866ed2 100644 --- a/Engine/Ioxide/Infrastructure/Server.cs +++ b/Engine/Ioxide/Infrastructure/Server.cs @@ -25,7 +25,7 @@ namespace GenHTTP.Engine.Ioxide.Infrastructure; /// public sealed partial class Server : IServer { - private readonly ServerConfiguration _config; + private readonly ServerConfiguration _serverConfiguration; /// Every endpoint, in the order it was bound. The first one names the server. private readonly EndPoint[] _endPoints; @@ -45,7 +45,7 @@ public sealed partial class Server : IServer private readonly Action? _onReactorStart; - private readonly EngineOptions _options; + private readonly EngineOptions _engineOptions; private readonly ILogger _logger; @@ -53,38 +53,44 @@ public sealed partial class Server : IServer private Reactor[]? _reactors; +#region Get-/Setters + public string Version { get; } = typeof(Server).Assembly.GetName().Version?.ToString() ?? "0.1"; public bool Running { get; private set; } - public bool Development => _config.DevelopmentMode; + public bool Development => _serverConfiguration.DevelopmentMode; public IPropertyBag Properties { get; } = new PropertyBag(); - public ILoggerFactory Logging => _config.Logging; + public ILoggerFactory Logging => _serverConfiguration.Logging; public IEndPointCollection EndPoints { get; } public IHandler Handler { get; } + +#endregion +#region Constructors + internal Server( - ServerConfiguration config, + ServerConfiguration serverConfiguration, IHandler handler, Action? onReactorStart = null, EngineOptions? options = null) { - _config = config; + _serverConfiguration = serverConfiguration; Handler = handler; _onReactorStart = onReactorStart; - _options = options ?? EngineOptions.Default; + _engineOptions = options ?? EngineOptions.Default; - _logger = config.Logging.CreateLogger(); + _logger = serverConfiguration.Logging.CreateLogger(); - _endPoints = MapEndPoints(config); + _endPoints = MapEndPoints(serverConfiguration); _endPointByPort = _endPoints.ToDictionary(e => e.Port); _dualStack = _endPoints[0].DualStack; - _protocols = _endPoints.ToDictionary(e => e.Port, e => ResolveProtocols(_options, config, e.Port)); + _protocols = _endPoints.ToDictionary(e => e.Port, e => ResolveProtocols(_engineOptions, serverConfiguration, e.Port)); // Which endpoints want which listener, settled here so StartAsync only has to act on it. // Order matters: both read _protocols, and the TCP one reads _endPoints as well. @@ -93,60 +99,8 @@ internal Server( EndPoints = new EndPointCollection(_endPoints); } - - /// - /// GenHTTP's endpoints as the engine's own, which is also where the one thing every endpoint - /// must agree on is checked. - /// - /// - /// GenHTTP takes dual-stack per endpoint, on Bind; ioxide takes one flag for the whole - /// server, deciding whether listeners are IPv6 on :: with V6ONLY off or plain IPv4 on 0.0.0.0. - /// The engine can only honour one, and honours the first endpoint's - so endpoints that - /// disagree are refused here rather than silently served the first one's mode. - /// - private static EndPoint[] MapEndPoints(ServerConfiguration config) - { - var mapped = config.EndPoints - .Select(e => new EndPoint(e.Address, e.Port, e.DualStack, e.Security)) - .ToArray(); - - var dualStack = mapped[0].DualStack; - - // No disagreement between DualStack capability among endpoints is supported as of today - // To support that user can simply have two different IServerHost instances running. - if (mapped.Any(e => e.DualStack != dualStack)) - { - var disagreeing = mapped.Where(e => e.DualStack != dualStack).Select(e => e.Port); - - throw new NotSupportedException( - $"The ioxide engine binds every endpoint with one dual-stack mode, taken from the first one bound " - + $"(port {mapped[0].Port}, DualStack = {dualStack}). These ask for the other: {string.Join(", ", disagreeing)}."); - } - - return mapped; - } - - /// - /// The engine-wide configuration, straight from the options. The listeners are added on top by - /// WithTcp and WithQuic, which take their ports from the endpoint bindings. - /// - private ServerConfig BuildServerConfig() => new() - { - ReactorCount = _options.Reactor.ReactorCount, - RingEntries = _options.Reactor.RingEntries, - RecvBufferSize = _options.Reactor.RecvBufferSize, - RecvSlots = _options.Reactor.RecvSlots, - Incremental = _options.Reactor.Incremental, - - // Server-wide rather than per transport: it applies to the TCP listener and the UDP socket - // alike, which is why the engine binds every endpoint with one mode. - DualStack = _dualStack, - - // No listeners yet - WithTcp and WithQuic add the ones the bindings ask for. Explicitly - // null because ioxide's own default is a live listener on 8080, which an HTTP/3-only server - // would otherwise inherit and bind for a protocol it does not serve. - Tcp = null, - }; + +#endregion public async ValueTask StartAsync() { @@ -238,6 +192,60 @@ public async ValueTask StartAsync() } } + /// + /// GenHTTP's endpoints as the engine's own, which is also where the one thing every endpoint + /// must agree on is checked. + /// + /// + /// GenHTTP takes dual-stack per endpoint, on Bind; ioxide takes one flag for the whole + /// server, deciding whether listeners are IPv6 on :: with V6ONLY off or plain IPv4 on 0.0.0.0. + /// The engine can only honour one, and honours the first endpoint's - so endpoints that + /// disagree are refused here rather than silently served the first one's mode. + /// + private static EndPoint[] MapEndPoints(ServerConfiguration config) + { + var mapped = config.EndPoints + .Select(e => new EndPoint(e.Address, e.Port, e.DualStack, e.Security)) + .ToArray(); + + var dualStack = mapped[0].DualStack; + + // No disagreement between DualStack capability among endpoints is supported as of today + // To support that user can simply have two different IServerHost instances running. + if (mapped.Any(e => e.DualStack != dualStack)) + { + var disagreeing = mapped.Where(e => e.DualStack != dualStack).Select(e => e.Port); + + throw new NotSupportedException( + $"The ioxide engine binds every endpoint with one dual-stack mode, taken from the first one bound " + + $"(port {mapped[0].Port}, DualStack = {dualStack}). These ask for the other: {string.Join(", ", disagreeing)}."); + } + + return mapped; + } + + /// + /// The engine-wide configuration, straight from the options. The listeners are added on top by + /// WithTcp and WithQuic, which take their ports from the endpoint bindings. + /// + private ServerConfig BuildServerConfig() => new() + { + ReactorCount = _engineOptions.Reactor.ReactorCount, + RingEntries = _engineOptions.Reactor.RingEntries, + RecvBufferSize = _engineOptions.Reactor.RecvBufferSize, + RecvSlots = _engineOptions.Reactor.RecvSlots, + Incremental = _engineOptions.Reactor.Incremental, + + // Server-wide rather than per transport: it applies to the TCP listener and the UDP socket + // alike, which is why the engine binds every endpoint with one mode. + DualStack = _dualStack, + + // No listeners yet - WithTcp and WithQuic add the ones the bindings ask for. Explicitly + // null because ioxide's own default is a live listener on 8080, which an HTTP/3-only server + // would otherwise inherit and bind for a protocol it does not serve. + Tcp = null, + }; + private async ValueTask PrepareHandlerAsync() { try From 5b7a6fe8abb90caaa9e9d0367cce9089cc58c538 Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Mon, 17 Aug 2026 14:55:40 +0100 Subject: [PATCH 46/55] refactor(ioxide): drop the port table, the endpoints array already answers it _endPointByPort was a second collection over the same objects, built from _endPoints in the constructor to serve exactly one caller - the TcpHandle lookup that turns a connection's listener port into the endpoint it arrived on. A whole dictionary for one lookup, and another thing to keep in step with the array beside it. EndPointFor scans instead, and sits next to ProtocolsFor which answers the same question about the same port. A server binds a handful of endpoints, so walking a contiguous array is no worse than hashing a ushort, and the endpoints are in one place. A port with no endpoint now throws with the port in the message rather than a bare KeyNotFoundException from the indexer. The duplicate-port guard survives the removal: _protocols is still built with ToDictionary over the same key, so two endpoints on one port still fail in the constructor rather than silently keeping one. --- Engine/Ioxide/Infrastructure/Server.cs | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/Engine/Ioxide/Infrastructure/Server.cs b/Engine/Ioxide/Infrastructure/Server.cs index 934866ed2..ab0b2f96d 100644 --- a/Engine/Ioxide/Infrastructure/Server.cs +++ b/Engine/Ioxide/Infrastructure/Server.cs @@ -30,9 +30,6 @@ public sealed partial class Server : IServer /// Every endpoint, in the order it was bound. The first one names the server. private readonly EndPoint[] _endPoints; - /// The same endpoints by listener port, which is how a connection finds its own. - private readonly Dictionary _endPointByPort; - /// /// One mode for the whole server, since that is all ioxide takes - see , /// which refuses endpoints that disagree. @@ -87,7 +84,6 @@ internal Server( _logger = serverConfiguration.Logging.CreateLogger(); _endPoints = MapEndPoints(serverConfiguration); - _endPointByPort = _endPoints.ToDictionary(e => e.Port); _dualStack = _endPoints[0].DualStack; _protocols = _endPoints.ToDictionary(e => e.Port, e => ResolveProtocols(_engineOptions, serverConfiguration, e.Port)); @@ -155,7 +151,7 @@ public async ValueTask StartAsync() TcpHandle = (_, tcpConnection) => ConnectionDriver.HandleAsync( this, - _endPointByPort[tcpConnection.ListenerPort], + EndPointFor(tcpConnection.ListenerPort), tcpConnection, ProtocolsFor(tcpConnection.ListenerPort)), @@ -298,6 +294,24 @@ private static Protocols ResolveProtocols(EngineOptions options, ServerConfigura private Protocols ProtocolsFor(ushort port) => _protocols.TryGetValue(port, out var protocols) ? protocols : Protocols.Http1; + /// + /// The endpoint a connection arrived on, matched by the port its listener bound. A scan rather + /// than a table: a server binds a handful of endpoints, so the array is the whole truth and + /// there is no second copy of it to keep in step. + /// + private EndPoint EndPointFor(ushort port) + { + foreach (var endPoint in _endPoints) + { + if (endPoint.Port == port) + { + return endPoint; + } + } + + throw new InvalidOperationException($"A connection arrived on port {port}, which no endpoint is bound to."); + } + private string DescribeSettings() { var protocols = string.Join(" ", _protocols.OrderBy(p => p.Key).Select(p => $"{p.Key}:{Describe(p.Value)}")); From ae3050e565ce1c7c85cb986c99bebc73f803eb8b Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Mon, 17 Aug 2026 15:04:59 +0100 Subject: [PATCH 47/55] refactor(ioxide): let an endpoint carry its own protocols _protocols was a Dictionary beside the endpoints, keyed by the port that already identifies them - the same second table 30d2ad5a removed for SecurityConfiguration, and the last one left. An endpoint is unique per port, which is what that dictionary was quietly asserting, so what a port serves is a fact about the endpoint and now lives on it. ResolveProtocols stops hunting. Given only a port, it went back to config.EndPoints twice per endpoint to ask whether that port had a certificate and whether it had enabled QUIC. It takes the binding itself now, and both scans become endPoint.Security is null and endPoint.EnableQuic. The accept path does one lookup instead of two. TcpHandle was calling EndPointFor and ProtocolsFor with the same port; it now reads the protocols off the endpoint it has already found. ResolveQuicEndPoint and ResolveTcpPorts filter the array directly rather than indexing a table alongside it. One thing had to be replaced rather than deleted. Building _protocols with ToDictionary was, by accident, the check that no port was bound twice - with it gone MapEndPoints refuses duplicates itself, naming the port and how often it was bound instead of raising a bare ArgumentException from the dictionary. ProtocolsFor's fallback to Http1 for an unknown port is not carried over: it became unreachable in 5b7a6fe8, when EndPointFor started throwing for a port no endpoint is bound to. --- .../Infrastructure/Endpoints/EndPoint.cs | 11 ++-- Engine/Ioxide/Infrastructure/Server.Quic.cs | 2 +- .../Ioxide/Infrastructure/Server.Tcp.Tls.cs | 2 +- Engine/Ioxide/Infrastructure/Server.Tcp.cs | 4 +- Engine/Ioxide/Infrastructure/Server.cs | 56 ++++++++++--------- 5 files changed, 40 insertions(+), 35 deletions(-) diff --git a/Engine/Ioxide/Infrastructure/Endpoints/EndPoint.cs b/Engine/Ioxide/Infrastructure/Endpoints/EndPoint.cs index 0cea7b329..2b3401b24 100644 --- a/Engine/Ioxide/Infrastructure/Endpoints/EndPoint.cs +++ b/Engine/Ioxide/Infrastructure/Endpoints/EndPoint.cs @@ -11,11 +11,11 @@ namespace GenHTTP.Engine.Ioxide.Infrastructure.Endpoints; /// /// /// carries the binding's certificate provider, protocols and client -/// certificate validator, so an endpoint answers for its own TLS rather than the server keeping a -/// second table keyed by port. Nothing to dispose: the listener belongs to the reactors, which bind -/// it themselves and tear it down with their rings. +/// certificate validator, and what the port serves, so an endpoint answers +/// for itself rather than the server keeping second tables keyed by port. Nothing to dispose: the +/// listener belongs to the reactors, which bind it themselves and tear it down with their rings. /// -internal sealed class EndPoint(IPAddress? address, ushort port, bool dualStack, SecurityConfiguration? security) : IEndPoint +internal sealed class EndPoint(IPAddress? address, ushort port, bool dualStack, SecurityConfiguration? security, Protocols protocols) : IEndPoint { public IPAddress? Address => address; @@ -28,5 +28,8 @@ internal sealed class EndPoint(IPAddress? address, ushort port, bool dualStack, public bool Secure => security is not null; + /// What this endpoint serves, resolved once from the options and its own binding. + public Protocols Protocols => protocols; + public void Dispose() { } } diff --git a/Engine/Ioxide/Infrastructure/Server.Quic.cs b/Engine/Ioxide/Infrastructure/Server.Quic.cs index e279041bf..637a81b98 100644 --- a/Engine/Ioxide/Infrastructure/Server.Quic.cs +++ b/Engine/Ioxide/Infrastructure/Server.Quic.cs @@ -29,7 +29,7 @@ public sealed partial class Server /// private EndPoint? ResolveQuicEndPoint() { - var quicEndPoints = _endPoints.Where(e => _protocols[e.Port].HasFlag(Protocols.Http3)).ToList(); + var quicEndPoints = _endPoints.Where(e => e.Protocols.HasFlag(Protocols.Http3)).ToList(); if (quicEndPoints.Count > 1) { diff --git a/Engine/Ioxide/Infrastructure/Server.Tcp.Tls.cs b/Engine/Ioxide/Infrastructure/Server.Tcp.Tls.cs index ec1b7d209..cb3e9ad7f 100644 --- a/Engine/Ioxide/Infrastructure/Server.Tcp.Tls.cs +++ b/Engine/Ioxide/Infrastructure/Server.Tcp.Tls.cs @@ -38,7 +38,7 @@ private IEnumerable> ResolveTls() // Server preference, most preferred first. A client offering neither continues // without an ALPN extension at all. - Alpn = ProtocolsFor(port).HasFlag(Protocols.Http2) ? ["h2", "http/1.1"] : ["http/1.1"], + Alpn = endPoint.Protocols.HasFlag(Protocols.Http2) ? ["h2", "http/1.1"] : ["http/1.1"], ClientCaPath = _engineOptions.MutualTls.ClientCaPath, ClientCaPem = _engineOptions.MutualTls.ClientCaPem, diff --git a/Engine/Ioxide/Infrastructure/Server.Tcp.cs b/Engine/Ioxide/Infrastructure/Server.Tcp.cs index 7b627c0b0..67438dae6 100644 --- a/Engine/Ioxide/Infrastructure/Server.Tcp.cs +++ b/Engine/Ioxide/Infrastructure/Server.Tcp.cs @@ -18,8 +18,8 @@ public sealed partial class Server /// protocols. /// private ushort[] ResolveTcpPorts() - => _protocols.Where(p => (p.Value & Protocols.Http1AndHttp2) != 0) - .Select(p => p.Key) + => _endPoints.Where(e => (e.Protocols & Protocols.Http1AndHttp2) != 0) + .Select(e => e.Port) .OrderBy(p => p == _endPoints[0].Port ? 0 : 1) .ToArray(); diff --git a/Engine/Ioxide/Infrastructure/Server.cs b/Engine/Ioxide/Infrastructure/Server.cs index ab0b2f96d..f60778205 100644 --- a/Engine/Ioxide/Infrastructure/Server.cs +++ b/Engine/Ioxide/Infrastructure/Server.cs @@ -38,8 +38,6 @@ public sealed partial class Server : IServer private readonly ushort[] _tcpPorts; - private readonly Dictionary _protocols; - private readonly Action? _onReactorStart; private readonly EngineOptions _engineOptions; @@ -83,13 +81,11 @@ internal Server( _logger = serverConfiguration.Logging.CreateLogger(); - _endPoints = MapEndPoints(serverConfiguration); + _endPoints = MapEndPoints(serverConfiguration, _engineOptions); _dualStack = _endPoints[0].DualStack; - _protocols = _endPoints.ToDictionary(e => e.Port, e => ResolveProtocols(_engineOptions, serverConfiguration, e.Port)); - // Which endpoints want which listener, settled here so StartAsync only has to act on it. - // Order matters: both read _protocols, and the TCP one reads _endPoints as well. + // Both read _endPoints, which each endpoint's own protocols now come with. _tcpPorts = ResolveTcpPorts(); _quicEndPoint = ResolveQuicEndPoint(); @@ -148,12 +144,12 @@ public async ValueTask StartAsync() listening.Signal(); }, - TcpHandle = (_, tcpConnection) => - ConnectionDriver.HandleAsync( - this, - EndPointFor(tcpConnection.ListenerPort), - tcpConnection, - ProtocolsFor(tcpConnection.ListenerPort)), + TcpHandle = (_, tcpConnection) => + { + var endPoint = EndPointFor(tcpConnection.ListenerPort); + + return ConnectionDriver.HandleAsync(this, endPoint, tcpConnection, endPoint.Protocols); + }, QuicHandle = _quicEngine is not null ? (_, quicConnection) => Http3Driver.RunAsync(this, _quicEndPoint!, quicConnection, _h3Options!) @@ -189,8 +185,9 @@ public async ValueTask StartAsync() } /// - /// GenHTTP's endpoints as the engine's own, which is also where the one thing every endpoint - /// must agree on is checked. + /// GenHTTP's endpoints as the engine's own, resolving what each one serves, and where the two + /// things the engine cannot express are refused: a port bound twice, and endpoints asking for + /// different dual-stack modes. /// /// /// GenHTTP takes dual-stack per endpoint, on Bind; ioxide takes one flag for the whole @@ -198,12 +195,21 @@ public async ValueTask StartAsync() /// The engine can only honour one, and honours the first endpoint's - so endpoints that /// disagree are refused here rather than silently served the first one's mode. /// - private static EndPoint[] MapEndPoints(ServerConfiguration config) + private static EndPoint[] MapEndPoints(ServerConfiguration config, EngineOptions options) { var mapped = config.EndPoints - .Select(e => new EndPoint(e.Address, e.Port, e.DualStack, e.Security)) + .Select(e => new EndPoint(e.Address, e.Port, e.DualStack, e.Security, ResolveProtocols(options, e))) .ToArray(); + // A connection carries only the port it arrived on, so that port has to name one endpoint. + // Checked explicitly because nothing else keys them by port any more. + if (mapped.GroupBy(e => e.Port).FirstOrDefault(g => g.Count() > 1) is { } duplicate) + { + throw new NotSupportedException( + $"Port {duplicate.Key} was bound {duplicate.Count()} times. A connection is matched to its endpoint " + + "by the port it arrived on, so each port carries one endpoint."); + } + var dualStack = mapped[0].DualStack; // No disagreement between DualStack capability among endpoints is supported as of today @@ -261,39 +267,35 @@ private async ValueTask PrepareHandlerAsync() } /// - /// What one port serves: the default, its override, and the endpoint's own enableQuic flag. + /// What one endpoint serves: the default, its port's override, and its own enableQuic flag. /// - private static Protocols ResolveProtocols(EngineOptions options, ServerConfiguration config, ushort port) + private static Protocols ResolveProtocols(EngineOptions options, EndPointConfiguration endPoint) { - var named = options.ProtocolsByPort.TryGetValue(port, out var configured); + var named = options.ProtocolsByPort.TryGetValue(endPoint.Port, out var configured); var protocols = named ? configured : options.Protocols; // HTTP/3 from the DEFAULT applies only where it can, so Protocols = All means "everything // each port supports" rather than an error about the plaintext one. Named per port it is // taken literally, and refused where the port has no certificate. - if (!named && protocols.HasFlag(Protocols.Http3) && config.EndPoints.All(e => e.Port != port || e.Security is null)) + if (!named && protocols.HasFlag(Protocols.Http3) && endPoint.Security is null) { protocols &= ~Protocols.Http3; } - if (config.EndPoints.Any(e => e.Port == port && e.EnableQuic)) + if (endPoint.EnableQuic) { protocols |= Protocols.Http3; } if (protocols == 0) { - throw new NotSupportedException($"Port {port} was given no protocols to serve."); + throw new NotSupportedException($"Port {endPoint.Port} was given no protocols to serve."); } return protocols; } - /// The protocols this port serves. - private Protocols ProtocolsFor(ushort port) - => _protocols.TryGetValue(port, out var protocols) ? protocols : Protocols.Http1; - /// /// The endpoint a connection arrived on, matched by the port its listener bound. A scan rather /// than a table: a server binds a handful of endpoints, so the array is the whole truth and @@ -314,7 +316,7 @@ private EndPoint EndPointFor(ushort port) private string DescribeSettings() { - var protocols = string.Join(" ", _protocols.OrderBy(p => p.Key).Select(p => $"{p.Key}:{Describe(p.Value)}")); + var protocols = string.Join(" ", _endPoints.OrderBy(e => e.Port).Select(e => $"{e.Port}:{Describe(e.Protocols)}")); return $"ioxide, {protocols}, TLS on {SecureEndPoints.Count()}" + (MutualTlsConfigured ? ", mTLS" : string.Empty) From dfc0e1a306c01d4908cd522875d77a678c70162a Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Mon, 17 Aug 2026 15:10:18 +0100 Subject: [PATCH 48/55] refactor(ioxide): keep _tcpPorts with the transport that resolves it The field was declared in Server.cs while the only things that write and read it - ResolveTcpPorts and WithTcp - live in Server.Tcp.cs. It moves to the partial that owns it, which is what Server.Quic.cs already does with _quicEndPoint. --- Engine/Ioxide/Infrastructure/Server.Tcp.cs | 2 ++ Engine/Ioxide/Infrastructure/Server.cs | 2 -- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Engine/Ioxide/Infrastructure/Server.Tcp.cs b/Engine/Ioxide/Infrastructure/Server.Tcp.cs index 67438dae6..f988e6e48 100644 --- a/Engine/Ioxide/Infrastructure/Server.Tcp.cs +++ b/Engine/Ioxide/Infrastructure/Server.Tcp.cs @@ -7,6 +7,8 @@ namespace GenHTTP.Engine.Ioxide.Infrastructure; /// public sealed partial class Server { + private readonly ushort[] _tcpPorts; + /// /// The ports that want a TCP listener: those serving HTTP/1.1 or HTTP/2, primary first - it /// becomes ioxide's TcpOptions.Port and the rest its ExtraPorts. Empty means this diff --git a/Engine/Ioxide/Infrastructure/Server.cs b/Engine/Ioxide/Infrastructure/Server.cs index f60778205..d736c2df0 100644 --- a/Engine/Ioxide/Infrastructure/Server.cs +++ b/Engine/Ioxide/Infrastructure/Server.cs @@ -36,8 +36,6 @@ public sealed partial class Server : IServer /// private readonly bool _dualStack; - private readonly ushort[] _tcpPorts; - private readonly Action? _onReactorStart; private readonly EngineOptions _engineOptions; From 0816304761f6fa2e2e42f054734c044522fcace8 Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Mon, 17 Aug 2026 15:14:15 +0100 Subject: [PATCH 49/55] refactor(ioxide): split the endpoint in two, and let the secure one carry its TLS EndPoint answered for both kinds at once: a nullable Security that half the engine null-checked and the other half dereferenced with a !, and a Secure derived from whether it was set. It is abstract now, with InsecureEndPoint and SecureEndPoint under it, so which kind an endpoint is became its type. SecureEndPoints is _endPoints.OfType(), and every null-forgiving operator on Security went with it. The mutual-TLS settings move onto the secure endpoint. RequiresClientCertificate was ORing the engine's flag with the binding's own validator at each use - once building the TLS options, again creating the QUIC engine - the same question answered twice about the same endpoint. SecureEndPoint settles it in its constructor and both transports read RequireClientCertificate, so neither reaches back into EngineOptions for it. WithQuic's check that HTTP/3 was not asked for on a plaintext port is a type test now rather than a null test. The trust anchors stay configured on EngineOptions, since GenHTTP's Bind takes no bundle per endpoint. What moves onto the endpoint is the resolved answer, the same way protocols did in ae3050e5. One behaviour change: MutualTlsConfigured is per endpoint rather than server-wide, so it reads false where the engine names a CA bundle but nothing is bound to serve it. It decides only whether the startup line says mTLS. --- Engine/Ioxide/EngineOptions.cs | 5 +- .../Infrastructure/Endpoints/EndPoint.cs | 20 +++---- .../Endpoints/InsecureEndPoint.cs | 13 +++++ .../Endpoints/SecureEndPoint.cs | 58 +++++++++++++++++++ Engine/Ioxide/Infrastructure/Server.Quic.cs | 12 ++-- .../Ioxide/Infrastructure/Server.Tcp.Tls.cs | 28 ++------- Engine/Ioxide/Infrastructure/Server.cs | 17 +++++- 7 files changed, 108 insertions(+), 45 deletions(-) create mode 100644 Engine/Ioxide/Infrastructure/Endpoints/InsecureEndPoint.cs create mode 100644 Engine/Ioxide/Infrastructure/Endpoints/SecureEndPoint.cs diff --git a/Engine/Ioxide/EngineOptions.cs b/Engine/Ioxide/EngineOptions.cs index 452450eab..9a7ad9958 100644 --- a/Engine/Ioxide/EngineOptions.cs +++ b/Engine/Ioxide/EngineOptions.cs @@ -157,7 +157,10 @@ public sealed record Http3Options /// /// What client certificates are validated against - by OpenSSL for HTTP/1.1 and HTTP/2, by ngtcp2 /// for HTTP/3, so a bad chain is refused before any request exists. WHICH endpoints ask for one is -/// decided per endpoint, by the certificateValidator passed to Bind. +/// decided per endpoint, by the certificateValidator passed to Bind. Configured here +/// for the whole engine because Bind takes no bundle of its own, then resolved onto each +/// secure endpoint as the server is built - see SecureEndPoint, which is what the transports +/// read. /// public sealed record MutualTlsOptions { diff --git a/Engine/Ioxide/Infrastructure/Endpoints/EndPoint.cs b/Engine/Ioxide/Infrastructure/Endpoints/EndPoint.cs index 2b3401b24..db9c30789 100644 --- a/Engine/Ioxide/Infrastructure/Endpoints/EndPoint.cs +++ b/Engine/Ioxide/Infrastructure/Endpoints/EndPoint.cs @@ -2,20 +2,17 @@ using GenHTTP.Api.Infrastructure; -using GenHTTP.Engine.Shared.Infrastructure; - namespace GenHTTP.Engine.Ioxide.Infrastructure.Endpoints; /// -/// One bound endpoint, as the engine sees it: where it listens, and what secures it. +/// One bound endpoint, as the engine sees it: where it listens and what it serves. Whether it is +/// secured is the subclass - or . /// /// -/// carries the binding's certificate provider, protocols and client -/// certificate validator, and what the port serves, so an endpoint answers -/// for itself rather than the server keeping second tables keyed by port. Nothing to dispose: the -/// listener belongs to the reactors, which bind it themselves and tear it down with their rings. +/// Nothing to dispose: the listener belongs to the reactors, which bind it themselves and tear it +/// down with their rings. /// -internal sealed class EndPoint(IPAddress? address, ushort port, bool dualStack, SecurityConfiguration? security, Protocols protocols) : IEndPoint +internal abstract class EndPoint(IPAddress? address, ushort port, bool dualStack, Protocols protocols) : IEndPoint { public IPAddress? Address => address; @@ -23,13 +20,10 @@ internal sealed class EndPoint(IPAddress? address, ushort port, bool dualStack, public bool DualStack => dualStack; - /// How this endpoint is secured, or null for a plaintext one. - public SecurityConfiguration? Security => security; - - public bool Secure => security is not null; - /// What this endpoint serves, resolved once from the options and its own binding. public Protocols Protocols => protocols; + public abstract bool Secure { get; } + public void Dispose() { } } diff --git a/Engine/Ioxide/Infrastructure/Endpoints/InsecureEndPoint.cs b/Engine/Ioxide/Infrastructure/Endpoints/InsecureEndPoint.cs new file mode 100644 index 000000000..f1dc28fe3 --- /dev/null +++ b/Engine/Ioxide/Infrastructure/Endpoints/InsecureEndPoint.cs @@ -0,0 +1,13 @@ +using System.Net; + +namespace GenHTTP.Engine.Ioxide.Infrastructure.Endpoints; + +/// +/// An endpoint bound without a certificate. Cleartext HTTP/1.1 or HTTP/2 only - HTTP/3 is refused +/// on one of these, since QUIC carries TLS 1.3 and has no cleartext mode. +/// +internal sealed class InsecureEndPoint(IPAddress? address, ushort port, bool dualStack, Protocols protocols) + : EndPoint(address, port, dualStack, protocols) +{ + public override bool Secure => false; +} diff --git a/Engine/Ioxide/Infrastructure/Endpoints/SecureEndPoint.cs b/Engine/Ioxide/Infrastructure/Endpoints/SecureEndPoint.cs new file mode 100644 index 000000000..07029e96d --- /dev/null +++ b/Engine/Ioxide/Infrastructure/Endpoints/SecureEndPoint.cs @@ -0,0 +1,58 @@ +using System.Net; + +using GenHTTP.Engine.Shared.Infrastructure; + +namespace GenHTTP.Engine.Ioxide.Infrastructure.Endpoints; + +/// +/// An endpoint bound with a certificate: what TLS termination here takes, in one place. The +/// handshake itself belongs to the transport - OpenSSL for HTTP/1.1 and HTTP/2, ngtcp2 for HTTP/3 - +/// so this carries the settings rather than performing it. +/// +/// +/// The trust anchors are configured once for the engine, since GenHTTP's Bind takes no +/// bundle per endpoint, but whether a client certificate is demanded is settled here: the engine's +/// flag and the binding's own validator are ORed at construction rather than at each use. +/// +internal sealed class SecureEndPoint : EndPoint +{ + internal SecureEndPoint(IPAddress? address, ushort port, bool dualStack, Protocols protocols, + SecurityConfiguration security, MutualTlsOptions mutualTls) + : base(address, port, dualStack, protocols) + { + Security = security; + + ClientCaPath = mutualTls.ClientCaPath; + ClientCaPem = mutualTls.ClientCaPem; + + RequireClientCertificate = mutualTls.RequireClientCertificate + || security.CertificateValidator?.RequireCertificate == true; + } + + public override bool Secure => true; + + /// How this endpoint is secured: its certificate provider, protocols and validator. + public SecurityConfiguration Security { get; } + + /// + /// PEM bundle of trust anchors that client certificates are validated against, as a path. Its + /// subject names are also sent in the CertificateRequest, so a client holding several + /// certificates can pick the right one; sends no such hint. + /// + public string? ClientCaPath { get; } + + /// The trust anchors as PEM text - the in-memory alternative to . + public string? ClientCaPem { get; } + + /// + /// Whether a client offering no certificate is refused here. False still asks for one and + /// validates what arrives, since the CertificateRequest goes out either way. + /// + public bool RequireClientCertificate { get; } + + /// Whether this endpoint asks for a client certificate at all. + public bool MutualTls => RequireClientCertificate + || ClientCaPath is not null + || ClientCaPem is not null + || Security.CertificateValidator is not null; +} diff --git a/Engine/Ioxide/Infrastructure/Server.Quic.cs b/Engine/Ioxide/Infrastructure/Server.Quic.cs index 637a81b98..7f212c90f 100644 --- a/Engine/Ioxide/Infrastructure/Server.Quic.cs +++ b/Engine/Ioxide/Infrastructure/Server.Quic.cs @@ -48,22 +48,22 @@ public sealed partial class Server /// private ServerConfig WithQuic(ServerConfig serverConfig) { - var quicEndPoint = _quicEndPoint!; + var endPoint = _quicEndPoint!; - if (quicEndPoint.Security is not { } security) + if (endPoint is not SecureEndPoint quicEndPoint) { - _logger.LogWarning("HTTP/3 was requested on port {Port}, which is not bound with a certificate; QUIC has no cleartext mode, so no listener was started.", quicEndPoint.Port); + _logger.LogWarning("HTTP/3 was requested on port {Port}, which is not bound with a certificate; QUIC has no cleartext mode, so no listener was started.", endPoint.Port); return serverConfig; } - if (!TryResolveQuicCertificate(security, quicEndPoint.Port, out var certPath, out var keyPath)) + if (!TryResolveQuicCertificate(quicEndPoint.Security, quicEndPoint.Port, out var certPath, out var keyPath)) { return serverConfig; } _quicEngine = new QuicEngine(certPath, keyPath, alpn: ["h3"], - clientCaPemPath: _engineOptions.MutualTls.ClientCaPath, - requireClientCertificate: RequiresClientCertificate(security)); + clientCaPemPath: quicEndPoint.ClientCaPath, + requireClientCertificate: quicEndPoint.RequireClientCertificate); // Built once here, not in the QuicHandle below - that runs per accepted connection, and // these two never change. Nghttp3Options is ngtcp2's own record; Http3Options is diff --git a/Engine/Ioxide/Infrastructure/Server.Tcp.Tls.cs b/Engine/Ioxide/Infrastructure/Server.Tcp.Tls.cs index cb3e9ad7f..01c44be3f 100644 --- a/Engine/Ioxide/Infrastructure/Server.Tcp.Tls.cs +++ b/Engine/Ioxide/Infrastructure/Server.Tcp.Tls.cs @@ -1,7 +1,6 @@ using System.Security.Cryptography.X509Certificates; using GenHTTP.Engine.Ioxide.Infrastructure.Endpoints; -using GenHTTP.Engine.Shared.Infrastructure; using ioxide.tls; @@ -25,7 +24,7 @@ private IEnumerable> ResolveTls() { var port = endPoint.Port; - if (endPoint.Security!.CertificateProvider.Provide(null) is not { } certificate) + if (endPoint.Security.CertificateProvider.Provide(null) is not { } certificate) { _logger.LogWarning("No default certificate for secure port {Port}; handshakes there will be refused (SNI selection is unsupported).", port); continue; @@ -40,9 +39,9 @@ private IEnumerable> ResolveTls() // without an ALPN extension at all. Alpn = endPoint.Protocols.HasFlag(Protocols.Http2) ? ["h2", "http/1.1"] : ["http/1.1"], - ClientCaPath = _engineOptions.MutualTls.ClientCaPath, - ClientCaPem = _engineOptions.MutualTls.ClientCaPem, - RequireClientCertificate = RequiresClientCertificate(endPoint.Security), + ClientCaPath = endPoint.ClientCaPath, + ClientCaPem = endPoint.ClientCaPem, + RequireClientCertificate = endPoint.RequireClientCertificate, KernelTx = _engineOptions.Tcp.TxKernelTls, KernelRx = _engineOptions.Tcp.RxKernelTls @@ -50,28 +49,13 @@ private IEnumerable> ResolveTls() } } - /// - /// Whether a client offering no certificate is refused on this endpoint: either the engine says - /// so for every endpoint, or the endpoint's own validator does. A validator that only wants to - /// inspect what arrives still gets asked, because the CertificateRequest goes out either way. - /// - /// - /// Shared with the QUIC half, which asks the same question of ngtcp2 - mutual TLS is the one - /// setting that means the same thing on both transports. - /// - private bool RequiresClientCertificate(SecurityConfiguration security) - => _engineOptions.MutualTls.RequireClientCertificate || security.CertificateValidator?.RequireCertificate == true; - /// /// Whether any endpoint asks for a client certificate at all. /// - private bool MutualTlsConfigured - => _engineOptions.MutualTls.ClientCaPath is not null || _engineOptions.MutualTls.ClientCaPem is not null - || _engineOptions.MutualTls.RequireClientCertificate - || SecureEndPoints.Any(e => e.Security!.CertificateValidator is not null); + private bool MutualTlsConfigured => SecureEndPoints.Any(e => e.MutualTls); /// The endpoints bound with a certificate - the ones TLS applies to. - private IEnumerable SecureEndPoints => _endPoints.Where(e => e.Security is not null); + private IEnumerable SecureEndPoints => _endPoints.OfType(); private static string ExportKeyPem(X509Certificate2 certificate) => certificate.GetRSAPrivateKey()?.ExportPkcs8PrivateKeyPem() diff --git a/Engine/Ioxide/Infrastructure/Server.cs b/Engine/Ioxide/Infrastructure/Server.cs index d736c2df0..619e64259 100644 --- a/Engine/Ioxide/Infrastructure/Server.cs +++ b/Engine/Ioxide/Infrastructure/Server.cs @@ -195,9 +195,7 @@ public async ValueTask StartAsync() /// private static EndPoint[] MapEndPoints(ServerConfiguration config, EngineOptions options) { - var mapped = config.EndPoints - .Select(e => new EndPoint(e.Address, e.Port, e.DualStack, e.Security, ResolveProtocols(options, e))) - .ToArray(); + var mapped = config.EndPoints.Select(e => Map(e, options)).ToArray(); // A connection carries only the port it arrived on, so that port has to name one endpoint. // Checked explicitly because nothing else keys them by port any more. @@ -264,6 +262,19 @@ private async ValueTask PrepareHandlerAsync() } } + /// + /// One binding as the engine's own endpoint. A certificate makes it a , + /// which is what carries the TLS settings; without one it is cleartext and has none to carry. + /// + private static EndPoint Map(EndPointConfiguration endPoint, EngineOptions options) + { + var protocols = ResolveProtocols(options, endPoint); + + return endPoint.Security is { } security + ? new SecureEndPoint(endPoint.Address, endPoint.Port, endPoint.DualStack, protocols, security, options.MutualTls) + : new InsecureEndPoint(endPoint.Address, endPoint.Port, endPoint.DualStack, protocols); + } + /// /// What one endpoint serves: the default, its port's override, and its own enableQuic flag. /// From 4c13d48619dbeb21bafdf914d3a97d227f8e1666 Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Mon, 17 Aug 2026 15:36:13 +0100 Subject: [PATCH 50/55] feat(ioxide): client certificates per port, so two secure ports can trust different issuers MutualTlsOptions was engine-wide and had no per-endpoint form, because GenHTTP's Bind carries a certificate provider, protocols and a validator but no trust bundle. That left one set of anchors for every secure endpoint: a server fronting two audiences on two ports had to validate both against the same issuers, or bind two hosts. MutualTlsByPort is the override, shaped exactly like ProtocolsByPort - name the port, give it its own MutualTlsOptions, and the engine-wide MutualTls covers the rest. Taken whole rather than merged, for the same reason ProtocolsByPort is: a named port that inherited the halves it left unset would make a bundle appear on an endpoint that named none. It resolves in Map, so SecureEndPoint carries its own anchors and the transports go on reading the endpoint rather than the options. 08163047 moved the answer onto the endpoint; this gives the answer somewhere per-endpoint to come from. Untested by the acceptance suite: the client-certificate tests are skipped for this engine by a guard in their helper that still says TLS termination is not implemented, which stopped being true. --- Engine/Ioxide/EngineOptions.cs | 24 +++++++++++++------ .../Endpoints/SecureEndPoint.cs | 7 +++--- Engine/Ioxide/Infrastructure/Server.cs | 13 +++++++--- 3 files changed, 31 insertions(+), 13 deletions(-) diff --git a/Engine/Ioxide/EngineOptions.cs b/Engine/Ioxide/EngineOptions.cs index 9a7ad9958..a3f4ed93c 100644 --- a/Engine/Ioxide/EngineOptions.cs +++ b/Engine/Ioxide/EngineOptions.cs @@ -31,8 +31,18 @@ public sealed record EngineOptions /// The HTTP/3 endpoint: the certificate QUIC serves, and QPACK. public Http3Options Http3 { get; init; } = new(); - /// Client certificates: what they are validated against, and whether one is required. + /// + /// Client certificates: what they are validated against, and whether one is required. Applies + /// to every secure endpoint unless names one. + /// public MutualTlsOptions MutualTls { get; init; } = new(); + + /// + /// Client certificates for one port, overriding whole - a named port + /// takes none of the engine-wide settings, the way does with + /// . This is what lets two secure ports trust different issuers. + /// + public Dictionary MutualTlsByPort { get; init; } = []; } /// @@ -157,10 +167,10 @@ public sealed record Http3Options /// /// What client certificates are validated against - by OpenSSL for HTTP/1.1 and HTTP/2, by ngtcp2 /// for HTTP/3, so a bad chain is refused before any request exists. WHICH endpoints ask for one is -/// decided per endpoint, by the certificateValidator passed to Bind. Configured here -/// for the whole engine because Bind takes no bundle of its own, then resolved onto each -/// secure endpoint as the server is built - see SecureEndPoint, which is what the transports -/// read. +/// decided per endpoint, by the certificateValidator passed to Bind. Set on +/// EngineOptions.MutualTls for every secure endpoint, or per port through +/// EngineOptions.MutualTlsByPort; either way it is resolved onto each SecureEndPoint +/// as the server is built, and read from there rather than from the options. /// public sealed record MutualTlsOptions { @@ -175,8 +185,8 @@ public sealed record MutualTlsOptions public string? ClientCaPem { get; init; } /// - /// Refuse a client that offers no certificate, on every secure endpoint; false still asks for - /// one and validates what arrives. Usually left alone, since an endpoint's + /// Refuse a client that offers no certificate, on the endpoints this applies to; false still + /// asks for one and validates what arrives. Usually left alone, since an endpoint's /// certificateValidator raises it for that endpoint. The two are ORed. /// public bool RequireClientCertificate { get; init; } diff --git a/Engine/Ioxide/Infrastructure/Endpoints/SecureEndPoint.cs b/Engine/Ioxide/Infrastructure/Endpoints/SecureEndPoint.cs index 07029e96d..d8a004f59 100644 --- a/Engine/Ioxide/Infrastructure/Endpoints/SecureEndPoint.cs +++ b/Engine/Ioxide/Infrastructure/Endpoints/SecureEndPoint.cs @@ -10,9 +10,10 @@ namespace GenHTTP.Engine.Ioxide.Infrastructure.Endpoints; /// so this carries the settings rather than performing it. /// /// -/// The trust anchors are configured once for the engine, since GenHTTP's Bind takes no -/// bundle per endpoint, but whether a client certificate is demanded is settled here: the engine's -/// flag and the binding's own validator are ORed at construction rather than at each use. +/// The trust anchors come from EngineOptions - the engine-wide ones, or the set named for +/// this port - since GenHTTP's Bind carries no bundle of its own. Whether a client +/// certificate is demanded is settled here: that setting and the binding's own validator are ORed +/// at construction rather than at each use. /// internal sealed class SecureEndPoint : EndPoint { diff --git a/Engine/Ioxide/Infrastructure/Server.cs b/Engine/Ioxide/Infrastructure/Server.cs index 619e64259..96705f44c 100644 --- a/Engine/Ioxide/Infrastructure/Server.cs +++ b/Engine/Ioxide/Infrastructure/Server.cs @@ -270,9 +270,16 @@ private static EndPoint Map(EndPointConfiguration endPoint, EngineOptions option { var protocols = ResolveProtocols(options, endPoint); - return endPoint.Security is { } security - ? new SecureEndPoint(endPoint.Address, endPoint.Port, endPoint.DualStack, protocols, security, options.MutualTls) - : new InsecureEndPoint(endPoint.Address, endPoint.Port, endPoint.DualStack, protocols); + if (endPoint.Security is not { } security) + { + return new InsecureEndPoint(endPoint.Address, endPoint.Port, endPoint.DualStack, protocols); + } + + // Named per port or the engine-wide one, taken whole either way - a named port does not + // inherit the halves it left unset, matching how ProtocolsByPort overrides Protocols. + var mutualTls = options.MutualTlsByPort.GetValueOrDefault(endPoint.Port) ?? options.MutualTls; + + return new SecureEndPoint(endPoint.Address, endPoint.Port, endPoint.DualStack, protocols, security, mutualTls); } /// From 4fea67c81b8025ca2a546b49ba26fe6816fc771f Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Mon, 17 Aug 2026 15:44:15 +0100 Subject: [PATCH 51/55] refactor(ioxide): mutual TLS comes off the binding, not the engine options The engine held what client certificates are validated against while the endpoint held whether one was asked for, and the two were ORed at each use. Both are facts about a single binding, and IServerHost already carries them there: Bind takes a certificateValidator, which is GenHTTP's own per-endpoint client-certificate hook. What that hook could not carry is the trust anchors. ICertificateValidator is handed a chain that has already been built, which suits an engine validating in managed code; ioxide validates in OpenSSL, and in ngtcp2 for HTTP/3, both of which need the anchors before the handshake starts. IMutualTlsValidator adds them to the validator, so an endpoint that wants mutual TLS names its issuers on the binding that asked for it. EngineOptions.MutualTls, MutualTlsByPort and MutualTlsOptions are gone. Per-port anchors were the whole point of MutualTlsByPort one commit ago; they now fall out of where the settings live, with no second table keyed by port to resolve against. RequireClientCertificate stops being an OR of two sources, there being one now, and MutualTls collapses to Security.CertificateValidator is not null - everything mutual TLS needs arrives on a validator, so having one is what it means to want it. One behaviour change beyond the move: a secure endpoint bound without a validator used to inherit the engine-wide client CA, handing OpenSSL a trust store for a port that never asked for client certificates. It gets none now. In the playground that is 8443, which the comment there already described as staying open while 8444 requires one. Breaking for anyone setting EngineOptions.MutualTls: the CA moves onto the validator passed to Bind. Still untested by the acceptance suite, whose client-certificate tests are skipped for this engine. --- Engine/Ioxide/EngineOptions.cs | 41 ------------------- Engine/Ioxide/IMutualTlsValidator.cs | 27 ++++++++++++ .../Endpoints/SecureEndPoint.cs | 30 +++++++------- Engine/Ioxide/Infrastructure/Server.cs | 13 ++---- Playground/Program.cs | 25 +++++------ 5 files changed, 56 insertions(+), 80 deletions(-) create mode 100644 Engine/Ioxide/IMutualTlsValidator.cs diff --git a/Engine/Ioxide/EngineOptions.cs b/Engine/Ioxide/EngineOptions.cs index a3f4ed93c..196f41bae 100644 --- a/Engine/Ioxide/EngineOptions.cs +++ b/Engine/Ioxide/EngineOptions.cs @@ -30,19 +30,6 @@ public sealed record EngineOptions /// The HTTP/3 endpoint: the certificate QUIC serves, and QPACK. public Http3Options Http3 { get; init; } = new(); - - /// - /// Client certificates: what they are validated against, and whether one is required. Applies - /// to every secure endpoint unless names one. - /// - public MutualTlsOptions MutualTls { get; init; } = new(); - - /// - /// Client certificates for one port, overriding whole - a named port - /// takes none of the engine-wide settings, the way does with - /// . This is what lets two secure ports trust different issuers. - /// - public Dictionary MutualTlsByPort { get; init; } = []; } /// @@ -163,31 +150,3 @@ public sealed record Http3Options /// public long QpackBlockedStreams { get; init; } } - -/// -/// What client certificates are validated against - by OpenSSL for HTTP/1.1 and HTTP/2, by ngtcp2 -/// for HTTP/3, so a bad chain is refused before any request exists. WHICH endpoints ask for one is -/// decided per endpoint, by the certificateValidator passed to Bind. Set on -/// EngineOptions.MutualTls for every secure endpoint, or per port through -/// EngineOptions.MutualTlsByPort; either way it is resolved onto each SecureEndPoint -/// as the server is built, and read from there rather than from the options. -/// -public sealed record MutualTlsOptions -{ - /// - /// PEM bundle of trust anchors that client certificates are validated against, as a path. Its - /// subject names are also sent in the CertificateRequest, so a client holding several - /// certificates can pick the right one; sends no such hint. - /// - public string? ClientCaPath { get; init; } - - /// The trust anchors as PEM text - the in-memory alternative to . - public string? ClientCaPem { get; init; } - - /// - /// Refuse a client that offers no certificate, on the endpoints this applies to; false still - /// asks for one and validates what arrives. Usually left alone, since an endpoint's - /// certificateValidator raises it for that endpoint. The two are ORed. - /// - public bool RequireClientCertificate { get; init; } -} diff --git a/Engine/Ioxide/IMutualTlsValidator.cs b/Engine/Ioxide/IMutualTlsValidator.cs new file mode 100644 index 000000000..d28886d27 --- /dev/null +++ b/Engine/Ioxide/IMutualTlsValidator.cs @@ -0,0 +1,27 @@ +using GenHTTP.Api.Infrastructure; + +namespace GenHTTP.Engine.Ioxide; + +/// +/// A client-certificate validator that also names what the offered chain is validated against. +/// Pass one to Bind and that endpoint does mutual TLS against these anchors. +/// +/// +/// is called with a chain that has already been built, which +/// suits an engine validating in managed code. ioxide validates in OpenSSL, and in ngtcp2 for +/// HTTP/3, both of which need the trust anchors before the handshake begins - early enough that a +/// bad chain is refused before any request exists, and Validate is never reached. So the +/// anchors travel with the validator, on the binding that wanted them. +/// +public interface IMutualTlsValidator : ICertificateValidator +{ + /// + /// PEM bundle of trust anchors, as a path. Its subject names are also sent in the + /// CertificateRequest, so a client holding several certificates can pick the right one; + /// sends no such hint. + /// + string? ClientCaPath => null; + + /// The trust anchors as PEM text - the in-memory alternative to . + string? ClientCaPem => null; +} diff --git a/Engine/Ioxide/Infrastructure/Endpoints/SecureEndPoint.cs b/Engine/Ioxide/Infrastructure/Endpoints/SecureEndPoint.cs index d8a004f59..ebda0ea4a 100644 --- a/Engine/Ioxide/Infrastructure/Endpoints/SecureEndPoint.cs +++ b/Engine/Ioxide/Infrastructure/Endpoints/SecureEndPoint.cs @@ -10,24 +10,26 @@ namespace GenHTTP.Engine.Ioxide.Infrastructure.Endpoints; /// so this carries the settings rather than performing it. /// /// -/// The trust anchors come from EngineOptions - the engine-wide ones, or the set named for -/// this port - since GenHTTP's Bind carries no bundle of its own. Whether a client -/// certificate is demanded is settled here: that setting and the binding's own validator are ORed -/// at construction rather than at each use. +/// Everything here comes off the binding: the certificate from Bind, and the mutual-TLS +/// settings from the validator passed alongside it. A validator asks for a client certificate; an +/// also names what the chain is validated against. Read once +/// here rather than at each use, so the transports ask the endpoint and nothing else. /// internal sealed class SecureEndPoint : EndPoint { internal SecureEndPoint(IPAddress? address, ushort port, bool dualStack, Protocols protocols, - SecurityConfiguration security, MutualTlsOptions mutualTls) + SecurityConfiguration security) : base(address, port, dualStack, protocols) { Security = security; - ClientCaPath = mutualTls.ClientCaPath; - ClientCaPem = mutualTls.ClientCaPem; + RequireClientCertificate = security.CertificateValidator?.RequireCertificate == true; - RequireClientCertificate = mutualTls.RequireClientCertificate - || security.CertificateValidator?.RequireCertificate == true; + if (security.CertificateValidator is IMutualTlsValidator mutualTls) + { + ClientCaPath = mutualTls.ClientCaPath; + ClientCaPem = mutualTls.ClientCaPem; + } } public override bool Secure => true; @@ -51,9 +53,9 @@ internal SecureEndPoint(IPAddress? address, ushort port, bool dualStack, Protoco /// public bool RequireClientCertificate { get; } - /// Whether this endpoint asks for a client certificate at all. - public bool MutualTls => RequireClientCertificate - || ClientCaPath is not null - || ClientCaPem is not null - || Security.CertificateValidator is not null; + /// + /// Whether this endpoint asks for a client certificate at all - which is exactly whether the + /// binding named a validator, since everything mutual TLS needs now arrives on one. + /// + public bool MutualTls => Security.CertificateValidator is not null; } diff --git a/Engine/Ioxide/Infrastructure/Server.cs b/Engine/Ioxide/Infrastructure/Server.cs index 96705f44c..da650baa4 100644 --- a/Engine/Ioxide/Infrastructure/Server.cs +++ b/Engine/Ioxide/Infrastructure/Server.cs @@ -270,16 +270,9 @@ private static EndPoint Map(EndPointConfiguration endPoint, EngineOptions option { var protocols = ResolveProtocols(options, endPoint); - if (endPoint.Security is not { } security) - { - return new InsecureEndPoint(endPoint.Address, endPoint.Port, endPoint.DualStack, protocols); - } - - // Named per port or the engine-wide one, taken whole either way - a named port does not - // inherit the halves it left unset, matching how ProtocolsByPort overrides Protocols. - var mutualTls = options.MutualTlsByPort.GetValueOrDefault(endPoint.Port) ?? options.MutualTls; - - return new SecureEndPoint(endPoint.Address, endPoint.Port, endPoint.DualStack, protocols, security, mutualTls); + return endPoint.Security is { } security + ? new SecureEndPoint(endPoint.Address, endPoint.Port, endPoint.DualStack, protocols, security) + : new InsecureEndPoint(endPoint.Address, endPoint.Port, endPoint.DualStack, protocols); } /// diff --git a/Playground/Program.cs b/Playground/Program.cs index 7f6732ffa..f4efc2b48 100644 --- a/Playground/Program.cs +++ b/Playground/Program.cs @@ -131,14 +131,6 @@ await Host.Create( RecvQueueEntries = 64, }, - MutualTls = new MutualTlsOptions - { - // What an offered client certificate is validated against. WHICH ports ask - // for one is decided per endpoint, by the validator passed to Bind - so 8443 - // stays open while 8444 requires a certificate. - ClientCaPath = clientCa, - }, - Http3 = new Http3Options { // Bytes of QPACK dynamic table offered to HTTP/3 clients. 0 keeps every @@ -162,7 +154,7 @@ await Host.Create( .Bind(IPAddress.Loopback, 8082) .Bind(IPAddress.Loopback, 8443, certificate) // mTLS - .Bind(IPAddress.Loopback, 8444, certificate, certificateValidator: new RequireClientCertificate()) + .Bind(IPAddress.Loopback, 8444, certificate, certificateValidator: new RequireClientCertificate(clientCa)) .RunAsync(); /// @@ -277,18 +269,21 @@ static void WritePrivateKey(string path, string pem) } /// -/// Marks an endpoint as requiring a client certificate. +/// Marks an endpoint as requiring a client certificate, and names the CA the offered chain is +/// validated against. Both travel with the endpoint, so another binding can require none or trust +/// a different issuer. /// /// -/// The ioxide engine reads and lets OpenSSL (or ngtcp2 on HTTP/3) -/// validate the offered chain against the configured client CA, so a bad chain is refused before a -/// request exists and is never called. Returning true here would not admit -/// anyone the CA had already rejected. +/// OpenSSL (or ngtcp2 on HTTP/3) validates the chain against that CA, so a bad one is refused +/// before a request exists and is never called. Returning true here would +/// not admit anyone the CA had already rejected. /// -internal sealed class RequireClientCertificate : ICertificateValidator +internal sealed class RequireClientCertificate(string clientCaPath) : IMutualTlsValidator { public bool RequireCertificate => true; + public string? ClientCaPath => clientCaPath; + public X509RevocationMode RevocationCheck => X509RevocationMode.NoCheck; public bool Validate(X509Certificate? certificate, X509Chain? chain, SslPolicyErrors policyErrors) => true; From 64f80228399ab71ee3a36d0bcaa3429be765a0ab Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Mon, 17 Aug 2026 17:01:12 +0100 Subject: [PATCH 52/55] fix(ioxide): send the certificate's chain, not only its leaf ResolveTls handed ioxide certificate.ExportCertificatePem(), which exports one certificate. Anything issued by a real CA is signed by an intermediate, and a client that does not already hold that intermediate cannot build a path to a root it trusts - so the handshake fails, or the certificate is reported untrusted, for every client without it cached. Nothing caught it because a self-signed certificate is leaf and root at once, which is what the playground and the tests use. It would have shown up the first time someone pointed this engine at a certificate from an actual issuer. The Internal engine never had the bug: SslStream assembles the chain itself. This engine terminates TLS on its own, so ExportChainPem assembles it here, leaf first, with the root left off - a client that does not already trust the root will not start because the server sent it, and it is bytes on every handshake. Two limits worth knowing. ICertificateProvider hands back a single X509Certificate2, which cannot carry a chain at all, so the intermediate has to be findable in the machine store; when it is not, the leaf goes out alone as before, but a warning now names the port and subject rather than saying nothing. And certificate downloads are off, because fetching a missing intermediate over AIA would put a network call on the startup path - an unreachable host there is a hung server, not a slow one. --- .../Ioxide/Infrastructure/Server.Tcp.Tls.cs | 71 ++++++++++++++++++- 1 file changed, 70 insertions(+), 1 deletion(-) diff --git a/Engine/Ioxide/Infrastructure/Server.Tcp.Tls.cs b/Engine/Ioxide/Infrastructure/Server.Tcp.Tls.cs index 01c44be3f..8ad69aae7 100644 --- a/Engine/Ioxide/Infrastructure/Server.Tcp.Tls.cs +++ b/Engine/Ioxide/Infrastructure/Server.Tcp.Tls.cs @@ -1,4 +1,5 @@ using System.Security.Cryptography.X509Certificates; +using System.Text; using GenHTTP.Engine.Ioxide.Infrastructure.Endpoints; @@ -32,7 +33,7 @@ private IEnumerable> ResolveTls() yield return new(port, new TlsOptions { - CertificatePem = certificate.ExportCertificatePem(), + CertificatePem = ExportChainPem(certificate, port), KeyPem = ExportKeyPem(certificate), // Server preference, most preferred first. A client offering neither continues @@ -57,6 +58,74 @@ private IEnumerable> ResolveTls() /// The endpoints bound with a certificate - the ones TLS applies to. private IEnumerable SecureEndPoints => _endPoints.OfType(); + /// + /// The certificate and the intermediates a client needs to reach a root it trusts, leaf first. + /// + /// + /// ICertificateProvider hands over one certificate, but anything issued by a real + /// CA is signed by an intermediate, and a client that does not already hold that intermediate + /// cannot build a path to its root - so a server sends them (RFC 8446 4.4.2). The Internal + /// engine gets this for free from SslStream, which assembles the chain itself; this + /// engine terminates TLS on its own, so it assembles it here. + /// + /// The root is left out deliberately: a client that does not already trust it will not start + /// because we sent it, and it is bytes on every handshake. + /// + /// Certificate downloads are off. Fetching a missing intermediate over AIA would put a network + /// call on the startup path, where a slow or unreachable host is a hung server rather than a + /// slow one - the intermediate is expected in the machine store beside the certificate. + /// + private string ExportChainPem(X509Certificate2 certificate, ushort port) + { + using var chain = new X509Chain(); + + chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; + chain.ChainPolicy.VerificationFlags = X509VerificationFlags.AllFlags; + chain.ChainPolicy.DisableCertificateDownloads = true; + + // Built for its elements, not its verdict: a privately issued or self-signed certificate + // does not validate against this machine's roots and still carries the chain to send. + chain.Build(certificate); + + var links = chain.ChainElements; + + if (links.Count <= 1) + { + // Self-signed is the ordinary case here - leaf and root at once, nothing to add. Any + // other certificate arriving alone means its issuer was not found, and the handshake + // will fail for clients that cannot supply the gap themselves. + if (!IsSelfIssued(certificate)) + { + _logger.LogWarning( + "No issuer chain found for the certificate on port {Port} ({Subject}), so only the leaf will be sent. " + + "Clients without its intermediates cached will refuse the handshake; install them in the machine store.", + port, certificate.Subject); + } + + return certificate.ExportCertificatePem(); + } + + var pem = new StringBuilder(); + + for (var i = 0; i < links.Count; i++) + { + var link = links[i].Certificate; + + if (i == links.Count - 1 && IsSelfIssued(link)) + { + break; + } + + pem.AppendLine(link.ExportCertificatePem()); + } + + return pem.ToString(); + } + + /// Whether a certificate is its own issuer, which is what makes it a root. + private static bool IsSelfIssued(X509Certificate2 certificate) + => certificate.SubjectName.RawData.AsSpan().SequenceEqual(certificate.IssuerName.RawData); + private static string ExportKeyPem(X509Certificate2 certificate) => certificate.GetRSAPrivateKey()?.ExportPkcs8PrivateKeyPem() ?? certificate.GetECDsaPrivateKey()?.ExportPkcs8PrivateKeyPem() From 3461cf6236777825287536855e0f243c8a0fed62 Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Mon, 17 Aug 2026 17:01:12 +0100 Subject: [PATCH 53/55] fix(ioxide): refuse an endpoint that requires a client certificate but trusts nothing ICertificateValidator.RequireCertificate defaults to TRUE, so any validator that does not override it asks for a client certificate. Since 4fea67c8 the trust anchors travel on the validator too, which means a plain ICertificateValidator - not an IMutualTlsValidator - now means "require a certificate, validate it against nothing". ioxide refuses that combination, correctly: TlsService throws when RequireClientCertificate is set with no anchors. But it throws where it is built, on a reactor thread, part-way through StartAsync - so a configuration mistake surfaced as a crash from inside the engine rather than as an answer about the binding. MapEndPoints refuses it up front instead, naming the port and what to do about it, alongside the duplicate-port and dual-stack checks that were already there. --- Engine/Ioxide/Infrastructure/Server.cs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/Engine/Ioxide/Infrastructure/Server.cs b/Engine/Ioxide/Infrastructure/Server.cs index da650baa4..831b8d7d5 100644 --- a/Engine/Ioxide/Infrastructure/Server.cs +++ b/Engine/Ioxide/Infrastructure/Server.cs @@ -206,6 +206,21 @@ private static EndPoint[] MapEndPoints(ServerConfiguration config, EngineOptions + "by the port it arrived on, so each port carries one endpoint."); } + // ioxide validates a client chain in OpenSSL and ngtcp2, which need the anchors before the + // handshake. ICertificateValidator.RequireCertificate defaults to TRUE, so a validator that + // names none is the easy mistake to make - and it would otherwise surface as an exception + // out of TlsService, on a reactor thread, halfway through starting the server. + foreach (var endPoint in mapped.OfType()) + { + if (endPoint.RequireClientCertificate && endPoint.ClientCaPath is null && endPoint.ClientCaPem is null) + { + throw new NotSupportedException( + $"Port {endPoint.Port} requires a client certificate but names nothing to validate one against. " + + $"Pass an {nameof(IMutualTlsValidator)} to Bind with ClientCaPath or ClientCaPem set, or leave " + + "RequireCertificate false to let the connection in and decide in the handler."); + } + } + var dualStack = mapped[0].DualStack; // No disagreement between DualStack capability among endpoints is supported as of today From 9d6ab1ddc5c84715ac0f4bf59e5efae4dae48726 Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Mon, 17 Aug 2026 17:19:20 +0100 Subject: [PATCH 54/55] docs(ioxide): say what each transport takes a certificate as, and where they differ The two halves of this engine accept different things in different forms, for a reason that is invisible from either one alone: OpenSSL is handed the certificate as data, ngtcp2 loads it by path. So the server certificate arrives as an X509Certificate2 on TCP and as a file path on HTTP/3, and the HTTP/3 one has to be named a second time on EngineOptions.Http3 rather than being taken from the binding. SecureEndPoint is where both transports read from, so the table belongs on it. It also records the one place they disagree on the same setting: ClientCaPem reaches OpenSSL and is dropped on the way to ngtcp2, so an endpoint serving both validates clients on TCP and not on QUIC. ioxide 0.5.192 takes PEM text for QUIC; this engine references 0.4.186 and closes it at that bump. --- .../Endpoints/SecureEndPoint.cs | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/Engine/Ioxide/Infrastructure/Endpoints/SecureEndPoint.cs b/Engine/Ioxide/Infrastructure/Endpoints/SecureEndPoint.cs index ebda0ea4a..344c2fbf3 100644 --- a/Engine/Ioxide/Infrastructure/Endpoints/SecureEndPoint.cs +++ b/Engine/Ioxide/Infrastructure/Endpoints/SecureEndPoint.cs @@ -14,6 +14,40 @@ namespace GenHTTP.Engine.Ioxide.Infrastructure.Endpoints; /// settings from the validator passed alongside it. A validator asks for a client certificate; an /// also names what the chain is validated against. Read once /// here rather than at each use, so the transports ask the endpoint and nothing else. +/// +/// +/// What each transport accepts, and in what form. The two do not match, because OpenSSL is handed +/// the certificate as data while ngtcp2 loads it by path: +/// +/// +/// +/// TCP (HTTP/1.1, HTTP/2) HTTP/3 (QUIC) +/// server certificate X509Certificate2, from Bind PEM file, Http3.CertificatePath +/// server key exported from that certificate PEM file, Http3.KeyPath +/// issuer chain built here, root omitted whatever that PEM file holds +/// client trust anchors ClientCaPath or ClientCaPem ClientCaPath only +/// demand a client cert ICertificateValidator.RequireCertificate, on both +/// +/// +/// +/// Three consequences of that table. The HTTP/3 certificate is named a SECOND time, on +/// EngineOptions.Http3, because ngtcp2 takes paths and the engine will not write a private +/// key out on anyone's behalf - it should be the same certificate the endpoint is bound with, and +/// the QUIC half warns when the thumbprints disagree. +/// +/// +/// +/// The issuer chain is assembled for TCP only. ICertificateProvider yields a single +/// certificate, which cannot carry intermediates, so they are recovered from the machine store and +/// sent leaf-first with the root left off; HTTP/3 sends whatever the configured PEM file contains. +/// +/// +/// +/// And is DROPPED on HTTP/3. ioxide gained PEM-text anchors in 0.5.192 +/// and this engine still references 0.4.186, so until that bump an endpoint serving both transports +/// validates clients over TCP and lets them through unvalidated over QUIC. Anchors given as a path +/// apply to both. +/// /// internal sealed class SecureEndPoint : EndPoint { From eaec0a5900db119e377ae5b7df5bb6e7840413cd Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Mon, 17 Aug 2026 17:24:17 +0100 Subject: [PATCH 55/55] fix(ioxide): 0.5.192, and client anchors as PEM text reach HTTP/3 too The engine offered ClientCaPath and ClientCaPem alike, but only the path survived the trip to QUIC: ngtcp2 took a path and nothing else, so WithQuic had nothing to hand it the text form through. An endpoint serving Protocols.All therefore validated client certificates over HTTP/1.1 and HTTP/2 and let every client through unvalidated over HTTP/3 - the same origin, two answers, and no warning either way, since from the QUIC side it looked like an endpoint that had asked for no client verification at all. ioxide 0.5.192 takes the anchors as text for QUIC as well, so WithQuic passes ClientCaPem and the setting means one thing on both transports. The reference moves for ioxide.file too, which was still on 0.4.186 alongside the rest. --- Engine/Ioxide/GenHTTP.Engine.Ioxide.csproj | 8 ++++---- .../Ioxide/Infrastructure/Endpoints/SecureEndPoint.cs | 10 +++++----- Engine/Ioxide/Infrastructure/Server.Quic.cs | 3 ++- Modules/IoxideFiles/GenHTTP.Modules.IoxideFiles.csproj | 2 +- 4 files changed, 12 insertions(+), 11 deletions(-) diff --git a/Engine/Ioxide/GenHTTP.Engine.Ioxide.csproj b/Engine/Ioxide/GenHTTP.Engine.Ioxide.csproj index 1ebdab7cf..958b17f7d 100644 --- a/Engine/Ioxide/GenHTTP.Engine.Ioxide.csproj +++ b/Engine/Ioxide/GenHTTP.Engine.Ioxide.csproj @@ -10,12 +10,12 @@ - + - - - + + + diff --git a/Engine/Ioxide/Infrastructure/Endpoints/SecureEndPoint.cs b/Engine/Ioxide/Infrastructure/Endpoints/SecureEndPoint.cs index 344c2fbf3..b326ba722 100644 --- a/Engine/Ioxide/Infrastructure/Endpoints/SecureEndPoint.cs +++ b/Engine/Ioxide/Infrastructure/Endpoints/SecureEndPoint.cs @@ -25,7 +25,7 @@ namespace GenHTTP.Engine.Ioxide.Infrastructure.Endpoints; /// server certificate X509Certificate2, from Bind PEM file, Http3.CertificatePath /// server key exported from that certificate PEM file, Http3.KeyPath /// issuer chain built here, root omitted whatever that PEM file holds -/// client trust anchors ClientCaPath or ClientCaPem ClientCaPath only +/// client trust anchors ClientCaPath or ClientCaPem ClientCaPath or ClientCaPem /// demand a client cert ICertificateValidator.RequireCertificate, on both /// /// @@ -43,10 +43,10 @@ namespace GenHTTP.Engine.Ioxide.Infrastructure.Endpoints; /// /// /// -/// And is DROPPED on HTTP/3. ioxide gained PEM-text anchors in 0.5.192 -/// and this engine still references 0.4.186, so until that bump an endpoint serving both transports -/// validates clients over TCP and lets them through unvalidated over QUIC. Anchors given as a path -/// apply to both. +/// Client anchors are the one setting that reads the same on both, in either form. That took +/// ioxide 0.5.192: before it, ngtcp2 took only a path, so reached OpenSSL +/// and was dropped on the way to QUIC - an endpoint serving both transports validated clients over +/// TCP and let them through unvalidated over HTTP/3, saying nothing about it. /// /// internal sealed class SecureEndPoint : EndPoint diff --git a/Engine/Ioxide/Infrastructure/Server.Quic.cs b/Engine/Ioxide/Infrastructure/Server.Quic.cs index 7f212c90f..fcbcb6f58 100644 --- a/Engine/Ioxide/Infrastructure/Server.Quic.cs +++ b/Engine/Ioxide/Infrastructure/Server.Quic.cs @@ -63,7 +63,8 @@ private ServerConfig WithQuic(ServerConfig serverConfig) _quicEngine = new QuicEngine(certPath, keyPath, alpn: ["h3"], clientCaPemPath: quicEndPoint.ClientCaPath, - requireClientCertificate: quicEndPoint.RequireClientCertificate); + requireClientCertificate: quicEndPoint.RequireClientCertificate, + clientCaPem: quicEndPoint.ClientCaPem); // Built once here, not in the QuicHandle below - that runs per accepted connection, and // these two never change. Nghttp3Options is ngtcp2's own record; Http3Options is diff --git a/Modules/IoxideFiles/GenHTTP.Modules.IoxideFiles.csproj b/Modules/IoxideFiles/GenHTTP.Modules.IoxideFiles.csproj index 9dedc5ea6..7a4bc1f18 100644 --- a/Modules/IoxideFiles/GenHTTP.Modules.IoxideFiles.csproj +++ b/Modules/IoxideFiles/GenHTTP.Modules.IoxideFiles.csproj @@ -19,7 +19,7 @@ - +