httpclient: HTTP/1.1 only, parsed by Glyph11, with mutual TLS - #184
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
ioxide.httpclientshipped 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 drivesH3TestClient, which carries its own privateDllImportof 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, andRingHttpClient- the Alt-Svc negotiating client, which only existed because there were three protocols to choose between. With it goAltSvcTests,Http2ClientTests,Http3ClientTests,RingHttpClientTests, the two h2-over-TLS cases inTlsClientTests, andResponseAssembly(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.httpclientreferencedioxide.ngtcp2,ioxide.http2andioxide.nghttp3; it now referencesioxidecore alone, and theInternalsVisibleTo("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.QuicClientEnginesurvives: it is the load driver for the raw QUIC echo samples inbench/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-Lengthit saw rather than rejecting conflicting ones, had no opinion onTransfer-Encodingarriving beside one, and trimmed withTrim(' ')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:
Transfer-Encoding+Content-LengthContent-LengthContent-Length: 1024That 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\nis 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:
-0.8%, with this branch's range sitting entirely inside main's. Hardening for free.
3. Mutual TLS
TlsClientOptionsgainsCertificateFileandPrivateKeyFile, loaded throughSSL_CTX_use_certificate_chain_fileandSSL_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_keybackstops the rest).Five tests, driven against
SslStreamso a pass means agreeing with an independent implementation rather than only with ourselves. The accept case assertsCN=aliceactually reached the origin, not merely that the handshake succeeded.Also
All packages to 0.4.184. Dropped the claim from
ioxide.http2andioxide.nghttp2that they back an HTTP/2 client inioxide.httpclient- they no longer do, and that text ships on nuget.org.Verification
Build clean, 0 warnings. 25 unit and 28 http tests green.