diff --git a/Engine/Ioxide/EngineOptions.cs b/Engine/Ioxide/EngineOptions.cs new file mode 100644 index 000000000..196f41bae --- /dev/null +++ b/Engine/Ioxide/EngineOptions.cs @@ -0,0 +1,152 @@ +using ioxide; + +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 EngineOptions +{ + internal static readonly EngineOptions Default = new(); + + /// + /// The protocols every endpoint serves, unless says otherwise. + /// An endpoint bound with enableQuic serves HTTP/3 whatever is set here. + /// + public Protocols Protocols { get; init; } = Protocols.Http1; + + /// + /// Protocols for one port, overriding - bind the ports, then name the + /// ones that differ: { [8081] = Protocols.Http2, [8443] = Protocols.All }. + /// + public Dictionary ProtocolsByPort { get; init; } = []; + + /// The reactors: how many, and the io_uring machinery each one owns. + public ReactorOptions Reactor { get; init; } = new(); + + /// The TCP endpoints: how TLS is terminated for HTTP/1.1 and HTTP/2. + public TcpTransportOptions Tcp { get; init; } = new(); + + /// The HTTP/3 endpoint: the certificate QUIC serves, and QPACK. + public Http3Options Http3 { 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 . +/// +public sealed record ReactorOptions +{ + /// + /// 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; } = Environment.ProcessorCount; + + /// io_uring submission and completion queue depth, per reactor. + 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; } = 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; } = 4096; + + /// + /// 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. +/// +public sealed record TcpTransportOptions +{ + /// + /// 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; } + + /// + /// 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; +} + +/// +/// The HTTP/3 endpoint. Only consulted when a port serves . +/// +public sealed record Http3Options +{ + /// + /// 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. + /// + 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. 0 keeps every header literal + /// against the static table, which costs bytes but can never stall a stream on a table update. + /// + 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/GenHTTP.Engine.Ioxide.csproj b/Engine/Ioxide/GenHTTP.Engine.Ioxide.csproj index 596800623..958b17f7d 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 @@ -10,10 +10,14 @@ - - + + + + + - + diff --git a/Engine/Ioxide/Host.cs b/Engine/Ioxide/Host.cs new file mode 100644 index 000000000..3f845d9e5 --- /dev/null +++ b/Engine/Ioxide/Host.cs @@ -0,0 +1,25 @@ +using GenHTTP.Api.Infrastructure; + +using GenHTTP.Engine.Ioxide.Infrastructure; +using ioxide; + +namespace GenHTTP.Engine.Ioxide; + +/// +/// Entry point to host an application using the ioxide io_uring engine. +/// +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. + /// + /// + /// 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, EngineOptions? options = null) + => new ServerHost(onReactorStart, options); + +} 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/Hosting/IoxideServer.cs b/Engine/Ioxide/Hosting/IoxideServer.cs deleted file mode 100644 index 1ca861cd9..000000000 --- a/Engine/Ioxide/Hosting/IoxideServer.cs +++ /dev/null @@ -1,207 +0,0 @@ -using System.Diagnostics; -using System.IO.Pipelines; - -using GenHTTP.Api.Content; -using GenHTTP.Api.Infrastructure; - -using GenHTTP.Engine.Ioxide.Protocol; -using GenHTTP.Engine.Shared.Infrastructure; -using GenHTTP.Engine.Shared.Types; - -using ioxide; -using Microsoft.Extensions.Logging; - -namespace GenHTTP.Engine.Ioxide.Hosting; - -public sealed class IoxideServer : IServer -{ - private readonly ServerConfiguration _config; - - private readonly IoxideEndPoint _endPoint; - - private readonly Func? _configure; - - private readonly Action? _onReactorStart; - - private readonly Func>? _connectionFactory; - - private readonly ILogger _logger; - - private Thread[]? _threads; - - private Reactor[]? _reactors; - - public string Version { get; } = typeof(IoxideServer).Assembly.GetName().Version?.ToString() ?? "0.1"; - - public bool Running { get; private set; } - - public bool Development => _config.DevelopmentMode; - - public IPropertyBag Properties { get; } = new PropertyBag(); - - public ILoggerFactory Logging => _config.Logging; - - public IEndPointCollection EndPoints { get; } - - public IHandler Handler { get; } - - internal IoxideServer(ServerConfiguration config, IHandler handler, Func? configure = null, Action? onReactorStart = null, Func>? connectionFactory = null) - { - _config = config; - Handler = handler; - _configure = configure; - _onReactorStart = onReactorStart; - _connectionFactory = connectionFactory; - - _logger = config.Logging.CreateLogger(); - - var ep = config.EndPoints.First(); // spike: still only SERVE the first endpoint - - _endPoint = new IoxideEndPoint(ep.Address, ep.Port, ep.DualStack, ep.Security != null); - - // 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() - ); - - var endPointCount = config.EndPoints.Count(); - - if (endPointCount > 1) - { - _logger.LogWarning("Configured with {Count} endpoints, but the ioxide engine only serves the first one ({Address}:{Port})", endPointCount, _endPoint.Address, _endPoint.Port); - } - } - - public async ValueTask StartAsync() - { - await PrepareHandlerAsync(); - - Running = true; - - var cfg = new ServerConfig { ReactorCount = Environment.ProcessorCount }; - - if (_configure is not null) - { - cfg = _configure(cfg); - } - - // The endpoint binding (.Port()/.Bind()) determines the listen port 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 - }; - - _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. - var listening = new CountdownEvent(cfg.ReactorCount); - - for (var i = 0; i < _threads.Length; i++) - { - 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); - _onReactorStart?.Invoke(r); - listening.Signal(); - }, - Handle = (_, c) => ConnectionDriver.HandleAsync(this, _endPoint, c, _connectionFactory), - }; - - _reactors[i] = reactor; - - _threads[i] = new Thread(reactor.Run) - { - Name = $"ioxide-genhttp-{i}", - IsBackground = true, - }; - - _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. - if (await Task.Run(() => listening.Wait(TimeSpan.FromSeconds(10)))) - { - listening.Dispose(); - } - else - { - _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()); - } - - private async ValueTask PrepareHandlerAsync() - { - try - { - var start = Stopwatch.GetTimestamp(); - - await Handler.PrepareAsync(this); - - var elapsed = Stopwatch.GetElapsedTime(start); - - _logger.LogInformation("Prepared handlers in {ElapsedMs:0.##} ms", elapsed.TotalMilliseconds); - } - catch (Exception e) - { - _logger.LogCritical(e, "Failed to prepare the handler chain"); - } - } - - private string DescribeSettings() => $"ioxide, {(_endPoint.Secure ? "HTTPS" : "HTTP")}, DualStack: {_endPoint.DualStack}, Reactors: {_reactors?.Length ?? 0}"; - - public async ValueTask DisposeAsync() - { - Running = false; - - var reactors = _reactors; - var threads = _threads; - - _reactors = null; - _threads = null; - - if (reactors is null || threads is null) - { - return; - } - - _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. - await Task.Run(() => - { - foreach (var reactor in reactors) - { - reactor.Stop(); - } - - foreach (var thread in threads) - { - thread.Join(TimeSpan.FromSeconds(5)); - } - }); - - _logger.LogInformation("Stopped ioxide reactors"); - } - -} diff --git a/Engine/Ioxide/Hosting/IoxideServerHost.cs b/Engine/Ioxide/Hosting/IoxideServerHost.cs deleted file mode 100644 index 62335033c..000000000 --- a/Engine/Ioxide/Hosting/IoxideServerHost.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System.IO.Pipelines; - -using GenHTTP.Api.Content; -using GenHTTP.Api.Infrastructure; -using GenHTTP.Engine.Shared.Hosting; -using GenHTTP.Engine.Shared.Infrastructure; - -using ioxide; - -namespace GenHTTP.Engine.Ioxide.Hosting; - -public sealed class IoxideServerHost(Func? configure = null, Action? onReactorStart = null, Func>? connectionFactory = null) : ServerHost -{ - - protected override IServer Build(ServerConfiguration config, IHandler handler) - => new IoxideServer(config, handler, configure, onReactorStart, connectionFactory); - -} 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/EndPoint.cs b/Engine/Ioxide/Infrastructure/Endpoints/EndPoint.cs new file mode 100644 index 000000000..db9c30789 --- /dev/null +++ b/Engine/Ioxide/Infrastructure/Endpoints/EndPoint.cs @@ -0,0 +1,29 @@ +using System.Net; + +using GenHTTP.Api.Infrastructure; + +namespace GenHTTP.Engine.Ioxide.Infrastructure.Endpoints; + +/// +/// One bound endpoint, as the engine sees it: where it listens and what it serves. Whether it is +/// secured is the subclass - or . +/// +/// +/// Nothing to dispose: the listener belongs to the reactors, which bind it themselves and tear it +/// down with their rings. +/// +internal abstract class EndPoint(IPAddress? address, ushort port, bool dualStack, Protocols protocols) : IEndPoint +{ + public IPAddress? Address => address; + + public ushort Port => port; + + public bool DualStack => dualStack; + + /// 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/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/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..b326ba722 --- /dev/null +++ b/Engine/Ioxide/Infrastructure/Endpoints/SecureEndPoint.cs @@ -0,0 +1,95 @@ +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. +/// +/// +/// 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. +/// +/// +/// 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 or ClientCaPem +/// 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. +/// +/// +/// +/// 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 +{ + internal SecureEndPoint(IPAddress? address, ushort port, bool dualStack, Protocols protocols, + SecurityConfiguration security) + : base(address, port, dualStack, protocols) + { + Security = security; + + RequireClientCertificate = security.CertificateValidator?.RequireCertificate == true; + + if (security.CertificateValidator is IMutualTlsValidator mutualTls) + { + ClientCaPath = mutualTls.ClientCaPath; + ClientCaPem = mutualTls.ClientCaPem; + } + } + + 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 - 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.Quic.cs b/Engine/Ioxide/Infrastructure/Server.Quic.cs new file mode 100644 index 000000000..fcbcb6f58 --- /dev/null +++ b/Engine/Ioxide/Infrastructure/Server.Quic.cs @@ -0,0 +1,168 @@ +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.Infrastructure; + +/// +/// The QUIC listener that carries HTTP/3, alongside the TCP one. +/// +public sealed partial class Server +{ + private QuicEngine? _quicEngine; + + /// The endpoint serving HTTP/3, or null. Resolved in the constructor. + private readonly EndPoint? _quicEndPoint; + + 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 EndPoint? ResolveQuicEndPoint() + { + var quicEndPoints = _endPoints.Where(e => e.Protocols.HasFlag(Protocols.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 + /// assumes when an Alt-Svc advertisement names none of its own. + /// + private ServerConfig WithQuic(ServerConfig serverConfig) + { + var endPoint = _quicEndPoint!; + + 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.", endPoint.Port); + return serverConfig; + } + + if (!TryResolveQuicCertificate(quicEndPoint.Security, quicEndPoint.Port, out var certPath, out var keyPath)) + { + return serverConfig; + } + + _quicEngine = new QuicEngine(certPath, keyPath, alpn: ["h3"], + clientCaPemPath: quicEndPoint.ClientCaPath, + 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 + // what the caller sets, and this is where the two meet. + _h3Options = new Nghttp3Options + { + QpackDynamicTableCapacity = _engineOptions.Http3.QpackDynamicTableCapacity, + QpackBlockedStreams = _engineOptions.Http3.QpackBlockedStreams, + }; + + return serverConfig with + { + Udp = serverConfig.Udp ?? new UdpOptions(), + Quic = new QuicOptions + { + Port = quicEndPoint.Port, + ConnectionFactory = _quicEngine.CreateFactory(), + }, + }; + } + + /// + /// The PEM files ngtcp2 loads. Configured, or HTTP/3 does not start. + /// + /// + /// 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) + { + certPath = keyPath = string.Empty; + + 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. " + + "Set EngineOptions.Http3.CertificatePath and Http3.KeyPath to the same certificate bound to that endpoint."); + } + + 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); + return false; + } + + WarnIfNotTheBoundCertificate(configuredCert, security, port); + + certPath = configuredCert; + keyPath = configuredKey; + return true; + } + + /// + /// 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. + /// + /// + /// 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) + { + 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); + } + } + + /// + /// Drops the QUIC engine. Nothing was written for it, so nothing is cleaned up. + /// + private void DisposeQuic() + { + _quicEngine?.Dispose(); + _quicEngine = null; + _h3Options = null; + } +} diff --git a/Engine/Ioxide/Infrastructure/Server.Tcp.Tls.cs b/Engine/Ioxide/Infrastructure/Server.Tcp.Tls.cs new file mode 100644 index 000000000..8ad69aae7 --- /dev/null +++ b/Engine/Ioxide/Infrastructure/Server.Tcp.Tls.cs @@ -0,0 +1,148 @@ +using System.Security.Cryptography.X509Certificates; +using System.Text; + +using GenHTTP.Engine.Ioxide.Infrastructure.Endpoints; + +using ioxide.tls; + +using Microsoft.Extensions.Logging; + +namespace GenHTTP.Engine.Ioxide.Infrastructure; + +/// +/// TLS termination for the TCP endpoints - HTTP/1.1 and HTTP/2 both ride this. +/// +public sealed partial class Server +{ + /// + /// 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. + /// + private IEnumerable> ResolveTls() + { + foreach (var endPoint in SecureEndPoints) + { + 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; + } + + yield return new(port, new TlsOptions + { + CertificatePem = ExportChainPem(certificate, port), + KeyPem = ExportKeyPem(certificate), + + // Server preference, most preferred first. A client offering neither continues + // without an ALPN extension at all. + Alpn = endPoint.Protocols.HasFlag(Protocols.Http2) ? ["h2", "http/1.1"] : ["http/1.1"], + + ClientCaPath = endPoint.ClientCaPath, + ClientCaPem = endPoint.ClientCaPem, + RequireClientCertificate = endPoint.RequireClientCertificate, + + KernelTx = _engineOptions.Tcp.TxKernelTls, + KernelRx = _engineOptions.Tcp.RxKernelTls + }); + } + } + + /// + /// Whether any endpoint asks for a client certificate at all. + /// + private bool MutualTlsConfigured => SecureEndPoints.Any(e => e.MutualTls); + + /// 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() + ?? 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.Tcp.cs b/Engine/Ioxide/Infrastructure/Server.Tcp.cs new file mode 100644 index 000000000..f988e6e48 --- /dev/null +++ b/Engine/Ioxide/Infrastructure/Server.Tcp.cs @@ -0,0 +1,52 @@ +using ioxide; + +namespace GenHTTP.Engine.Ioxide.Infrastructure; + +/// +/// The TCP listener that carries HTTP/1.1 and HTTP/2, alongside the QUIC one. +/// +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 + /// 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. + /// + private ushort[] ResolveTcpPorts() + => _endPoints.Where(e => (e.Protocols & Protocols.Http1AndHttp2) != 0) + .Select(e => e.Port) + .OrderBy(p => p == _endPoints[0].Port ? 0 : 1) + .ToArray(); + + /// + /// 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. + /// + /// + /// 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 + { + // ioxide's, not ours - the engine's own TcpTransportOptions is what feeds it below. + Tcp = new TcpOptions + { + Port = _tcpPorts[0], + ExtraPorts = _tcpPorts[1..], + + 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 new file mode 100644 index 000000000..831b8d7d5 --- /dev/null +++ b/Engine/Ioxide/Infrastructure/Server.cs @@ -0,0 +1,398 @@ +using System.Diagnostics; +using System.IO.Pipelines; + +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; +using GenHTTP.Engine.Shared.Types; + +using ioxide; +using ioxide.tls; + +using Microsoft.Extensions.Logging; + +namespace GenHTTP.Engine.Ioxide.Infrastructure; + +/// +/// 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. +/// +public sealed partial class Server : IServer +{ + private readonly ServerConfiguration _serverConfiguration; + + /// Every endpoint, in the order it was bound. The first one names the server. + private readonly EndPoint[] _endPoints; + + /// + /// One mode for the whole server, since that is all ioxide takes - see , + /// which refuses endpoints that disagree. + /// + private readonly bool _dualStack; + + private readonly Action? _onReactorStart; + + private readonly EngineOptions _engineOptions; + + private readonly ILogger _logger; + + private Thread[]? _threads; + + 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 => _serverConfiguration.DevelopmentMode; + + public IPropertyBag Properties { get; } = new PropertyBag(); + + public ILoggerFactory Logging => _serverConfiguration.Logging; + + public IEndPointCollection EndPoints { get; } + + public IHandler Handler { get; } + +#endregion + +#region Constructors + + internal Server( + ServerConfiguration serverConfiguration, + IHandler handler, + Action? onReactorStart = null, + EngineOptions? options = null) + { + _serverConfiguration = serverConfiguration; + Handler = handler; + _onReactorStart = onReactorStart; + _engineOptions = options ?? EngineOptions.Default; + + _logger = serverConfiguration.Logging.CreateLogger(); + + _endPoints = MapEndPoints(serverConfiguration, _engineOptions); + _dualStack = _endPoints[0].DualStack; + + // Which endpoints want which listener, settled here so StartAsync only has to act on it. + // Both read _endPoints, which each endpoint's own protocols now come with. + _tcpPorts = ResolveTcpPorts(); + _quicEndPoint = ResolveQuicEndPoint(); + + EndPoints = new EndPointCollection(_endPoints); + } + +#endregion + + public async ValueTask StartAsync() + { + await PrepareHandlerAsync(); + + Running = true; + + var serverConfig = BuildServerConfig(); + + if (_tcpPorts.Length > 0) + { + serverConfig = WithTcp(serverConfig); + } + + if (_quicEndPoint is not null) + { + serverConfig = WithQuic(serverConfig); + } + + _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(serverConfig.ReactorCount); + + for (var i = 0; i < _threads.Length; i++) + { + var reactor = new Reactor(i, serverConfig) + { + OnStart = r => + { + IoxideReactor.Bind(r); + + if (SecureEndPoints.Any()) + { + var registry = new TlsRegistry(); + + foreach (var (port, options) in ResolveTls()) + { + registry.Add(port, TlsService.Start(r, options, register: false)); + } + + r.AddService(registry); + } + + _onReactorStart?.Invoke(r); + + listening.Signal(); + }, + 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!) + : null + }; + + _reactors[i] = reactor; + + _threads[i] = new Thread(reactor.Run) + { + Name = $"ioxide-genhttp-{i}", + IsBackground = true, + }; + + _threads[i].Start(); + } + + // 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(); + } + else + { + _logger.LogWarning("Not all reactors reported listening within 10s; the server may not be fully accepting yet."); + } + + if (_logger.IsEnabled(LogLevel.Information)) + { + _logger.LogInformation("Listening on {Address}:{Port} ({Settings})", _endPoints[0].Address, _endPoints[0].Port, DescribeSettings()); + } + } + + /// + /// 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 + /// 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, EngineOptions options) + { + 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. + 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."); + } + + // 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 + // 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 + { + var start = Stopwatch.GetTimestamp(); + + await Handler.PrepareAsync(this); + + var elapsed = Stopwatch.GetElapsedTime(start); + + _logger.LogInformation("Prepared handlers in {ElapsedMs:0.##} ms", elapsed.TotalMilliseconds); + } + catch (Exception e) + { + _logger.LogCritical(e, "Failed to prepare the handler chain"); + } + } + + /// + /// 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) + : new InsecureEndPoint(endPoint.Address, endPoint.Port, endPoint.DualStack, protocols); + } + + /// + /// What one endpoint serves: the default, its port's override, and its own enableQuic flag. + /// + private static Protocols ResolveProtocols(EngineOptions options, EndPointConfiguration endPoint) + { + 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) && endPoint.Security is null) + { + protocols &= ~Protocols.Http3; + } + + if (endPoint.EnableQuic) + { + protocols |= Protocols.Http3; + } + + if (protocols == 0) + { + throw new NotSupportedException($"Port {endPoint.Port} was given no protocols to serve."); + } + + return protocols; + } + + /// + /// 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(" ", _endPoints.OrderBy(e => e.Port).Select(e => $"{e.Port}:{Describe(e.Protocols)}")); + + return $"ioxide, {protocols}, TLS on {SecureEndPoints.Count()}" + + (MutualTlsConfigured ? ", mTLS" : string.Empty) + + $", DualStack: {_dualStack}, Reactors: {_reactors?.Length ?? 0}"; + } + + private static string Describe(Protocols protocols) + { + var names = new List(3); + + 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); + } + + public async ValueTask DisposeAsync() + { + Running = false; + + var reactors = _reactors; + var threads = _threads; + + _reactors = null; + _threads = null; + + if (reactors is null || threads is null) + { + return; + } + + _logger.LogInformation("Stopping {Count} ioxide reactors ...", reactors.Length); + + // 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) + { + reactor.Stop(); + } + + foreach (var thread in threads) + { + thread.Join(TimeSpan.FromSeconds(5)); + } + }); + + DisposeQuic(); + + _logger.LogInformation("Stopped ioxide reactors"); + } +} diff --git a/Engine/Ioxide/Infrastructure/ServerHost.cs b/Engine/Ioxide/Infrastructure/ServerHost.cs new file mode 100644 index 000000000..987c156c7 --- /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, + EngineOptions? options = null) : Shared.Hosting.ServerHost +{ + + protected override IServer Build(ServerConfiguration config, IHandler handler) + => new Server(config, handler, onReactorStart, options); + +} 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/ConnectionDriver.cs b/Engine/Ioxide/Protocol/ConnectionDriver.cs index cc6aa8fd3..efad5248a 100644 --- a/Engine/Ioxide/Protocol/ConnectionDriver.cs +++ b/Engine/Ioxide/Protocol/ConnectionDriver.cs @@ -2,47 +2,32 @@ using System.IO.Pipelines; using System.Net; using System.Runtime.InteropServices; -using GenHTTP.Api.Infrastructure; -using GenHTTP.Api.Protocol; -using GenHTTP.Engine.Shared.Types; +using GenHTTP.Api.Infrastructure; -using Glyph11.Parser; -using Glyph11.Parser.UltraHardened; -using Glyph11.Pico; -using Glyph11.Protocol; +using GenHTTP.Engine.Ioxide.Infrastructure; +using GenHTTP.Engine.Ioxide.Protocol.Http1; +using GenHTTP.Engine.Ioxide.Protocol.Multiplexed; -using Microsoft.Extensions.Logging; +using ioxide.tls; -using Connection = GenHTTP.Api.Protocol.Connection; -using IoConnection = ioxide.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 - 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 . /// 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 - /// 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; @@ -52,231 +37,170 @@ 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) + internal static async Task HandleAsync(IServer server, IEndPoint endPoint, IoConnection conn, + Protocols protocols = Protocols.Http1) { - // 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; - 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); - - 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; + // Null on a plaintext port, and on a TLS port whose client offered nothing we serve. + string? negotiated = null; try { - var dataRemaining = false; - ReadResult readResult = default; - - while (server.Running) + if (endPoint.Secure) { - if (!dataRemaining) - { - readResult = await reader.ReadAsync(); - WarnIfThreadHopped(server, reactorThreadId, "after-read"); - } - - dataRemaining = false; - - var buffer = readResult.Buffer; - - if (!TryParseRequest(ref buffer, into)) + // 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)) { - reader.AdvanceTo(readResult.Buffer.Start, readResult.Buffer.End); - if (readResult.IsCompleted) - { - break; - } - continue; + _ = Shutdown(conn.ClientFd, ShutWrite); + conn.DecRef(); + return; } - // 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(); - } + (pipe, negotiated) = await AcceptTlsAsync(conn, service); + } + else + { + pipe = new ioxide.TcpConnectionDualPipe(conn); } } catch { - // spike: swallow client/protocol faults; teardown happens in finally + // failed handshake - release the connection instead of leaking it + conn.DecRef(); + return; } - 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(); + var remoteAddress = GetPeerAddress(conn.ClientFd); - 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); - } - } + var http2 = protocols.HasFlag(Protocols.Http2); + var http1 = protocols.HasFlag(Protocols.Http1); - private static bool TryParseRequest(ref ReadOnlySequence buffer, BinaryRequest into) - => UsePico ? TryParseRequestPico(ref buffer, into) : TryParseRequestGlyph11(ref buffer, into); + // 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))); - // 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)) + if (http2 && !http1) { - return false; + isHttp2 = true; } - buffer = buffer.Slice(bytesRead + 1); - return true; - } + if (isHttp2) + { + try + { + await Http2Driver.RunAsync(server, endPoint, pipe, remoteAddress, endPoint.Secure); + } + catch + { + // client or protocol fault - teardown happens below + } + finally + { + await CloseAsync(pipe, conn); + } - // 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; + } + + // 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) { - return false; + await CloseAsync(pipe, conn); + return; } - buffer = buffer.Slice(consumed + 1); - return true; + // 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); } - private static async ValueTask HandleRequestAsync(IServer server, PipeWriter writer, Request request) + /// + /// 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 header = request.Header; + var session = await service.AcceptAsync(conn); - var headRequest = header.Method == RequestMethod.Head; + return (new TlsConnectionDualPipe(conn, session), session.NegotiatedAlpn); + } - var connectionHeader = header.Headers.GetEntry(KnownHeaders.Connection); + /// + /// 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; - var keepAliveRequested = connectionHeader?.Bytes.Span.SequenceEqual(KeepAliveValue.Span) ?? (header.Protocol == HttpProtocol.Http11); + if (buffer.Length >= Preface.Length) + { + Span head = stackalloc byte[Preface.Length]; + buffer.Slice(0, Preface.Length).CopyTo(head); - var response = await server.Handler.HandleAsync(request) ?? throw new InvalidOperationException("The root request handler did not return a response"); + // 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); - var closeRequested = response.Mode is Connection.Close or Connection.Upgrade; + return head.SequenceEqual(Preface.Span); + } - await ResponseWriter.WriteAsync(writer, request, response, keepAliveRequested && !closeRequested, headRequest); + // Too short to decide yet - examined to the end, so the next read waits for more. + reader.AdvanceTo(buffer.Start, buffer.End); - return keepAliveRequested && !closeRequested; + if (result.IsCompleted) + { + return false; + } + } } - private static Request RentRequest() - => _requestPool is { } pool && pool.TryPop(out var request) ? request : new Request(); - - private static void ReturnRequest(Request request) + /// + /// 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. + /// + internal static async ValueTask CloseAsync(IDuplexPipe pipe, IoConnection conn) { - request.Reset(); + await pipe.Input.CompleteAsync(); + await pipe.Output.CompleteAsync(); - var pool = _requestPool ??= new Stack(); - - if (pool.Count < MaxPooledRequests) + if (pipe is IAsyncDisposable disposable) { - pool.Push(request); + await disposable.DisposeAsync(); // tears down a TLS transport (stops the decrypt pump, close_notify) } - } - // Set to 1 the first time a continuation is seen resuming off the reactor thread. - private static int _hopWarned; + Shutdown(conn.ClientFd, ShutWrite); + conn.DecRef(); + } - // 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) + // 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 now = Environment.CurrentManagedThreadId; + var addr = new byte[128]; // sockaddr_storage + var len = addr.Length; - if (now == reactorThreadId || _hopWarned != 0) + if (GetPeerName(fd, addr, ref len) != 0) { - return; + return null; } - if (Interlocked.Exchange(ref _hopWarned, 1) == 0) + var family = addr[0] | (addr[1] << 8); + + 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/ChunkedSink.cs b/Engine/Ioxide/Protocol/Http1/ChunkedSink.cs similarity index 62% rename from Engine/Ioxide/Protocol/ChunkedSink.cs rename to Engine/Ioxide/Protocol/Http1/ChunkedSink.cs index 627623a28..8099ed785 100644 --- a/Engine/Ioxide/Protocol/ChunkedSink.cs +++ b/Engine/Ioxide/Protocol/Http1/ChunkedSink.cs @@ -3,13 +3,12 @@ using GenHTTP.Api.Protocol; -namespace GenHTTP.Engine.Ioxide.Protocol; +namespace GenHTTP.Engine.Ioxide.Protocol.Http1; /// -/// 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/Http1/ChunkedWriter.cs similarity index 82% rename from Engine/Ioxide/Protocol/ChunkedWriter.cs rename to Engine/Ioxide/Protocol/Http1/ChunkedWriter.cs index 391495c1b..f0c715658 100644 --- a/Engine/Ioxide/Protocol/ChunkedWriter.cs +++ b/Engine/Ioxide/Protocol/Http1/ChunkedWriter.cs @@ -1,13 +1,12 @@ using System.Buffers; using System.IO.Pipelines; -namespace GenHTTP.Engine.Ioxide.Protocol; +namespace GenHTTP.Engine.Ioxide.Protocol.Http1; /// -/// 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/DateHeader.cs b/Engine/Ioxide/Protocol/Http1/DateHeader.cs similarity index 71% rename from Engine/Ioxide/Protocol/DateHeader.cs rename to Engine/Ioxide/Protocol/Http1/DateHeader.cs index dbd1ba2c3..f24666698 100644 --- a/Engine/Ioxide/Protocol/DateHeader.cs +++ b/Engine/Ioxide/Protocol/Http1/DateHeader.cs @@ -1,12 +1,10 @@ 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. 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/Http1/Http1Driver.cs b/Engine/Ioxide/Protocol/Http1/Http1Driver.cs new file mode 100644 index 000000000..37b5ee611 --- /dev/null +++ b/Engine/Ioxide/Protocol/Http1/Http1Driver.cs @@ -0,0 +1,218 @@ +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.Http1; + +/// +/// 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 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: 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, so the stack needs no locking. Reuses the per-connection Request allocation, + // which matters under connection churn. + [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; + + // 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 + { + 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 exposes one. + 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); + + // 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: 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)) + { + 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); + } + } + + private static int _hopWarned; + + // 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; + + 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/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 70% rename from Engine/Ioxide/Protocol/PipeWriterStream.cs rename to Engine/Ioxide/Protocol/Http1/PipeWriterStream.cs index cd867d917..779ed390e 100644 --- a/Engine/Ioxide/Protocol/PipeWriterStream.cs +++ b/Engine/Ioxide/Protocol/Http1/PipeWriterStream.cs @@ -2,14 +2,13 @@ using System.IO.Pipelines; using System.Runtime.CompilerServices; -namespace GenHTTP.Engine.Ioxide.Protocol; +namespace GenHTTP.Engine.Ioxide.Protocol.Http1; /// -/// 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/Http1/ResponseWriter.cs similarity index 77% rename from Engine/Ioxide/Protocol/ResponseWriter.cs rename to Engine/Ioxide/Protocol/Http1/ResponseWriter.cs index c67300095..6731bb646 100644 --- a/Engine/Ioxide/Protocol/ResponseWriter.cs +++ b/Engine/Ioxide/Protocol/Http1/ResponseWriter.cs @@ -5,13 +5,12 @@ using GenHTTP.Engine.Shared.Types; -namespace GenHTTP.Engine.Ioxide.Protocol; +namespace GenHTTP.Engine.Ioxide.Protocol.Http1; /// -/// 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/Protocol/Multiplexed/Http2Driver.cs b/Engine/Ioxide/Protocol/Multiplexed/Http2Driver.cs new file mode 100644 index 000000000..5b4ea4e5f --- /dev/null +++ b/Engine/Ioxide/Protocol/Multiplexed/Http2Driver.cs @@ -0,0 +1,88 @@ +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.Multiplexed; + +/// +/// 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: 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 +{ + private static readonly ReadOnlyMemory Head = "HEAD"u8.ToArray(); + + private static readonly Http2Options Options = new() { StreamRequestBodies = true }; + + /// + /// 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) + .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 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 = MultiplexedResponder.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 MultiplexedResponder.WriteBodyAsync(response, writer, writer.FlushAsync, headRequest); + } + catch (Exception e) + { + server.Logging.CreateLogger("GenHTTP.Engine.Ioxide.Protocol.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/Multiplexed/Http3Driver.cs b/Engine/Ioxide/Protocol/Multiplexed/Http3Driver.cs new file mode 100644 index 000000000..ac13a9ad5 --- /dev/null +++ b/Engine/Ioxide/Protocol/Multiplexed/Http3Driver.cs @@ -0,0 +1,84 @@ +using GenHTTP.Api.Infrastructure; +using GenHTTP.Api.Protocol; + +using ioxide; +using ioxide.nghttp3; + +using Microsoft.Extensions.Logging; + +namespace GenHTTP.Engine.Ioxide.Protocol.Multiplexed; + +/// +/// 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: 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. + 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: 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); + + var response = await server.Handler.HandleAsync(mapped) + ?? throw new InvalidOperationException("The root request handler did not return a response"); + + var data = MultiplexedResponder.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 MultiplexedResponder.WriteBodyAsync(response, writer, writer.FlushAsync, headRequest); + } + catch (Exception e) + { + server.Logging.CreateLogger("GenHTTP.Engine.Ioxide.Protocol.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/Multiplexed/MultiplexedKeyValueList.cs b/Engine/Ioxide/Protocol/Multiplexed/MultiplexedKeyValueList.cs new file mode 100644 index 000000000..ea32d39a1 --- /dev/null +++ b/Engine/Ioxide/Protocol/Multiplexed/MultiplexedKeyValueList.cs @@ -0,0 +1,27 @@ +using GenHTTP.Api.Protocol; + +namespace GenHTTP.Engine.Ioxide.Protocol.Multiplexed; + +/// +/// 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 MultiplexedKeyValueList : IRequestHeaders, IRequestQuery +{ + private readonly List<(ReadOnlyMemory Name, ReadOnlyMemory Value)> _entries; + + internal MultiplexedKeyValueList(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/Multiplexed/MultiplexedRequest.cs b/Engine/Ioxide/Protocol/Multiplexed/MultiplexedRequest.cs new file mode 100644 index 000000000..945937741 --- /dev/null +++ b/Engine/Ioxide/Protocol/Multiplexed/MultiplexedRequest.cs @@ -0,0 +1,121 @@ +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.Multiplexed; + +/// +/// An over a request decoded by HPACK or QPACK - not the shared +/// , whose Source assumes an HTTP/1.1 parse off a pipe. +/// +/// +/// 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 +{ + private readonly MultiplexedRequestBody? _body; + + private readonly ClientConnection _client = new(); + + private readonly PropertyBag _properties = new(); + + private readonly ResponseBuilder _response = new(); + + private Func? _bodyWrapper; + + private bool _bodyFetched; + + 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 MultiplexedRequestHeader(method, path, authority, headers, ParseQuery(path), protocol); + + _body = read is null ? null : new MultiplexedRequestBody(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. + /// + public PipeReader Upgrade() + => throw new NotSupportedException("Connection upgrades are not available over HTTP/2 or HTTP/3."); + + public ValueTask DisposeAsync() => new(); + + // 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)>(); + + 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/Multiplexed/MultiplexedRequestBody.cs b/Engine/Ioxide/Protocol/Multiplexed/MultiplexedRequestBody.cs new file mode 100644 index 000000000..77d1869bf --- /dev/null +++ b/Engine/Ioxide/Protocol/Multiplexed/MultiplexedRequestBody.cs @@ -0,0 +1,117 @@ +using GenHTTP.Api.Protocol; + +namespace GenHTTP.Engine.Ioxide.Protocol.Multiplexed; + +/// +/// 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. +/// +/// +/// 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 +{ + private readonly Func>> _read; + + internal MultiplexedRequestBody(Func>> read) + { + _read = read; + } + + public Stream AsStream() => new PullStream(_read); + + public async ValueTask> AsMemoryAsync() + { + // Assembling defeats the point of streaming, but a handler asking for the whole body 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); + + // 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."); + + 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/Multiplexed/MultiplexedRequestHeader.cs b/Engine/Ioxide/Protocol/Multiplexed/MultiplexedRequestHeader.cs new file mode 100644 index 000000000..d1cf16f11 --- /dev/null +++ b/Engine/Ioxide/Protocol/Multiplexed/MultiplexedRequestHeader.cs @@ -0,0 +1,113 @@ +using GenHTTP.Api.Protocol; +using GenHTTP.Engine.Shared.Types; + +namespace GenHTTP.Engine.Ioxide.Protocol.Multiplexed; + +/// +/// An over the pseudo-headers a multiplexed protocol carries. +/// +internal sealed class MultiplexedRequestHeader : IRequestHeader +{ + private static readonly ReadOnlyMemory HostName = "host"u8.ToArray(); + + private readonly MultiplexedKeyValueList _headers; + + private readonly MultiplexedKeyValueList _query; + + private readonly RequestTarget _target; + + internal MultiplexedRequestHeader(ReadOnlyMemory method, ReadOnlyMemory path, ReadOnlyMemory authority, + List<(ReadOnlyMemory Name, ReadOnlyMemory Value)> headers, + List<(ReadOnlyMemory Name, ReadOnlyMemory Value)> query, HttpProtocol protocol) + { + _headers = new MultiplexedKeyValueList(WithHost(headers, authority)); + _query = new MultiplexedKeyValueList(query); + + _target = new RequestTarget(); + + // 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 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; } + + public IRequestHeaders Headers => _headers; + + public IRequestQuery Query => _query; + + /// + /// 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) + { + 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/Multiplexed/MultiplexedResponder.cs b/Engine/Ioxide/Protocol/Multiplexed/MultiplexedResponder.cs new file mode 100644 index 000000000..c95e93bd9 --- /dev/null +++ b/Engine/Ioxide/Protocol/Multiplexed/MultiplexedResponder.cs @@ -0,0 +1,147 @@ +using System.Buffers; +using System.Buffers.Text; + +using GenHTTP.Api.Protocol; + +namespace GenHTTP.Engine.Ioxide.Protocol.Multiplexed; + +/// +/// 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. +/// +internal readonly struct MultiplexedResponseData +{ + internal MultiplexedResponseData(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 MultiplexedResponder +{ + 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. The content is streamed afterwards. + internal static MultiplexedResponseData 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 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)); + } + } + + 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. Send it whenever the content itself knows. + if (content.Length is { } length) + { + headers.Add((ContentLengthName, Digits(length))); + } + } + + return new MultiplexedResponseData((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 + { + // HEAD keeps the headers its GET would have produced and sends no body. + if (!headRequest) + { + await content.WriteAsync(new MultiplexedSink(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/Multiplexed/MultiplexedSink.cs b/Engine/Ioxide/Protocol/Multiplexed/MultiplexedSink.cs new file mode 100644 index 000000000..2becd5430 --- /dev/null +++ b/Engine/Ioxide/Protocol/Multiplexed/MultiplexedSink.cs @@ -0,0 +1,95 @@ +using System.Buffers; + +using GenHTTP.Api.Protocol; + +namespace GenHTTP.Engine.Ioxide.Protocol.Multiplexed; + +/// +/// Writes response content straight into a protocol response writer, which is itself an +/// - so the buffer channel reaches the wire with nothing between. +/// +/// +/// 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 +{ + private readonly IBufferWriter _writer; + + private readonly Func _flush; + + private Stream? _stream; + + internal MultiplexedSink(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 peer paces it. + /// + 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) + { + // Staged, but not paced: there is no flush on the sync path. Large bodies want async. + _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/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/Ioxide/Protocols.cs b/Engine/Ioxide/Protocols.cs new file mode 100644 index 000000000..735bfd464 --- /dev/null +++ b/Engine/Ioxide/Protocols.cs @@ -0,0 +1,43 @@ +namespace GenHTTP.Engine.Ioxide; + +/// +/// 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. +/// +[Flags] +public enum Protocols +{ + /// 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. + /// + 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, + + /// + /// 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. + /// + Http1AndHttp3 = Http1 | Http3, + + /// + /// 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. + /// + 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/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 deleted file mode 100644 index aafbe1ccd..000000000 --- a/Engine/Ioxide/Server.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System.IO.Pipelines; - -using GenHTTP.Api.Infrastructure; - -using GenHTTP.Engine.Ioxide.Hosting; -using ioxide; - -namespace GenHTTP.Engine.Ioxide; - -/// -/// Entry point to host an application using the ioxide io_uring engine. -/// -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()). - /// - /// - /// 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). - /// - /// - /// 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. - /// - 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 deleted file mode 100644 index ef25bc0bb..000000000 --- a/Engine/Ioxide/Tls/IoxideTls.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System.IO.Pipelines; - -using ioxide; -using ioxide.tls; - -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). -/// -/// -/// -/// 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. - /// - 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(Connection conn) - { - var session = await IoxideReactor.Current.GetService().AcceptAsync(conn); - return new TlsDuplexPipe(conn, session); - } -} 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(); - } -} 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 @@ + 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..7a4bc1f18 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/Playground/Program.cs b/Playground/Program.cs index 8333f9870..f4efc2b48 100644 --- a/Playground/Program.cs +++ b/Playground/Program.cs @@ -1,9 +1,290 @@ -using GenHTTP.Engine.Internal; - -using GenHTTP.Modules.IO; - -var app = Content.From(Resource.FromString("Hello World!")); - -await Host.Create() - .Handler(app) - .RunAsync(); +using System.Net; +using System.Net.Security; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +using GenHTTP.Api.Infrastructure; + +using GenHTTP.Engine.Ioxide; + +using 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; + +// 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 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 +// +// 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: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, +// since QUIC carries TLS 1.3 and has no cleartext mode. +// +// 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 +// 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 +// 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)); +} + +// 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(); + +// 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 +// both ways without generating anything by hand. +var clientCa = WriteClientCertificates(); + +await Host.Create( + options: new EngineOptions + { + // What a port serves unless named below. + Protocols = Protocols.Http1, + + Reactor = new ReactorOptions + { + ReactorCount = 2, + }, + + ProtocolsByPort = + { + [8081] = Protocols.Http2, + [8082] = Protocols.Http1AndHttp2, + [8443] = Protocols.All, + [8444] = Protocols.Http1, + }, + + 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 + // 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, + }, + + 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 + // update. In practice only browsers advertise a table of their own. + QpackDynamicTableCapacity = 4096, + QpackBlockedStreams = 100, + + // 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, + }, + }) + .Handler(app) + .Bind(IPAddress.Loopback, 8080) + .Bind(IPAddress.Loopback, 8081) + .Bind(IPAddress.Loopback, 8082) + .Bind(IPAddress.Loopback, 8443, certificate) + // mTLS + .Bind(IPAddress.Loopback, 8444, certificate, certificateValidator: new RequireClientCertificate(clientCa)) + .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. +/// +/// +/// 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()); + WritePrivateKey(Path.Combine(directory, $"{name}.key"), key.ExportPkcs8PrivateKeyPem()); + } +} + +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); +} + +/// +/// 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, 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. +/// +/// +/// 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(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; +} 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;