feat(tls): add a libssl TLS transport and an https:// HTTP/1.1 stack

HTTP/1.1 was plaintext-only, which left `https://` to either an HTTP/3
listener or a terminating proxy in front. Neither helps the callers this
stack exists for — curl scripts, CI tooling, old proxies — so wrap the
transport in libssl instead.

Two new partitions:

  :Stream  a ByteStream with per-call deadlines on both directions, plus
           the plaintext socket implementation. The HTTP/1.1 client and
           listener now hold a ByteStream& and never learn which
           transport they have, which is what lets one code path serve
           both schemes.
  :TLS     TLSContext/TLSStream over OpenSSL 3, with credentials for both
           roles: chain and hostname verification on by default, private
           trust anchors, client certificates, mutual TLS, ALPN, and an
           in-process self-signed certificate for development.

Both descriptors go non-blocking and every read and write is driven by
poll() against a deadline. That is required for TLS — a blocking
descriptor cannot express a handshake timeout — and it means a plaintext
write can now time out too, instead of parking forever against a peer
that stopped reading.

ClientHTTP1 and ListenerHTTP1 gain credential-taking constructors; the
existing ones still speak http://. The listener handshakes on the
connection's own thread, so a peer that stalls mid-handshake costs one
thread rather than the accept loop, and a failed handshake is counted
rather than logged — on a public port it is ordinary traffic.

MessageParser gains SetDefaultScheme so origin-form targets report the
scheme the transport actually used; handlers shared with ListenerHTTP now
see the same "https" they would over HTTP/3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
catbot 2026-07-28 20:13:30 +00:00
commit 9c22cbe09e
11 changed files with 1299 additions and 99 deletions

View file

@ -299,6 +299,14 @@ namespace Crafter::HTTP1 {
headRequest = EqualsIgnoreCase(requestMethod, "HEAD");
}
// Scheme reported for origin-form request targets, which carry none of
// their own. Only the transport knows — "https" once TLS is
// terminating the connection. An absolute-form target still wins, as
// it names its own scheme. Survives Reset().
void SetDefaultScheme(std::string scheme) {
defaultScheme = std::move(scheme);
}
void Feed(const char* data, std::size_t size) {
if (size != 0) buffer.append(data, size);
Advance();
@ -340,7 +348,7 @@ namespace Crafter::HTTP1 {
HTTPRequest request;
request.method = std::move(method);
request.path = std::move(path);
request.scheme = scheme.empty() ? std::string("http") : std::move(scheme);
request.scheme = scheme.empty() ? defaultScheme : std::move(scheme);
// Absolute-form targets carry their own authority and win over
// Host (RFC 9112 §3.2.2); otherwise Host supplies it. Either way
// it lives in the named field, matching the HTTP/3 shape, so it
@ -759,6 +767,10 @@ namespace Crafter::HTTP1 {
std::string method;
std::string path;
std::string scheme;
// Not cleared by Reset(): the transport does not change under a
// connection, so it is told to the parser once and applies to every
// request on it.
std::string defaultScheme = "http";
std::string authority;
std::string version;
std::string status;