Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions Engine/Ioxide/GenHTTP.Engine.Ioxide.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,7 @@
<ItemGroup>
<ProjectReference Include="..\..\API\GenHTTP.Api.csproj" />
<ProjectReference Include="..\Shared\GenHTTP.Engine.Shared.csproj" />
<PackageReference Include="ioxide" Version="0.1.1" />
<PackageReference Include="ioxide.tls" Version="0.1.1" />
<PackageReference Include="ioxide" Version="0.4.169" />
<PackageReference Include="Glyph11" Version="0.3.6" />
<!-- Parallel parse path for benchmarking — GENHTTP_IOXIDE_PARSER=pico (see ConnectionDriver). -->
<PackageReference Include="Glyph11.Pico" Version="0.0.1" />
Expand Down
115 changes: 92 additions & 23 deletions Engine/Ioxide/Hosting/IoxideServer.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Diagnostics;
using System.IO.Pipelines;
using System.Security.Cryptography.X509Certificates;

using GenHTTP.Api.Content;
using GenHTTP.Api.Infrastructure;
Expand All @@ -9,6 +10,8 @@
using GenHTTP.Engine.Shared.Types;

using ioxide;
using ioxide.tls;

using Microsoft.Extensions.Logging;

namespace GenHTTP.Engine.Ioxide.Hosting;
Expand All @@ -17,13 +20,23 @@ public sealed class IoxideServer : IServer
{
private readonly ServerConfiguration _config;

private readonly IoxideEndPoint _endPoint;
private readonly IoxideEndPoint _primary;

private readonly Dictionary<ushort, IoxideEndPoint> _endPointByPort;

private readonly Dictionary<ushort, SecurityConfiguration> _secure;

private readonly ushort[] _extraPorts;

private readonly Func<ServerConfig, ServerConfig>? _configure;

private readonly Action<Reactor>? _onReactorStart;

private readonly Func<Connection, ValueTask<IDuplexPipe>>? _connectionFactory;
private readonly Func<TcpConnection, ValueTask<IDuplexPipe>>? _connectionFactory;

private readonly bool _kernelTx;

private readonly bool _kernelRx;

private readonly ILogger _logger;

Expand All @@ -45,35 +58,74 @@ public sealed class IoxideServer : IServer

public IHandler Handler { get; }

internal IoxideServer(ServerConfiguration config, IHandler handler, Func<ServerConfig, ServerConfig>? configure = null, Action<Reactor>? onReactorStart = null, Func<Connection, ValueTask<IDuplexPipe>>? connectionFactory = null)
internal IoxideServer(ServerConfiguration config, IHandler handler, Func<ServerConfig, ServerConfig>? configure = null, Action<Reactor>? onReactorStart = null, Func<TcpConnection, ValueTask<IDuplexPipe>>? connectionFactory = null, bool kernelTx = false, bool kernelRx = false)
{
_config = config;
Handler = handler;
_configure = configure;
_onReactorStart = onReactorStart;
_connectionFactory = connectionFactory;
_kernelTx = kernelTx;
_kernelRx = kernelRx;

_logger = config.Logging.CreateLogger<IoxideServer>();

var ep = config.EndPoints.First(); // spike: still only SERVE the first endpoint
var mapped = config.EndPoints
.Select(e => new IoxideEndPoint(e.Address, e.Port, e.DualStack, e.Security != null))
.ToList();

_primary = mapped[0];
_endPointByPort = mapped.ToDictionary(e => e.Port);
_extraPorts = mapped.Skip(1).Select(e => e.Port).ToArray();

_endPoint = new IoxideEndPoint(ep.Address, ep.Port, ep.DualStack, ep.Security != null);
if (mapped.Any(e => e.DualStack != _primary.DualStack))
{
throw new NotSupportedException("The ioxide engine binds all endpoints with one dual-stack mode.");
}

// 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()
);
// Certificates are resolved per reactor in OnStart, not here: the provider is queried for
// its default (no-SNI) certificate then, and a port whose provider yields none is still
// advertised as secure (so secure-upgrade redirects work) but serves no handshake.
_secure = config.EndPoints
.Where(e => e.Security is not null)
.ToDictionary(e => e.Port, e => e.Security!);

var endPointCount = config.EndPoints.Count();
EndPoints = new IoxideEndPoints(mapped.Cast<IEndPoint>().ToList());
}

if (endPointCount > 1)
// The certificate for every secure port whose provider yields a default (no-SNI) certificate.
// Providers that select by SNI (unsupported here) return none and are skipped - the port stays
// advertised as secure but its handshakes are refused.
private IEnumerable<KeyValuePair<ushort, TlsOptions>> ResolveTls()
{
foreach (var (port, security) in _secure)
{
_logger.LogWarning("Configured with {Count} endpoints, but the ioxide engine only serves the first one ({Address}:{Port})", endPointCount, _endPoint.Address, _endPoint.Port);
if (security.CertificateValidator is not null)
{
throw new NotSupportedException("Client certificate validation is not supported by the ioxide engine.");
}

if (security.CertificateProvider.Provide(null) is not { } certificate)
{
_logger.LogWarning("No default certificate for secure port {Port}; handshakes there will be refused (SNI selection is unsupported).", port);
continue;
}

yield return new(port, new TlsOptions
{
CertificatePem = certificate.ExportCertificatePem(),
KeyPem = ExportKeyPem(certificate),
KernelTx = _kernelTx,
KernelRx = _kernelRx
});
}
}

private static string ExportKeyPem(X509Certificate2 certificate)
=> certificate.GetRSAPrivateKey()?.ExportPkcs8PrivateKeyPem()
?? certificate.GetECDsaPrivateKey()?.ExportPkcs8PrivateKeyPem()
?? throw new InvalidOperationException("The certificate carries no exportable RSA or ECDSA private key.");

public async ValueTask StartAsync()
{
await PrepareHandlerAsync();
Expand All @@ -87,12 +139,16 @@ public async ValueTask StartAsync()
cfg = _configure(cfg);
}

// The endpoint binding (.Port()/.Bind()) determines the listen port and dual-stack mode, so
// The endpoint bindings (.Port()/.Bind()) determine the listen ports and dual-stack mode, so
// they always win over whatever the configuration hook may have set.
cfg = cfg with
{
Port = _endPoint.Port,
DualStack = _endPoint.DualStack
DualStack = _primary.DualStack,
Tcp = (cfg.Tcp ?? new TcpOptions()) with
{
Port = _primary.Port,
ExtraPorts = _extraPorts
}
};

_threads = new Thread[cfg.ReactorCount];
Expand All @@ -108,16 +164,26 @@ public async ValueTask StartAsync()
{
var reactor = new Reactor(i, cfg)
{
// Runs once on the reactor's own thread before it serves: bind the reactor into the
// [ThreadStatic] seam so handler code can resolve per-reactor services, then let the
// host register those services (e.g. PgPool.Start(r, ...)) on this reactor's ring.
OnStart = r =>
{
IoxideReactor.Bind(r);

if (_secure.Count > 0)
{
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();
},
Handle = (_, c) => ConnectionDriver.HandleAsync(this, _endPoint, c, _connectionFactory),
TcpHandle = (_, c) => ConnectionDriver.HandleAsync(this, _endPointByPort[c.ListenerPort], c, _connectionFactory)
};

_reactors[i] = reactor;
Expand All @@ -143,7 +209,10 @@ public async ValueTask StartAsync()
_logger.LogWarning("Not all reactors reported listening within 10s; the server may not be fully accepting yet.");
}

_logger.LogInformation("Listening on {Address}:{Port} ({Settings})", _endPoint.Address, _endPoint.Port, DescribeSettings());
if (_logger.IsEnabled(LogLevel.Information))
{
_logger.LogInformation("Listening on {Address}:{Port} ({Settings})", _primary.Address, _primary.Port, DescribeSettings());
}
}

private async ValueTask PrepareHandlerAsync()
Expand All @@ -164,7 +233,7 @@ private async ValueTask PrepareHandlerAsync()
}
}

private string DescribeSettings() => $"ioxide, {(_endPoint.Secure ? "HTTPS" : "HTTP")}, DualStack: {_endPoint.DualStack}, Reactors: {_reactors?.Length ?? 0}";
private string DescribeSettings() => $"ioxide, {_endPointByPort.Count} endpoint(s), TLS on {_secure.Count}, DualStack: {_primary.DualStack}, Reactors: {_reactors?.Length ?? 0}";

public async ValueTask DisposeAsync()
{
Expand Down
6 changes: 3 additions & 3 deletions Engine/Ioxide/Hosting/IoxideServerHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@

namespace GenHTTP.Engine.Ioxide.Hosting;

public sealed class IoxideServerHost(Func<ServerConfig, ServerConfig>? configure = null, Action<Reactor>? onReactorStart = null, Func<Connection, ValueTask<IDuplexPipe>>? connectionFactory = null) : ServerHost
public sealed class IoxideServerHost(Func<ServerConfig, ServerConfig>? configure = null, Action<Reactor>? onReactorStart = null, Func<TcpConnection, ValueTask<IDuplexPipe>>? connectionFactory = null, bool kernelTx = false, bool kernelRx = false) : ServerHost
{

protected override IServer Build(ServerConfiguration config, IHandler handler)
=> new IoxideServer(config, handler, configure, onReactorStart, connectionFactory);
=> new IoxideServer(config, handler, configure, onReactorStart, connectionFactory, kernelTx, kernelRx);

}
39 changes: 34 additions & 5 deletions Engine/Ioxide/Protocol/ConnectionDriver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
using Microsoft.Extensions.Logging;

using Connection = GenHTTP.Api.Protocol.Connection;
using IoConnection = ioxide.Connection;
using IoConnection = ioxide.TcpConnection;

namespace GenHTTP.Engine.Ioxide.Protocol;

Expand Down Expand Up @@ -89,10 +89,39 @@ internal static partial class ConnectionDriver

internal static async Task HandleAsync(IServer server, IEndPoint endPoint, IoConnection conn, Func<IoConnection, ValueTask<IDuplexPipe>>? connectionFactory)
{
// Default transport is a plain duplex pipe over the connection. A connectionFactory (e.g. the
// TLS-terminating one supplied by the host for the :8081 listener) can swap in a transport that
// decrypts inbound bytes and writes plaintext for kTLS TX.
var pipe = connectionFactory is null ? new ioxide.ConnectionDualPipe(conn) : await connectionFactory(conn);
IDuplexPipe pipe;

try
{
if (connectionFactory is not null)
{
pipe = await connectionFactory(conn);
}
else if (endPoint.Secure)
{
// A secure port with no certificate (an SNI-only provider yielded none) is advertised
// for redirects but cannot handshake - FIN the connection so the client's handshake
// fails fast rather than a plaintext response landing on an https port.
if (!IoxideReactor.Current.GetService<TlsRegistry>().TryFor(conn.ListenerPort, out var service))
{
_ = Shutdown(conn.ClientFd, ShutWrite);
conn.DecRef();
return;
}

pipe = await IoxideTls.AcceptAsync(conn, service);
}
else
{
pipe = new ioxide.TcpConnectionDualPipe(conn);
}
}
catch
{
// failed handshake (or factory fault) - release the connection instead of leaking it
conn.DecRef();
return;
}

var reader = pipe.Input;
var writer = pipe.Output;
Expand Down
29 changes: 17 additions & 12 deletions Engine/Ioxide/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand All @@ -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`.
Expand All @@ -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.

Expand Down
24 changes: 17 additions & 7 deletions Engine/Ioxide/Server.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,23 @@ public static class Host
/// <see cref="Hosting.IoxideServer" />'s reactor seam (<c>IoxideReactor.Current</c>).
/// </param>
/// <param name="connectionFactory">
/// Optional hook to turn an accepted <see cref="Connection" /> into the duplex pipe the engine
/// serves it over. Defaults to a plain <c>ConnectionDualPipe</c>. 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 <see cref="IAsyncDisposable" />
/// is disposed when the connection ends.
/// Optional hook to turn an accepted <see cref="TcpConnection" /> into the duplex pipe the engine
/// serves it over, overriding the built-in transport selection (plain pipe, or TLS termination
/// for endpoints bound with a certificate). A returned pipe implementing
/// <see cref="IAsyncDisposable" /> is disposed when the connection ends.
/// </param>
public static IServerHost Create(Func<ServerConfig, ServerConfig>? configure = null, Action<Reactor>? onReactorStart = null, Func<Connection, ValueTask<IDuplexPipe>>? connectionFactory = null)
=> new IoxideServerHost(configure, onReactorStart, connectionFactory);
/// <param name="kernelTx">
/// Offload TLS record ENCRYPTION to the kernel (kTLS TX) on TLS-terminated endpoints instead of
/// encrypting in OpenSSL. Off by default (OpenSSL both ways). The kernel produces the records on
/// the send path while OpenSSL still drives the handshake; requires the Linux <c>tls</c> module
/// and TLS 1.3.
/// </param>
/// <param name="kernelRx">
/// Offload TLS record DECRYPTION to the kernel (kTLS RX) on the receive path. Off by default and
/// experimental; it requires <paramref name="kernelTx"/> (RX shares the ULP handoff TX installs,
/// so ioxide refuses RX alone) and a peer that sends no post-handshake control records.
/// </param>
public static IServerHost Create(Func<ServerConfig, ServerConfig>? configure = null, Action<Reactor>? onReactorStart = null, Func<TcpConnection, ValueTask<IDuplexPipe>>? connectionFactory = null, bool kernelTx = false, bool kernelRx = false)
=> new IoxideServerHost(configure, onReactorStart, connectionFactory, kernelTx, kernelRx);

}
Loading
Loading