Skip to content

httpclient: HTTP/1.1 only, parsed by Glyph11, with mutual TLS - #184

Merged
MDA2AV merged 5 commits into
mainfrom
feat/httpclient-h1-only
Aug 12, 2026
Merged

httpclient: HTTP/1.1 only, parsed by Glyph11, with mutual TLS#184
MDA2AV merged 5 commits into
mainfrom
feat/httpclient-h1-only

Conversation

@MDA2AV

@MDA2AV MDA2AV commented Aug 12, 2026

Copy link
Copy Markdown
Owner

ioxide.httpclient shipped HTTP/1.1, HTTP/2 and HTTP/3 behind one API. Three client protocol stacks is more than this project can keep current, and it showed: the mTLS work in #180 gave ioxide a server that can require client certificates and left it without a client that could present one, and no test noticed - because the E2E suite drives H3TestClient, which carries its own private DllImport of the capability the shipped client lacks.

This narrows the package to the leg that actually carries proxy-to-origin traffic, and makes that leg good.

6,765 deletions, 599 insertions.

1. Drop the h2 and h3 clients

HTTP/2 and HTTP/3 clients mean owning HPACK and QPACK, dynamic tables in both directions, and per-stream flow control. HTTP/1.1 is small enough to keep correct.

Gone: Http2ClientConnection/Pool, Http3ClientConnection/Pool/Messages, and RingHttpClient - the Alt-Svc negotiating client, which only existed because there were three protocols to choose between. With it go AltSvcTests, Http2ClientTests, Http3ClientTests, RingHttpClientTests, the two h2-over-TLS cases in TlsClientTests, and ResponseAssembly (dead once h2/h3 went - h1 never used it).

Playground had a full 3x3 proxy matrix. Six samples die; the three that remain - H1ToH1, H2ToH1, H3ToH1 - are the shapes people deploy. Site panes regenerated from the samples, so the docs cannot drift from them.

The dependency graph is the real prize. ioxide.httpclient referenced ioxide.ngtcp2, ioxide.http2 and ioxide.nghttp3; it now references ioxide core alone, and the InternalsVisibleTo("ioxide.httpclient") declarations are gone from all three protocol packages. A client that shares no internals with a protocol package cannot drift out of sync with one - which is the failure this PR exists to stop repeating.

QuicClientEngine survives: it is the load driver for the raw QUIC echo samples in bench/any.sh, not dead code. Tracked separately in #183.

2. Parse responses with Glyph11 instead of by hand

The hand-rolled head parser was written to get a response out of a well-behaved origin. It skipped any line it could not read ("tolerate a junk line rather than failing the response"), took the last Content-Length it saw rather than rejecting conflicting ones, had no opinion on Transfer-Encoding arriving beside one, and trimmed with Trim(' ') so a HTAB survived into the value. On a keep-alive connection each of those is a desync waiting for the next request.

Glyph11 0.3.7 does that job properly. Pure managed, no native asset, no transitive dependency.

Five new tests drive responses no server should send. Every one was accepted before this change:

response before now
Transfer-Encoding + Content-Length accepted refused
two conflicting Content-Length took the last refused
obs-fold continuation line accepted refused
bare LF inside a header line accepted refused
HEAD with Content-Length: 1024 blocked for bytes never coming no body

That last row is free rather than hand-maintained: framing moved to BodyFramingDetector, which takes the request method, so HEAD, 1xx, 204, 304 and CONNECT tunnels all come from one call.

Completeness is the parser's answer too, so the separate scan for \r\n\r\n is gone. Note the +1: Glyph11 reports one less than the block's real size, as its own diff harness documents - raised upstream as dotnet-web-stack/Glyph11#58.

No performance cost

Same origin, both client builds, interleaved, 3 reps each:

mean range
main 416,567 req/s 407k-425k
this branch 413,364 req/s 410k-416k

-0.8%, with this branch's range sitting entirely inside main's. Hardening for free.

3. Mutual TLS

TlsClientOptions gains CertificateFile and PrivateKeyFile, loaded through SSL_CTX_use_certificate_chain_file and SSL_CTX_use_PrivateKey_file. The chain form, not the leaf form: an origin validating us needs the intermediates, and leaf-only works against a store that already holds them while failing everywhere else.

Nothing here arms anything - client authentication is driven by the server sending a CertificateRequest, so configuring a certificate against an origin that never asks costs a file read and changes no handshake. Unlike QUIC there is no RFC 9001 §4.4 restriction to reason about.

Both configuration mistakes fail where the configuration is written rather than as an opaque handshake error later: one of the pair without the other throws, and a key that does not match its certificate throws (OpenSSL catches it while loading the key; check_private_key backstops the rest).

Five tests, driven against SslStream so a pass means agreeing with an independent implementation rather than only with ourselves. The accept case asserts CN=alice actually reached the origin, not merely that the handshake succeeded.

Also

All packages to 0.4.184. Dropped the claim from ioxide.http2 and ioxide.nghttp2 that they back an HTTP/2 client in ioxide.httpclient - they no longer do, and that text ships on nuget.org.

Verification

Build clean, 0 warnings. 25 unit and 28 http tests green.

MDA2AV added 5 commits August 12, 2026 00:39
The client half never kept pace with the server half. HTTP/2 and HTTP/3
clients mean owning HPACK and QPACK, dynamic tables in both directions and
per-stream flow control, and nothing drove them: the E2E suite proved the
test harness worked, not the shipped package. The mTLS gap was the tell -
QuicClientEngine still cannot present a certificate to a server this repo
just taught to demand one, and no test noticed.

HTTP/1.1 is the leg that actually carries proxy-to-origin traffic, and it is
small enough to keep correct.

Gone: Http2ClientConnection/Pool, Http3ClientConnection/Pool/Messages, and
RingHttpClient - the Alt-Svc negotiating client, which only existed because
there were three protocols to choose between. With it go AltSvcTests,
Http2ClientTests, Http3ClientTests, RingHttpClientTests, the two h2-over-TLS
cases in TlsClientTests, and six of the nine Proxy/* samples. The three that
remain - h1/h2/h3 in, h1 out - are the shapes people deploy.

The dependency graph is the real prize. ioxide.httpclient referenced
ioxide.ngtcp2, ioxide.http2 and ioxide.nghttp3; it now references ioxide core
alone, and the InternalsVisibleTo("ioxide.httpclient") declarations are gone
from all three protocol packages. A client that shares no internals with a
protocol package cannot drift out of sync with one.

Build clean, 29 unit and 18 http tests green.
The hand-rolled head parser was written to get a response out of a
well-behaved origin, and it showed. It skipped any line it could not read
("tolerate a junk line rather than failing the response"), took the last
Content-Length it saw rather than rejecting conflicting ones, had no opinion
on Transfer-Encoding arriving alongside a Content-Length, and trimmed values
with Trim(' ') so a HTAB survived into the value. Every one of those is a
desync waiting for the next request on a keep-alive connection.

Glyph11 0.3.7 does that job properly, so it does it now: bare LF, obs-fold,
whitespace before the colon, token and field-value charsets, Content-Length
format and duplicates, and the Transfer-Encoding + Content-Length pair. Pure
managed, no native asset, no transitive dependency - httpclient still
references ioxide core and nothing else of ours.

Framing moves to BodyFramingDetector, which takes the request method and
therefore gets HEAD right by construction: a HEAD response carries the
Content-Length its body would have had, so framing on the response alone
blocks waiting for bytes that are never coming. 1xx/204/304 and CONNECT
tunnels come from the same call rather than a hand-maintained list.

Completeness is now the parser's answer too - TryExtractFullResponseHeaderROM
returns false for a partial block - so the separate scan for the terminator
is gone. Note the +1: Glyph11 reports one less than the block's real size,
as its own diff harness documents.

Also drops ResponseAssembly and its tests, dead since the h2/h3 clients went.

New: five tests driving responses no server should send - both framings at
once, conflicting Content-Lengths, obs-fold, a bare LF inside a header line,
and the HEAD trap. All were accepted before this change.

Build clean, 25 unit and 23 http tests green.
… a second one

ioxide.utils already exports exactly this type; the nested copy was a
duplicate. Reset is internal to ioxide, so the view is rebuilt on the rare
realloc instead of re-pointed.
The gap that started this: ioxide could REQUIRE mutual TLS as a server (#180)
and had no client that could satisfy one. TlsClientOptions gains
CertificateFile and PrivateKeyFile, loaded through
SSL_CTX_use_certificate_chain_file and SSL_CTX_use_PrivateKey_file.

The chain form of the certificate call, not the leaf form: an origin
validating us needs the intermediates, and leaf-only works against a store
that already holds them while failing against every other one - the worst way
for this to break.

Nothing here arms anything. Client authentication is driven by the server
sending a CertificateRequest, so configuring a certificate against an origin
that never asks costs a file read and changes no handshake. Unlike QUIC there
is no RFC 9001 4.4 restriction to reason about.

Setting one of the two without the other throws at construction, as does a
key that does not match its certificate - OpenSSL catches the mismatch while
loading the key, and check_private_key backstops the rest. Both fail where
the configuration is written rather than as an opaque handshake error against
one origin later.

Five tests, driven against SslStream so a pass means we agree with an
independent implementation: a good certificate accepted and the identity
asserted at the origin (CN=alice actually arrived, not merely a successful
handshake), no certificate refused, a certificate from an untrusted CA
refused, and both configuration errors.

Build clean, 25 unit and 28 http tests green.
Also drops the stale claim from ioxide.http2 and ioxide.nghttp2 that they
back an HTTP/2 client in ioxide.httpclient. They no longer do, and the text
ships on nuget.org.
@MDA2AV
MDA2AV merged commit 30a6af8 into main Aug 12, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant