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

@ -0,0 +1,95 @@
//SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
export module Crafter.Network:Stream;
import std;
#ifndef CRAFTER_NETWORK_BROWSER
namespace Crafter {
// A reliable, ordered byte stream with deadlines on both directions.
//
// This exists so the HTTP/1.1 client and listener can be written once and
// run over either a bare socket or a TLS session: `PlainStream` below is
// the `http://` transport, `TLSStream` (in :TLS) the `https://` one. The
// HTTP/1.1 code holds a `ByteStream&` and never learns which it has.
//
// Every method takes its own timeout rather than the stream carrying one,
// because HTTP/1.1 uses different budgets for different states — a long
// idle keep-alive wait, a shorter one once a request has started.
export enum class StreamStatus {
Data, // `read` bytes are available in the buffer
Closed, // the peer closed its send side, cleanly
TimedOut, // nothing arrived before the deadline
};
export class ByteStream {
public:
virtual ~ByteStream() = default;
ByteStream() = default;
ByteStream(const ByteStream&) = delete;
ByteStream& operator=(const ByteStream&) = delete;
// Read whatever is already available, waiting at most `timeout` for
// the first byte. Sets `read` and returns Data, or reports a clean
// close / a timeout. Throws on a transport error.
virtual StreamStatus ReadSome(char* buffer, std::size_t size,
std::chrono::milliseconds timeout,
std::size_t& read) = 0;
// Write the whole buffer. Throws if it could not all be handed over
// within `timeout`.
virtual void Write(const void* buffer, std::size_t size,
std::chrono::milliseconds timeout) = 0;
// Best-effort orderly close of our send side. Never throws — it runs
// on teardown paths where there is nothing useful to do with a
// failure.
virtual void Shutdown() noexcept = 0;
// The underlying descriptor, so a listener can shutdown(2) it to wake
// a thread parked in poll().
virtual int Descriptor() const noexcept = 0;
// Negotiated ALPN protocol, empty when the transport has no notion of
// one (plaintext) or nothing was agreed.
virtual std::string_view Protocol() const noexcept { return {}; }
// Whether the bytes are encrypted on the wire.
virtual bool Secure() const noexcept { return false; }
};
// Plaintext TCP. Non-owning: the descriptor stays owned by the ClientTCP
// (or whatever else) that opened it.
//
// The descriptor is switched to non-blocking on construction — both
// directions are driven by poll() against a deadline, which a blocking
// descriptor cannot express. That is also what lets a write time out
// instead of parking forever against a peer that has stopped reading.
export class PlainStream final : public ByteStream {
public:
explicit PlainStream(int descriptor);
StreamStatus ReadSome(char* buffer, std::size_t size,
std::chrono::milliseconds timeout,
std::size_t& read) override;
void Write(const void* buffer, std::size_t size,
std::chrono::milliseconds timeout) override;
void Shutdown() noexcept override;
int Descriptor() const noexcept override { return descriptor; }
private:
int descriptor;
};
// Put a descriptor into non-blocking mode. Exposed because :TLS needs the
// same thing for the descriptor it wraps.
export void SetNonBlocking(int descriptor);
// Wait until `descriptor` is ready for `events` (a poll(2) event mask) or
// `deadline` passes; false means the deadline won. Retries across EINTR
// and throws on a real poll failure. Shared with :TLS, which has to poll
// for whichever direction OpenSSL asks for next.
export bool PollDescriptor(int descriptor, short events,
std::chrono::steady_clock::time_point deadline);
}
#endif