Skip to content
Merged
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
20 changes: 20 additions & 0 deletions Playground/Http3/MutualTls/Playground.Http3.MutualTls.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net11.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<RootNamespace>Playground.Http3.MutualTls</RootNamespace>
<AssemblyName>Playground.Http3.MutualTls</AssemblyName>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="../../Shared/Playground.Shared.csproj" />
<ProjectReference Include="../../../src/ioxide/ioxide.csproj" />
<ProjectReference Include="../../../src/protocols/ioxide.ngtcp2/ioxide.ngtcp2.csproj" />
<ProjectReference Include="../../../src/protocols/ioxide.http3/ioxide.http3.csproj" />
</ItemGroup>

</Project>
116 changes: 116 additions & 0 deletions Playground/Http3/MutualTls/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
using System.Text;
using ioxide;
using ioxide.http3;
using ioxide.ngtcp2;
using Playground.Shared;

// ─────────────────────────────────────────────────────────────────────────────────────────────
// http3-mtls - HTTP/3 where the CLIENT proves who it is too. The server verifies a client
// certificate during the QUIC handshake and the handler is told which peer it got.
//
// dotnet run -c Release --project Playground/Http3/MutualTls
// curl --http3 --cacert ca.crt --cert client.crt --key client.key https://localhost:8443/
//
// Make a CA and two certificates it signs - one for the server, one for the client:
//
// openssl req -x509 -newkey rsa:2048 -nodes -keyout ca.key -out ca.crt -days 365 \
// -subj "/CN=my CA" -addext "basicConstraints=critical,CA:TRUE"
// openssl req -newkey rsa:2048 -nodes -keyout client.key -out client.csr -subj "/CN=alice"
// printf 'extendedKeyUsage=clientAuth\n' > c.cnf
// openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
// -out client.crt -days 365 -extfile c.cnf
//
// QUIC settles client authentication during the handshake, and RFC 9001 4.4 forbids doing it
// afterwards - so this is a property of the whole CONNECTION. There is no asking for a certificate
// later because a request reached a protected route; that needs a second port.
// Needs: ioxide, ioxide.ngtcp2, ioxide.http3
// ─────────────────────────────────────────────────────────────────────────────────────────────

// ── Knobs ────────────────────────────────────────────────────────────────────────────────────
// Edit these. That is the whole mechanism - there is no config file and nothing else to find.

ushort quicPort = 8443;
int reactors = Environment.ProcessorCount;

Env.OverrideQuic(ref quicPort, ref reactors);

// The server's own certificate and key. Null generates a self-signed pair on first run.
string? certOverride = null;
string? keyOverride = null;

Env.OverrideCert(ref certOverride, ref keyOverride);

// The CA that client certificates are checked against. This is what turns mTLS ON - leave it null
// and the server verifies nothing about the client, exactly as the other h3 samples do.
string? clientCaPath = Environment.GetEnvironmentVariable("PLAYGROUND_CLIENT_CA");

// Refuse a client that offers no certificate, during the handshake. Off, so a client without one
// still connects and the handler decides what it may see - which is the more useful default when
// only part of a site is protected.
bool requireClientCertificate = Environment.GetEnvironmentVariable("PLAYGROUND_REQUIRE_CLIENT_CERT") == "1";

// UDP receive slots per reactor: how many datagrams the ring can have outstanding at once.
int udpRecvSlots = 16;
// ─────────────────────────────────────────────────────────────────────────────────────────────

(string certPath, string keyPath) = QuicCert.Ensure(certOverride, keyOverride);

if (clientCaPath is null)
{
Console.Error.WriteLine("set PLAYGROUND_CLIENT_CA to a PEM bundle of the CA that signs your client certificates.");
return 1;
}

using var engine = new QuicEngine(certPath, keyPath, cidLength: 8, alpn: ["h3"],
clientCaPemPath: clientCaPath, requireClientCertificate: requireClientCertificate);

var config = new ServerConfig
{
ReactorCount = reactors,
Tcp = null, // QUIC only
Udp = new UdpOptions { RecvSlots = udpRecvSlots },
Quic = new QuicOptions
{
Port = quicPort,
LocalCidLength = 8,
ConnectionFactory = engine.CreateFactory(),
},
};

var threads = new Thread[config.ReactorCount];

for (int i = 0; i < threads.Length; i++)
{
var reactor = new Reactor(i, config);

reactor.QuicHandle = (_, connection) =>
new Http3Connection(connection).RunAsync(_ =>
{
// Read HERE, per request - not where the connection is accepted. That callback runs
// before the handshake finishes, so there is no identity yet at that point.
string? peer = (connection as QuicEngineConnection)?.PeerSubject;

var response = new Http3Response
{
Body = Encoding.UTF8.GetBytes(peer is null
? "anonymous\n"
: $"authenticated as {peer}\n"),
};
response.Headers.Add(("content-type"u8.ToArray(), "text/plain"u8.ToArray()));
return response;
});

threads[i] = new Thread(reactor.Run) { Name = $"reactor-{i}" };
threads[i].Start();
}

Console.WriteLine($"[http3-mtls] {config.ReactorCount} reactors on :{quicPort}, "
+ $"client CA {clientCaPath}, "
+ $"client certificate {(requireClientCertificate ? "REQUIRED" : "optional")}");

foreach (Thread thread in threads)
{
thread.Join();
}

return 0;
3 changes: 3 additions & 0 deletions docs/assets/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,7 @@ nav.top .links a.gh svg { display: block; }
#tab-h2cstls:checked ~ .ex-menu label[for="tab-h2cstls"],
#tab-h2bcl:checked ~ .ex-menu label[for="tab-h2bcl"],
#tab-h3csstream:checked ~ .ex-menu label[for="tab-h3csstream"],
#tab-h3mtls:checked ~ .ex-menu label[for="tab-h3mtls"],
#tab-h3cs:checked ~ .ex-menu label[for="tab-h3cs"],
#tab-h3stream:checked ~ .ex-menu label[for="tab-h3stream"],
#tab-h3buf:checked ~ .ex-menu label[for="tab-h3buf"],
Expand Down Expand Up @@ -353,6 +354,7 @@ nav.top .links a.gh svg { display: block; }
#tab-h2cstls:checked ~ .pane-h2cstls { display: block; }
#tab-h2bcl:checked ~ .pane-h2bcl { display: block; }
#tab-h3csstream:checked ~ .pane-h3csstream { display: block; }
#tab-h3mtls:checked ~ .pane-h3mtls { display: block; }
#tab-h3cs:checked ~ .pane-h3cs { display: block; }
#tab-h3stream:checked ~ .pane-h3stream { display: block; }
#tab-h3buf:checked ~ .pane-h3buf { display: block; }
Expand Down Expand Up @@ -491,6 +493,7 @@ nav.top .links a.gh svg { display: block; }
#tab-h2cstls:checked ~ .ex-menu label[for="tab-h2cstls"],
#tab-h2bcl:checked ~ .ex-menu label[for="tab-h2bcl"],
#tab-h3csstream:checked ~ .ex-menu label[for="tab-h3csstream"],
#tab-h3mtls:checked ~ .ex-menu label[for="tab-h3mtls"],
#tab-h3cs:checked ~ .ex-menu label[for="tab-h3cs"],
#tab-h3stream:checked ~ .ex-menu label[for="tab-h3stream"],
#tab-h3buf:checked ~ .ex-menu label[for="tab-h3buf"],
Expand Down
106 changes: 106 additions & 0 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
<input type="radio" name="mode-tabs" id="tab-h3cs">
<input type="radio" name="mode-tabs" id="tab-h3stream">
<input type="radio" name="mode-tabs" id="tab-h3csstream">
<input type="radio" name="mode-tabs" id="tab-h3mtls">
<input type="radio" name="mode-tabs" id="tab-h3buf">
<input type="radio" name="mode-tabs" id="tab-pxmatrix">
<input type="radio" name="mode-tabs" id="tab-pxh1toh1">
Expand Down Expand Up @@ -120,6 +121,7 @@
<summary>http/3</summary>
<label for="tab-h3cs">h3 &middot; buffered</label>
<label for="tab-h3csstream">h3 &middot; request + response streamed</label>
<label for="tab-h3mtls">h3 &middot; mutual TLS</label>
<label for="tab-h3buf">h3 &middot; buffered (nghttp3)</label>
<label for="tab-h3">h3 &middot; request streamed (nghttp3)</label>
<label for="tab-h3stream">h3 &middot; response streamed (nghttp3)</label>
Expand Down Expand Up @@ -1432,6 +1434,110 @@ <h3>HTTP/3 &middot; request + response streamed</h3>
}</code></pre>
<p class="ex-foot">Both directions streamed, in pure C#. The request body is PULLED a chunk at a time through <code>Http3Request.BodyReader</code>, so a large upload is never held whole; the response is PUSHED through <code>Http3ResponseWriter</code>, one DATA frame per flush, so a large download is never built whole. <code>/echo</code> runs both at once - read a chunk, write a chunk - which is what a proxy does. Owning the framing is what makes the push side simple: a chunk is just <code>[0x00][varint length][payload]</code> handed to the QUIC stream, with no data-reader callback to answer and nothing to defer. <label for="tab-h3stream" class="ex-jump">nghttp3 &middot; streamed</label> carries a resume and a drain because nghttp3 pulls instead; this measures <b>1.32&times;</b> its throughput on the same 8&times;1&nbsp;KiB response.</p>
</div>
<div class="pane pane-h3mtls">
<div class="pane-head">
<h3>HTTP/3 &middot; mutual TLS</h3>
<span class="pane-pkg">ioxide + ioxide.ngtcp2 + ioxide.http3</span>
</div>
<pre><code class="language-csharp">// dotnet add package ioxide
// dotnet add package ioxide.ngtcp2
// dotnet add package ioxide.http3
// PLAYGROUND_CLIENT_CA=ca.crt dotnet run -c Release --project Playground/Http3/MutualTls
// curl --http3 --cacert ca.crt --cert client.crt --key client.key https://localhost:8443/

using System.Text;
using ioxide;
using ioxide.http3;
using ioxide.ngtcp2;

// ── Knobs ────────────────────────────────────────────────────────────────────────────────────

ushort quicPort = 8443;
int reactors = Environment.ProcessorCount;


// The server&#x27;s own certificate and key. Null generates a self-signed pair on first run.
string? certOverride = null;
string? keyOverride = null;


// The CA that client certificates are checked against. This is what turns mTLS ON - leave it null
// and the server verifies nothing about the client, exactly as the other h3 samples do.
string? clientCaPath = Environment.GetEnvironmentVariable(&quot;PLAYGROUND_CLIENT_CA&quot;);

// Refuse a client that offers no certificate, during the handshake. Off, so a client without one
// still connects and the handler decides what it may see - which is the more useful default when
// only part of a site is protected.
bool requireClientCertificate = Environment.GetEnvironmentVariable(&quot;PLAYGROUND_REQUIRE_CLIENT_CERT&quot;) == &quot;1&quot;;

// UDP receive slots per reactor: how many datagrams the ring can have outstanding at once.
int udpRecvSlots = 16;
// ─────────────────────────────────────────────────────────────────────────────────────────────

const string certPath = &quot;cert.pem&quot;; // any PEM pair
const string keyPath = &quot;key.pem&quot;;

if (clientCaPath is null)
{
Console.Error.WriteLine(&quot;set PLAYGROUND_CLIENT_CA to a PEM bundle of the CA that signs your client certificates.&quot;);
return 1;
}

using var engine = new QuicEngine(certPath, keyPath, cidLength: 8, alpn: [&quot;h3&quot;],
clientCaPemPath: clientCaPath, requireClientCertificate: requireClientCertificate);

var config = new ServerConfig
{
ReactorCount = reactors,
Tcp = null, // QUIC only
Udp = new UdpOptions { RecvSlots = udpRecvSlots },
Quic = new QuicOptions
{
Port = quicPort,
LocalCidLength = 8,
ConnectionFactory = engine.CreateFactory(),
},
};

var threads = new Thread[config.ReactorCount];

for (int i = 0; i &lt; threads.Length; i++)
{
var reactor = new Reactor(i, config);

reactor.QuicHandle = (_, connection) =&gt;
new Http3Connection(connection).RunAsync(_ =&gt;
{
// Read HERE, per request - not where the connection is accepted. That callback runs
// before the handshake finishes, so there is no identity yet at that point.
string? peer = (connection as QuicEngineConnection)?.PeerSubject;

var response = new Http3Response
{
Body = Encoding.UTF8.GetBytes(peer is null
? &quot;anonymous\n&quot;
: $&quot;authenticated as {peer}\n&quot;),
};
response.Headers.Add((&quot;content-type&quot;u8.ToArray(), &quot;text/plain&quot;u8.ToArray()));
return response;
});

threads[i] = new Thread(reactor.Run) { Name = $&quot;reactor-{i}&quot; };
threads[i].Start();
}

Console.WriteLine($&quot;[http3-mtls] {config.ReactorCount} reactors on :{quicPort}, &quot;
+ $&quot;client CA {clientCaPath}, &quot;
+ $&quot;client certificate {(requireClientCertificate ? &quot;REQUIRED&quot; : &quot;optional&quot;)}&quot;);

foreach (Thread thread in threads)
{
thread.Join();
}

return 0;</code></pre>
<p class="ex-foot">The client proves who it is too. <code>clientCaPemPath</code> is what turns it on - the CA that client certificates are checked against - and the handler reads <code>PeerSubject</code> to find out WHICH client it got. That distinction is the point: a server that can only answer <em>some valid certificate</em> has a gate, where one that can name the peer has an identity to authorise against. Read it per REQUEST, not where the connection is accepted - that callback runs before the handshake finishes, so there is no identity yet. <b>QUIC settles client authentication during the handshake</b>, and RFC 9001 &sect;4.4 forbids doing it afterwards, so this is a property of the whole CONNECTION: there is no asking for a certificate later because a request reached a protected route. That needs a second port. <code>requireClientCertificate</code> decides whether a client offering none is refused during the handshake or arrives unauthenticated for the handler to judge.</p>
</div>
<div class="pane pane-h3buf">
<div class="pane-head">
<h3>HTTP/3 &middot; buffered (nghttp3)</h3>
Expand Down
1 change: 1 addition & 0 deletions ioxide.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
<Project Path="Playground/Http3/Nghttp3Buffered/Playground.Http3.Nghttp3Buffered.csproj" />
<Project Path="Playground/Http3/Buffered/Playground.Http3.Buffered.csproj" />
<Project Path="Playground/Http3/StreamedBoth/Playground.Http3.StreamedBoth.csproj" />
<Project Path="Playground/Http3/MutualTls/Playground.Http3.MutualTls.csproj" />
<Project Path="Playground/Http3/Nghttp3Response/Playground.Http3.Nghttp3Response.csproj" />
</Folder>

Expand Down
16 changes: 16 additions & 0 deletions scripts/gen-docs-panes.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,22 @@
"the trade is that memory holds the whole body, which suits normal requests and not hostile "
"uploads. <b>Streamed</b> runs you while the body is still arriving and credits the peer's "
"flow-control window as you read, so memory is bound by one window instead."),
"h3mtls": (
"Http3/MutualTls", "HTTP/3 &middot; mutual TLS", "ioxide + ioxide.ngtcp2 + ioxide.http3",
["PLAYGROUND_CLIENT_CA=ca.crt dotnet run -c Release --project Playground/Http3/MutualTls",
"curl --http3 --cacert ca.crt --cert client.crt --key client.key https://localhost:8443/"],
"The client proves who it is too. <code>clientCaPemPath</code> is what turns it on - the CA "
"that client certificates are checked against - and the handler reads "
"<code>PeerSubject</code> to find out WHICH client it got. That distinction is the point: a "
"server that can only answer <em>some valid certificate</em> has a gate, where one that can "
"name the peer has an identity to authorise against. "
"Read it per REQUEST, not where the connection is accepted - that callback runs before the "
"handshake finishes, so there is no identity yet. "
"<b>QUIC settles client authentication during the handshake</b>, and RFC 9001 &sect;4.4 "
"forbids doing it afterwards, so this is a property of the whole CONNECTION: there is no "
"asking for a certificate later because a request reached a protected route. That needs a "
"second port. <code>requireClientCertificate</code> decides whether a client offering none "
"is refused during the handshake or arrives unauthenticated for the handler to judge."),
"quicalpn": (
"Quic/Alpn", "QUIC &middot; two protocols by ALPN", "ioxide + ioxide.ngtcp2 + ioxide.nghttp3",
["curl --http3-only -k https://127.0.0.1:8443/"],
Expand Down
2 changes: 1 addition & 1 deletion src/clients/ioxide.file/ioxide.file.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<RootNamespace>ioxide.file</RootNamespace>

<PackageId>ioxide.file</PackageId>
<Version>0.4.178</Version>
<Version>0.4.179</Version>
<Authors>MDA2AV</Authors>
<Description>File serving for the ioxide io_uring runtime: immutable asset snapshots with baked responses, pooled positional ring reads, atomic reloads.</Description>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
Expand Down
2 changes: 1 addition & 1 deletion src/clients/ioxide.httpclient/ioxide.httpclient.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<RootNamespace>ioxide.httpclient</RootNamespace>

<PackageId>ioxide.httpclient</PackageId>
<Version>0.4.178</Version>
<Version>0.4.179</Version>
<Authors>MDA2AV</Authors>
<Description>The ring-native HTTP client for the ioxide io_uring runtime: HTTP/1.1, HTTP/2 and HTTP/3 behind one API, with the protocol chosen per origin via Alt-Svc. One package - the h1 parser, a pure-C# HTTP/2 client on ioxide.http2's framing, the nghttp3 bridge, client-side TLS (SNI, ALPN and certificate verification) for https:// origins, and the negotiating client - sharing one set of message types. Every response resumes the awaiting handler inline on its own reactor thread.</Description>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
Expand Down
2 changes: 1 addition & 1 deletion src/clients/ioxide.pg/ioxide.pg.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<RootNamespace>ioxide.pg</RootNamespace>

<PackageId>ioxide.pg</PackageId>
<Version>0.4.178</Version>
<Version>0.4.179</Version>
<Authors>MDA2AV</Authors>
<Description>Postgres driver for the ioxide io_uring runtime: pooled ring-native connections per reactor, ring-native connect and handshake, inline completion resume.</Description>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
Expand Down
2 changes: 1 addition & 1 deletion src/clients/ioxide.redis/ioxide.redis.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<RootNamespace>ioxide.redis</RootNamespace>

<PackageId>ioxide.redis</PackageId>
<Version>0.4.178</Version>
<Version>0.4.179</Version>
<Authors>MDA2AV</Authors>
<Description>Redis client for the ioxide io_uring runtime: pooled ring-native connections per reactor, full RESP2 protocol, a generic command API plus typed helpers (strings, keys, hashes, lists, sets, sorted sets, pub/sub, transactions, scripting), and pipelining. Inline completion resume.</Description>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
Expand Down
Loading
Loading