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

@ -5,13 +5,14 @@ export module Crafter.Network:ClientHTTP1;
import std;
import :HTTP;
import :HTTP1;
import :TLS;
#ifndef CRAFTER_NETWORK_BROWSER
namespace Crafter {
// HTTP/1.1 client over plain TCP, for peers that cannot speak HTTP/3.
// The request/response types are the ones the HTTP/3 client uses, so
// swapping ClientHTTP for ClientHTTP1 is a one-line change at the call
// site.
// HTTP/1.1 client over TCP, with or without TLS, for peers that cannot
// speak HTTP/3. The request/response types are the ones the HTTP/3 client
// uses, so swapping ClientHTTP for ClientHTTP1 is a one-line change at the
// call site.
//
// The connection is persistent: the first Send() dials, and later calls
// reuse the socket unless the peer asked for it to be closed
@ -25,9 +26,12 @@ namespace Crafter {
// Thread-affinity matches ClientHTTP: one ClientHTTP1 serves one caller
// at a time; distinct instances are independent.
//
// No TLS. This talks `http://`; for an encrypted transport use
// ClientHTTP (HTTP/3 over QUIC), or put a TLS-terminating proxy in
// front of the HTTP/1.1 endpoint.
// `http://` or `https://` is chosen by the constructor: pass
// TLSClientCredentials and every byte goes through libssl (see :TLS),
// leave them out and the transport is plaintext. TLS changes nothing
// above the transport — the same keep-alive, replay and framing rules
// apply, and `authority` still defaults to host:port with the scheme's
// default port elided (443 under TLS, 80 without).
export class ClientHTTP1 {
public:
std::string host;
@ -36,13 +40,21 @@ namespace Crafter {
ClientHTTP1(const char* host, std::uint16_t port);
ClientHTTP1(std::string host, std::uint16_t port);
// https://. The credentials verify the server's certificate chain and
// its name against `host` by default; see TLSClientCredentials for
// self-signed peers, private trust anchors and client certificates.
// Throws TLSException if the certificate is rejected.
ClientHTTP1(const char* host, std::uint16_t port, TLSClientCredentials credentials);
ClientHTTP1(std::string host, std::uint16_t port, TLSClientCredentials credentials);
~ClientHTTP1();
ClientHTTP1(const ClientHTTP1&) = delete;
ClientHTTP1(ClientHTTP1&&) noexcept;
// Send a request and read the full response. `authority` defaults to
// the host:port this client was constructed with; `scheme` is
// ignored (the transport is plaintext).
// the host:port this client was constructed with; `scheme` is ignored
// — HTTP/1.1 request targets are origin-form and the transport was
// already decided by the constructor.
HTTPResponse Send(const HTTPRequest& request);
// Send a request and deliver the response (or the error text) via
@ -55,6 +67,14 @@ namespace Crafter {
// tests asserting that keep-alive actually kept the socket.
bool Connected() const noexcept;
// Whether this client speaks https://.
bool Secure() const noexcept;
// ALPN protocol the last connection negotiated, empty for plaintext
// or when the server offered no ALPN. For an https:// client with the
// default credentials this is "http/1.1".
std::string_view Protocol() const noexcept;
// Drop the pooled connection; the next Send() dials again.
void Disconnect();
@ -63,6 +83,10 @@ namespace Crafter {
// How long to wait for the next piece of a response before giving
// up on a server that accepted the connection and then went quiet.
std::chrono::milliseconds timeout{30000};
// How long the TLS handshake may take, on an https:// client. Separate
// from `timeout` because it covers a multi-round-trip exchange before
// any request has been written.
std::chrono::milliseconds handshakeTimeout{15000};
private:
struct Impl;