From 356d8dceb821c5a03a944896763e1a867fe7ad1d Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Sat, 8 Aug 2026 18:15:02 +0100 Subject: [PATCH 1/7] 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 59680062..935f2249 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 1ca861cd..0e76e3f4 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 62335033..b74098f4 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 cc6aa8fd..a5dadc6c 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 22a029dd..64a17e62 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 aafbe1cc..9a6803d4 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 ef25bc0b..10e323ab 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 0bf52cdd..00000000 --- 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 2/7] 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 0e76e3f4..63c6de1a 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 a5dadc6c..a760215a 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 10e323ab..d0b95483 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 3/7] 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 935f2249..2bfe3b2c 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 63c6de1a..9409730d 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 b74098f4..76ce2dc9 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 9a6803d4..3bfc619c 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 4/7] 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 9409730d..a4dd6544 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 a760215a..105dd75b 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 5/7] 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 2bfe3b2c..e46653b1 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 00000000..22ba712a --- /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 5173c738..5722f1a7 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 3f57ff8a..448277b3 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 5563a7f6..8db2de9a 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 00000000..17371836 --- /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 6/7] 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 8333f987..925868c3 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 7/7] 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 17371836..00000000 --- a/nuget.config +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - -