https #6

Merged
jorijnvdgraaf merged 5 commits from claude/issue-3 into master 2026-07-30 16:18:39 +00:00
15 changed files with 2074 additions and 112 deletions

View file

@ -11,7 +11,7 @@ Crafter.Network is a C++ networking library designed for modern C++ applications
- **TCP Networking**: Client and server implementations for raw TCP connections (native only).
- **QUIC Networking**: Encrypted, multi-stream transport via msquic — reliable streams for control plane, unreliable datagrams for low-latency state sync.
- **HTTP/3**: Client and server implementations on top of QUIC. Uses ALPN `h3`, QUIC bidi streams for requests/responses, the mandatory unidirectional control stream + SETTINGS frame (RFC 9114 §6.2.1), the (empty) QPACK encoder + decoder unidi streams required by stricter peers like Chromium, and a built-in QPACK codec (RFC 9204) with the full static table, Huffman *decoding* (RFC 7541), and literal-only emission. The QPACK dynamic table is unused. The client is interoperable with mainstream public h3 endpoints (cloudflare, nghttp3-based servers, etc.).
- **HTTP/1.1**: Client and server over plain TCP (RFC 9112), for the large part of the world that is not ready for HTTP/3 — old proxies, CI tooling, load balancers, `curl` scripts. Shares the `HTTPRequest`/`HTTPResponse` types and the route-map API — including the `fallback` hook for paths that cannot be enumerated — with the HTTP/3 stack, so a handler or call site moves between the two by changing the class name. Keep-alive and pipelining, `content-length` and `chunked` bodies with trailers, `Expect: 100-continue`, HEAD, automatic `Date`, and per-connection timeouts. Plaintext only — see [HTTP/1.1 Components](#http11-components).
- **HTTP/1.1**: Client and server over plain TCP (RFC 9112), for the large part of the world that is not ready for HTTP/3 — old proxies, CI tooling, load balancers, `curl` scripts. Shares the `HTTPRequest`/`HTTPResponse` types and the route-map API — including the `fallback` hook for paths that cannot be enumerated — with the HTTP/3 stack, so a handler or call site moves between the two by changing the class name. Keep-alive and pipelining, `content-length` and `chunked` bodies with trailers, `Expect: 100-continue`, HEAD, automatic `Date`, and per-connection timeouts. `https://` on both sides via libssl, including ALPN and mutual TLS — see [HTTP/1.1 Components](#http11-components).
- **WebTransport (server)**: `ListenerHTTP` accepts extended-CONNECT sessions (`:method=CONNECT, :protocol=webtransport`) negotiated on the existing h3 listener — no separate port or alternate stack. Both draft-02 and draft-07+ identifier sets are advertised in SETTINGS so current Chrome/Edge browsers connect out of the box. Per-route handlers receive a `WebTransportSession&` and can multiplex bidirectional streams over the session.
- **Browser client**: Same C++ API compiled to wasm32-wasip1 and routed through `fetch()` (for `ClientHTTP`) and `WebTransport` (for `ClientQUIC`). Listeners and raw TCP are not compiled in the browser build — the browser is client-only.
- **Asynchronous Operations**: Thread poolbased async operations on native; the same `*Async` API on the browser side, where it's required (no synchronous I/O in the browser event loop).
@ -29,8 +29,10 @@ The library follows a modular design using C++20 modules:
- `Crafter.Network:ClientHTTP`: HTTP/3 client (ALPN `h3`). On browser builds this maps to `fetch()`.
- `Crafter.Network:ListenerHTTP`: HTTP/3 + WebTransport server (ALPN `h3`, native only)
- `Crafter.Network:HTTP`: HTTP request/response types, constructors, and `PathWithoutQueryHTTP`, shared by every HTTP version
- `Crafter.Network:ClientHTTP1`: HTTP/1.1 client over TCP (native only)
- `Crafter.Network:ListenerHTTP1`: HTTP/1.1 server over TCP (native only)
- `Crafter.Network:ClientHTTP1`: HTTP/1.1 client over TCP, plaintext or TLS (native only)
- `Crafter.Network:ListenerHTTP1`: HTTP/1.1 server over TCP, plaintext or TLS (native only)
- `Crafter.Network:Stream`: `ByteStream` — a reliable byte stream with deadlines on both directions — plus `PlainStream`, the plaintext socket implementation. The seam that lets one HTTP/1.1 implementation serve `http://` and `https://` (native only)
- `Crafter.Network:TLS`: TLS over a connected socket via libssl (OpenSSL 3): `TLSContext`, `TLSStream`, and the credential types for both roles. Protocol-agnostic — usable over any descriptor, not just HTTP (native only)
- `Crafter.Network:HTTP1`: HTTP/1.1 wire format — serialisation plus an incremental parser (RFC 9112). Transport-free; usable on its own to speak HTTP/1.1 over some other byte stream. Native only, for the same reason as `:HTTP3`
- `Crafter.Network:ClientQUIC`: QUIC connection (client + accepted-server side) with reliable streams and unreliable datagrams. On browser builds this maps to the `WebTransport` JS API.
- `Crafter.Network:ListenerQUIC`: QUIC listener accepting incoming connections (native only). Also exports `ComputeCertificateHashSHA256()` and `GetSelfSignedCertificatePath()` for browser-peer cert pinning.
@ -95,7 +97,7 @@ The `HTTPRequest` exposes the four HTTP/3 pseudo-headers (`method`, `scheme`, `a
### HTTP/1.1 Components
HTTP/3 is not something every peer can be made to speak. `ClientHTTP1` and `ListenerHTTP1` provide the same API over plain TCP, using the same `HTTPRequest`/`HTTPResponse` types and the same route-map shape, so a handler can be registered with both and served over either protocol.
HTTP/3 is not something every peer can be made to speak. `ClientHTTP1` and `ListenerHTTP1` provide the same API over TCP — with or without TLS — using the same `HTTPRequest`/`HTTPResponse` types and the same route-map shape, so a handler can be registered with both and served over either protocol.
#### ClientHTTP1
```cpp
@ -106,7 +108,7 @@ Crafter::HTTPResponse response = client.Send(
);
```
The connection is persistent: the first `Send()` dials, and later calls reuse the socket unless the peer asked for it to be closed. A pooled connection that the peer closed while it looked idle — the race HTTP/1.1 keep-alive cannot avoid — is redialled once and the request replayed; nothing is replayed once a response byte has arrived, and a freshly dialled connection is never retried on, so a genuinely broken server surfaces as an exception rather than a retry loop. `authority` defaults to the host:port the client was constructed with (the port is elided when it is 80).
The connection is persistent: the first `Send()` dials, and later calls reuse the socket unless the peer asked for it to be closed. A pooled connection that the peer closed while it looked idle — the race HTTP/1.1 keep-alive cannot avoid — is redialled once and the request replayed; nothing is replayed once a response byte has arrived, and a freshly dialled connection is never retried on, so a genuinely broken server surfaces as an exception rather than a retry loop. `authority` defaults to the host:port the client was constructed with (the port is elided when it is the scheme default — 443 under TLS, 80 without).
#### ListenerHTTP1
```cpp
@ -123,13 +125,52 @@ Each accepted connection gets its own thread and is served sequentially until th
Routing matches `path` exactly and then falls back to the query-stripped path, so `/thing?x=1` reaches the handler registered for `/thing` while the handler still sees the full target in `request.path`. A handler that throws becomes a 500; an unknown path a 404 (or `fallback`, see [Routes that cannot be enumerated](#routes-that-cannot-be-enumerated)); a request we refuse to parse a 400. `Date` is stamped automatically unless the handler set one, HEAD returns the headers a GET would have produced with no body, and a handler can end the connection by answering with a `connection: close` header.
Implemented: keep-alive, pipelining, `content-length` and `chunked` request bodies with trailers, `Expect: 100-continue`, absolute-form request targets, obs-fold, and HTTP/1.0 peers (which only get connection reuse when they ask for it). Not implemented: TLS, CONNECT tunnels, `Upgrade`, and chunked *responses* — handlers return a complete body, so responses are always `content-length` framed.
Implemented: TLS (see [HTTPS](#https)), keep-alive, pipelining, `content-length` and `chunked` request bodies with trailers, `Expect: 100-continue`, absolute-form request targets, obs-fold, and HTTP/1.0 peers (which only get connection reuse when they ask for it). Not implemented: CONNECT tunnels, `Upgrade`, and chunked *responses* — handlers return a complete body, so responses are always `content-length` framed.
Ambiguous framing is rejected rather than guessed at, because guessing is how request smuggling happens (RFC 9112 §11.2): `Content-Length` together with `Transfer-Encoding`, disagreeing duplicate `Content-Length` values, and whitespace between a field name and its colon are all 400s. CR/LF in a header value we are asked to *send* throws instead of splitting the message.
#### No TLS
#### HTTPS
`ClientHTTP1`/`ListenerHTTP1` speak `http://` only. There is no `https://` support and no plan to link a TLS stack into this path: for encrypted traffic use `ClientHTTP`/`ListenerHTTP` (HTTP/3 over QUIC, which is always encrypted), or terminate TLS in a proxy in front of the HTTP/1.1 endpoint. Do not expose an HTTP/1.1 listener directly to the internet.
`https://` is a constructor argument, not a different class. Pass credentials and every byte goes through libssl (OpenSSL 3); leave them out and the transport is plaintext. Nothing above the transport changes — same routes, same keep-alive and replay rules, same framing.
```cpp
// Server. selfSigned mints an ephemeral development certificate; in
// production set certPath/keyPath (or certPem/keyPem) instead.
Crafter::ListenerAsyncHTTP1 listener(8443, std::move(routes),
Crafter::TLSServerCredentials{ .selfSigned = true });
// Client. The default verifies the chain against the system trust store
// *and* the hostname — a chain check alone accepts any valid certificate
// for any name, which is not a check.
Crafter::TLSClientCredentials credentials;
Crafter::ClientHTTP1 client("example.com", 443, credentials);
```
To verify a self-signed or privately issued server without giving up verification, hand the client the certificate as a trust anchor (`caPath` for a file or hashed directory, `caPem` for the bytes) rather than reaching for `insecureNoServerValidation` — that switch accepts an attacker's certificate too, and exists for development only.
ALPN is negotiated: the listener advertises `http/1.1` and a client offering only something else (say `h2`) is refused with `no_application_protocol` rather than being served an HTTP/1.1 response it cannot parse. `alpnProtocols` on either side changes what is offered or accepted. Requests reach handlers with `scheme` set to `https`, so a handler shared with `ListenerHTTP` sees the same thing over both protocols.
Mutual TLS: set `requireClientCertificate` with a `clientCaPath`, and a peer presenting no certificate — or one that does not chain to that CA — is rejected during the handshake. `HandshakeFailureCount()` counts connections dropped that way; on a public port those are ordinary traffic (scanners, misconfigured callers) rather than something to alert on. Client certificates are supplied by `certPath`/`keyPath` on `TLSClientCredentials`.
The handshake runs on the connection's own thread, so a peer that stalls halfway through it costs one thread rather than the accept loop. `handshakeTimeout` (default 15 s) bounds it on both sides.
For an encrypted transport with better properties than TLS-over-TCP, `ClientHTTP`/`ListenerHTTP` speak HTTP/3 over QUIC, which is always encrypted.
#### TLS on its own
`Crafter::TLSContext` and `Crafter::TLSStream` are transport-agnostic: anything holding a connected descriptor can put a record layer over it, HTTP or not. `TLSStream` implements `Crafter::ByteStream` — the same interface `PlainStream` implements and the HTTP/1.1 endpoints are written against — so code that reads and writes through a `ByteStream&` works over either.
```cpp
Crafter::ClientTCP socket("example.com", 443);
auto context = Crafter::TLSContext::Client({});
auto stream = Crafter::TLSStream::Connect(socket.socketid, context,
"example.com", std::chrono::seconds(10));
stream->Write(request.data(), request.size(), std::chrono::seconds(10));
```
The descriptor stays owned by its `ClientTCP`; the stream only adds the record layer. Both factories complete the handshake before returning, so a stream you hold is one you can write to, and both throw `Crafter::TLSException` — with the specific certificate error, not just "handshake failed" — when they cannot. Reads and writes are driven by `poll()` against a deadline, which is what makes the timeouts real; the descriptor is put into non-blocking mode to allow it.
`GetSelfSignedCertificatePem()` returns the process-wide development certificate (ECDSA P-256, `CN=localhost`, SANs for `localhost`/`127.0.0.1`/`::1`, 10 days) so it can be written out for a peer process or handed to a client as a trust anchor. It is generated in-process and regenerated on every start — no peer has any reason to trust it.
#### HTTP/1.1 wire format on its own
@ -242,8 +283,11 @@ The library includes tests covering:
- HTTP/1.1 interop (`ShouldInteropCurlHTTP1`) — `curl` against `ListenerHTTP1` (keep-alive reuse, chunked upload, `Expect: 100-continue`, HEAD) and `ClientHTTP1` against python3's `http.server`, which answers HTTP/1.0 with `Connection: close`
- HTTP/1.1 under abuse (`ShouldSurviveAbuseHTTP1`) — 24 concurrent keep-alive clients, peers that vanish mid-request or send garbage, and a stalled peer that must be timed out
- Fallback routing (`ShouldFallbackUnknownRoutes`) — one route map plus a `fallback` replayed over both `ListenerHTTP1` and `ListenerHTTP`, asserting identical answers: exact routes win, query strings still route to the bare path, the fallback sees the full target, a throwing fallback is a 500, and an unset fallback still means a synthetic 404
- HTTPS round-trip (`ShouldSendRecieveHTTPS1`) — the plaintext round-trip replayed over TLS, so a transport regression surfaces as an HTTP failure, plus what only exists under TLS: ALPN, `scheme=https` reaching handlers, a body spanning many records, verification failing for an untrusted certificate *and* for a trusted certificate presented under the wrong name, and a plaintext peer on the TLS port being counted and shrugged off
- HTTPS interop (`ShouldInteropCurlHTTPS1`) — `curl` verifying our certificate with `--cacert` (not `--insecure`): keep-alive reuse, POST, HEAD, negotiated version, and an h2-only client being refused rather than mis-served; then `ClientHTTP1` against python3's `http.server` behind `ssl.wrap_socket`, whose HTTP/1.0 `Connection: close` frames the body by `close_notify`
- Mutual TLS and the raw stream (`ShouldRequireClientCertificateHTTPS1`) — a client certificate the listener's CA vouches for is served and one absent is refused; `TLSStream` is also driven directly with hand-written HTTP/1.1 to keep `:TLS` usable without `:ClientHTTP1` on top
The external-interop test requires outbound UDP/443; if your network blocks it the test will fail. `ShouldInteropCurlHTTP1` skips whichever half is unavailable when `curl` or `python3` is not installed, so it passes on a bare machine — install both to actually exercise it.
The external-interop test requires outbound UDP/443; if your network blocks it the test will fail. `ShouldInteropCurlHTTP1` and `ShouldInteropCurlHTTPS1` skip whichever half is unavailable when `curl` or `python3` is not installed, and the mutual-TLS half of `ShouldRequireClientCertificateHTTPS1` skips without the `openssl` CLI (a *client* certificate needs the `clientAuth` extended key usage, which the built-in development certificate does not carry) — so all three pass on a bare machine. Install `curl`, `python3` and `openssl` to actually exercise them.
## Dependencies
@ -251,7 +295,8 @@ The external-interop test requires outbound UDP/443; if your network blocks it t
- **msquic** (native target only) — fetched and built automatically as a Crafter `ExternalDependency` (no system install required). The build clones `microsoft/msquic` recursively into the per-project external cache, configures it via CMake (`QUIC_TLS_LIB=quictls`, tests/tools/perf disabled), and links the produced `libmsquic` into the QUIC and HTTP/3 modules. Skipped entirely on browser builds.
- On Linux msquic links against `libnuma` (provided by the `numactl` package on most distros).
- The self-signed-cert path used by tests/dev shells out to the `openssl` CLI; install `openssl` if you intend to use `QUICServerCredentials{selfSigned = true}`. The same path also produces the cert hash that browser peers need for `serverCertificateHashes`.
- The HTTP/1.1 stack has no dependencies beyond POSIX sockets — no TLS library, no msquic.
- **libssl / libcrypto** (OpenSSL 3, native target only) — a *system* package, unlike msquic, linked via `-lssl -lcrypto`. Backs `Crafter.Network:TLS`, i.e. `https://` on the HTTP/1.1 client and listener. It is not built here on purpose: OpenSSL 3 is present on every platform this targets, and vendoring it would mean shipping a second TLS stack next to the one msquic already links (quictls, whose symbols stay inside `libmsquic.so`). Install your distro's OpenSSL development package (`openssl` on Arch, `libssl-dev` on Debian/Ubuntu). Certificate generation for `TLSServerCredentials{selfSigned = true}` happens in-process through this library — no `openssl` CLI needed.
- Plaintext HTTP/1.1 needs nothing beyond POSIX sockets: `ClientHTTP1`/`ListenerHTTP1` built without credentials pull in no msquic and touch no TLS code path.
- **Browser build** has no extra dependencies beyond Crafter.Build's `wasi-browser` runtime: HTTP delegates to the browser's `fetch()`, QUIC to its `WebTransport`. The JS glue lives in `additional/network-env.js` and is shipped alongside the produced `.wasm`.
## Usage Example

View file

@ -2,8 +2,6 @@
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
module;
#include <poll.h>
#include <sys/socket.h>
#include <cerrno>
module Crafter.Network:ClientHTTP1_impl;
@ -11,41 +9,19 @@ import :ClientHTTP1;
import :ClientTCP;
import :HTTP;
import :HTTP1;
import :Stream;
import :TLS;
import Crafter.Thread;
import std;
using namespace Crafter;
namespace {
// Read whatever is available, waiting at most `timeout`. Returns 0 on a
// clean close by the peer. ClientTCP::RecieveSync() can't express a
// timeout — and a server that accepts the connection and then says
// nothing would hang Send() forever — so the read is done here.
std::size_t ReadSome(int socketid, char* buffer, std::size_t size,
std::chrono::milliseconds timeout) {
for (;;) {
pollfd pfd{ .fd = socketid, .events = POLLIN, .revents = 0 };
const int ready = poll(&pfd, 1, static_cast<int>(timeout.count()));
if (ready < 0) {
if (errno == EINTR) continue;
throw std::runtime_error(std::string("poll failed: ") + std::strerror(errno));
}
if (ready == 0) throw std::runtime_error("timed out waiting for the response");
const auto read = recv(socketid, buffer, size, 0);
if (read < 0) {
if (errno == EINTR) continue;
throw std::runtime_error(std::string("recv failed: ") + std::strerror(errno));
}
return static_cast<std::size_t>(read);
}
}
// Host header value. The port is elided when it is the scheme default,
// which is what every other HTTP/1.1 client on the wire does and what
// virtual-host matching on the far side tends to expect.
std::string DefaultAuthority(const std::string& host, std::uint16_t port) {
if (port == 80) return host;
std::string DefaultAuthority(const std::string& host, std::uint16_t port, bool secure) {
if (port == (secure ? 443 : 80)) return host;
return host + ":" + std::to_string(port);
}
}
@ -53,13 +29,34 @@ namespace {
struct ClientHTTP1::Impl {
std::string host;
std::uint16_t port;
// Null for plaintext; shared by every connection this client dials, so the
// trust store is parsed once rather than per redial.
std::shared_ptr<TLSContext> tls;
std::unique_ptr<ClientTCP> tcp;
// Sits on top of `tcp` and must therefore be destroyed before it.
std::unique_ptr<ByteStream> stream;
std::string protocol;
void Connect() {
void Connect(std::chrono::milliseconds handshakeTimeout) {
tcp = std::make_unique<ClientTCP>(host, port);
try {
if (tls) {
stream = TLSStream::Connect(tcp->socketid, tls, host, handshakeTimeout);
} else {
stream = std::make_unique<PlainStream>(tcp->socketid);
}
} catch (...) {
// A half-built connection must not be left pooled: the next Send()
// would treat it as reusable and read from a socket with no
// session on it.
tcp.reset();
throw;
}
protocol = std::string(stream->Protocol());
}
void Close() {
stream.reset();
tcp.reset();
}
@ -69,14 +66,19 @@ struct ClientHTTP1::Impl {
HTTPResponse Exchange(const std::string& wire, std::string_view method,
const HTTP1::MessageLimits& limits,
std::chrono::milliseconds timeout, bool& received) {
tcp->Send(wire.data(), static_cast<std::uint32_t>(wire.size()));
stream->Write(wire.data(), wire.size(), timeout);
HTTP1::MessageParser parser(HTTP1::MessageKind::Response, limits);
parser.SetRequestMethod(method);
std::vector<char> chunk(16 * 1024);
while (!parser.Complete()) {
const std::size_t read = ReadSome(tcp->socketid, chunk.data(), chunk.size(), timeout);
if (read == 0) {
std::size_t read = 0;
const StreamStatus status = stream->ReadSome(chunk.data(), chunk.size(),
timeout, read);
if (status == StreamStatus::TimedOut) {
throw std::runtime_error("timed out waiting for the response");
}
if (status == StreamStatus::Closed) {
// Peer closed. Completes a response framed by close;
// anything else throws out of Finish().
parser.Finish();
@ -100,6 +102,17 @@ ClientHTTP1::ClientHTTP1(const char* host, std::uint16_t port)
ClientHTTP1::ClientHTTP1(std::string host, std::uint16_t port)
: ClientHTTP1(host.c_str(), port) {}
ClientHTTP1::ClientHTTP1(const char* host, std::uint16_t port, TLSClientCredentials credentials)
: host(host), port(port), impl(std::make_unique<Impl>(std::string(host), port)) {
// Built here rather than on first Send() so bad credentials — an
// unreadable certificate, a trust anchor that is not a certificate —
// surface at construction, where the caller is still looking.
impl->tls = TLSContext::Client(credentials);
}
ClientHTTP1::ClientHTTP1(std::string host, std::uint16_t port, TLSClientCredentials credentials)
: ClientHTTP1(host.c_str(), port, std::move(credentials)) {}
ClientHTTP1::ClientHTTP1(ClientHTTP1&&) noexcept = default;
ClientHTTP1::~ClientHTTP1() = default;
@ -107,13 +120,23 @@ bool ClientHTTP1::Connected() const noexcept {
return impl && impl->tcp != nullptr;
}
bool ClientHTTP1::Secure() const noexcept {
return impl && impl->tls != nullptr;
}
std::string_view ClientHTTP1::Protocol() const noexcept {
return impl ? std::string_view(impl->protocol) : std::string_view();
}
void ClientHTTP1::Disconnect() {
if (impl) impl->Close();
}
HTTPResponse ClientHTTP1::Send(const HTTPRequest& request) {
HTTPRequest prepared = request;
if (prepared.authority.empty()) prepared.authority = DefaultAuthority(host, port);
if (prepared.authority.empty()) {
prepared.authority = DefaultAuthority(host, port, Secure());
}
const std::string wire = HTTP1::SerializeRequest(prepared);
const std::string method = prepared.method.empty() ? std::string("GET") : prepared.method;
@ -123,7 +146,7 @@ HTTPResponse ClientHTTP1::Send(const HTTPRequest& request) {
// read fails. Nothing is replayed once a response byte has arrived.
for (int attempt = 0;; ++attempt) {
const bool reused = impl->tcp != nullptr;
if (!reused) impl->Connect();
if (!reused) impl->Connect(handshakeTimeout);
bool received = false;
try {

View file

@ -2,7 +2,6 @@
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
module;
#include <poll.h>
#include <sys/socket.h>
#include <cerrno>
@ -12,39 +11,12 @@ import :ListenerTCP;
import :ClientTCP;
import :HTTP;
import :HTTP1;
import :Stream;
import :TLS;
import std;
using namespace Crafter;
namespace {
enum class ReadStatus { Data, Closed, TimedOut };
// Wait for readable data with a deadline, so an idle or stalled peer
// releases the connection thread instead of holding it forever.
ReadStatus ReadSome(int socketid, char* buffer, std::size_t size,
std::chrono::milliseconds timeout, std::size_t& read) {
read = 0;
for (;;) {
pollfd pfd{ .fd = socketid, .events = POLLIN, .revents = 0 };
const int ready = poll(&pfd, 1, static_cast<int>(timeout.count()));
if (ready < 0) {
if (errno == EINTR) continue;
return ReadStatus::Closed;
}
if (ready == 0) return ReadStatus::TimedOut;
const auto got = recv(socketid, buffer, size, 0);
if (got < 0) {
if (errno == EINTR) continue;
return ReadStatus::Closed;
}
if (got == 0) return ReadStatus::Closed;
read = static_cast<std::size_t>(got);
return ReadStatus::Data;
}
}
}
// One accepted connection: the socket, the thread serving it, and a flag
// the accept loop uses to join finished threads without blocking.
struct HTTP1Connection {
@ -56,6 +28,9 @@ struct HTTP1Connection {
struct ListenerHTTP1::Impl {
ListenerHTTP1* owner = nullptr;
std::unique_ptr<ListenerTCP> listener;
// Null on a plaintext listener. One context for every connection: the
// certificate and trust store are parsed once, at construction.
std::shared_ptr<TLSContext> tls;
std::mutex mutex;
// Signalled when a connection thread finishes, so Stop() can wait for
// the last one instead of polling.
@ -63,6 +38,7 @@ struct ListenerHTTP1::Impl {
std::vector<std::unique_ptr<HTTP1Connection>> connections;
std::atomic<bool> running{true};
std::atomic<std::uint64_t> accepted{0};
std::atomic<std::uint64_t> handshakeFailures{0};
// Join threads whose connection has ended. Called from the accept loop
// with `mutex` held, so a connection is never reaped mid-registration.
@ -88,7 +64,13 @@ struct ListenerHTTP1::Impl {
this->accepted.fetch_add(1);
connection->thread = std::thread([this, pointer] {
try {
Serve(*pointer->client);
// The TLS handshake happens here, on the connection's own
// thread, so a peer that stalls halfway through it costs one
// thread rather than the whole accept loop. The stream is
// scoped so it — and its close_notify — go out before the
// socket underneath is released below.
std::unique_ptr<ByteStream> stream = Wrap(*pointer->client);
if (stream) Serve(*stream);
} catch (...) {
// A connection dying must never take the server with it.
}
@ -112,8 +94,22 @@ struct ListenerHTTP1::Impl {
// would abort the process.
}
void Send(ClientTCP& client, const std::string& wire) {
client.Send(wire.data(), static_cast<std::uint32_t>(wire.size()));
// Put the transport on top of the accepted socket. A TLS handshake that
// fails is a normal event on a public port — a scanner, a client with no
// protocol in common, an untrusted client certificate — so it is counted
// and the connection dropped rather than logged or thrown.
std::unique_ptr<ByteStream> Wrap(ClientTCP& client) {
if (!tls) return std::make_unique<PlainStream>(client.socketid);
try {
return TLSStream::Accept(client.socketid, tls, owner->handshakeTimeout);
} catch (...) {
handshakeFailures.fetch_add(1);
return nullptr;
}
}
void Send(ByteStream& stream, const std::string& wire) {
stream.Write(wire.data(), wire.size(), owner->requestTimeout);
}
HTTPResponse Dispatch(const HTTPRequest& request) {
@ -138,8 +134,12 @@ struct ListenerHTTP1::Impl {
// Serve one connection until the peer goes away, asks to close, stalls,
// or sends something we refuse to parse.
void Serve(ClientTCP& client) {
void Serve(ByteStream& stream) {
HTTP1::MessageParser parser(HTTP1::MessageKind::Request, owner->limits);
// Origin-form targets carry no scheme, so the transport supplies it —
// handlers shared with ListenerHTTP see the same "https" they would
// over HTTP/3.
if (stream.Secure()) parser.SetDefaultScheme("https");
std::vector<char> chunk(16 * 1024);
bool keepAlive = true;
@ -151,21 +151,21 @@ struct ListenerHTTP1::Impl {
const bool idle = parser.AtMessageBoundary();
const auto timeout = idle ? owner->keepAliveTimeout : owner->requestTimeout;
std::size_t read = 0;
const ReadStatus status = ReadSome(client.socketid, chunk.data(), chunk.size(),
timeout, read);
if (status == ReadStatus::TimedOut) {
const StreamStatus status = stream.ReadSome(chunk.data(), chunk.size(),
timeout, read);
if (status == StreamStatus::TimedOut) {
// An idle keep-alive connection is simply dropped;
// a half-sent request earns a 408 first.
if (!idle) {
try {
Send(client, HTTP1::SerializeResponse(
Send(stream, HTTP1::SerializeResponse(
CreateResponseHTTP("408", "Request Timeout"),
{ .keepAlive = false }));
} catch (...) {}
}
return;
}
if (status == ReadStatus::Closed) {
if (status == StreamStatus::Closed) {
if (parser.AtMessageBoundary()) return; // clean end of connection
parser.Finish(); // throws if truncated
closed = true;
@ -174,13 +174,13 @@ struct ListenerHTTP1::Impl {
parser.Feed(chunk.data(), read);
// The peer is holding its body back until we say go.
if (parser.ExpectsContinue()) {
Send(client, HTTP1::SerializeContinue());
Send(stream, HTTP1::SerializeContinue());
parser.ContinueSent();
}
}
} catch (const HTTP1::HTTP1ProtocolError& error) {
try {
Send(client, HTTP1::SerializeResponse(
Send(stream, HTTP1::SerializeResponse(
CreateResponseHTTP("400", std::string(error.what())),
{ .keepAlive = false }));
} catch (...) {}
@ -210,7 +210,7 @@ struct ListenerHTTP1::Impl {
}
try {
Send(client, HTTP1::SerializeResponse(response, {
Send(stream, HTTP1::SerializeResponse(response, {
.keepAlive = keepAlive,
// HTTP/1.0 peers only reuse a connection they were told
// stays open.
@ -235,7 +235,9 @@ struct ListenerHTTP1::Impl {
ListenerHTTP1::ListenerHTTP1(std::uint16_t port,
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes)
: ListenerHTTP1(port, std::move(routes), {})
// Spelled out rather than `{}`: with a TLS overload also taking a third
// argument, a braced empty initialiser no longer names one constructor.
: ListenerHTTP1(port, std::move(routes), std::function<HTTPResponse(const HTTPRequest&)>{})
{}
ListenerHTTP1::ListenerHTTP1(std::uint16_t port,
@ -252,11 +254,31 @@ ListenerHTTP1::ListenerHTTP1(std::uint16_t port,
});
}
ListenerHTTP1::ListenerHTTP1(std::uint16_t port,
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes,
TLSServerCredentials credentials)
: ListenerHTTP1(port, std::move(routes),
std::function<HTTPResponse(const HTTPRequest&)>{}, std::move(credentials))
{}
ListenerHTTP1::ListenerHTTP1(std::uint16_t port,
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes,
std::function<HTTPResponse(const HTTPRequest&)> fallback,
TLSServerCredentials credentials)
: ListenerHTTP1(port, std::move(routes), std::move(fallback))
{
// After the plaintext constructor: the socket is already bound and
// listening, but nothing has been accepted (Listen() has not run), so
// there is no window in which a connection could be served unencrypted.
impl->tls = TLSContext::Server(credentials);
}
ListenerHTTP1::ListenerHTTP1(ListenerHTTP1&& other) noexcept
: routes(std::move(other.routes))
, fallback(std::move(other.fallback))
, keepAliveTimeout(other.keepAliveTimeout)
, requestTimeout(other.requestTimeout)
, handshakeTimeout(other.handshakeTimeout)
, limits(other.limits)
, impl(std::move(other.impl))
{
@ -266,7 +288,15 @@ ListenerHTTP1::ListenerHTTP1(ListenerHTTP1&& other) noexcept
}
ListenerHTTP1::~ListenerHTTP1() {
if (impl) Stop();
// Stop() joins threads and touches sockets, so it can throw. Letting that
// out of a destructor ends the process — and the case where it matters is
// exactly the unhappy one: a second listener failing to bind unwinds past
// a live first listener, so a throw here replaces a reportable error with
// a terminate.
try {
if (impl) Stop();
} catch (...) {
}
}
void ListenerHTTP1::Listen() {
@ -313,6 +343,14 @@ std::uint64_t ListenerHTTP1::AcceptedCount() const {
return impl ? impl->accepted.load() : 0;
}
std::uint64_t ListenerHTTP1::HandshakeFailureCount() const {
return impl ? impl->handshakeFailures.load() : 0;
}
bool ListenerHTTP1::Secure() const noexcept {
return impl && impl->tls != nullptr;
}
ListenerAsyncHTTP1::ListenerAsyncHTTP1(std::uint16_t port,
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes)
: listener(port, std::move(routes))
@ -326,8 +364,26 @@ ListenerAsyncHTTP1::ListenerAsyncHTTP1(std::uint16_t port,
, thread(&ListenerHTTP1::Listen, &listener)
{}
ListenerAsyncHTTP1::ListenerAsyncHTTP1(std::uint16_t port,
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes,
TLSServerCredentials credentials)
: listener(port, std::move(routes), std::move(credentials))
, thread(&ListenerHTTP1::Listen, &listener)
{}
ListenerAsyncHTTP1::ListenerAsyncHTTP1(std::uint16_t port,
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes,
std::function<HTTPResponse(const HTTPRequest&)> fallback,
TLSServerCredentials credentials)
: listener(port, std::move(routes), std::move(fallback), std::move(credentials))
, thread(&ListenerHTTP1::Listen, &listener)
{}
ListenerAsyncHTTP1::~ListenerAsyncHTTP1() {
Stop();
try {
Stop();
} catch (...) {
}
}
void ListenerAsyncHTTP1::Stop() {

View file

@ -0,0 +1,99 @@
//SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
module;
#include <poll.h>
#include <fcntl.h>
#include <sys/socket.h>
#include <cerrno>
module Crafter.Network:Stream_impl;
import :Stream;
import std;
using namespace Crafter;
void Crafter::SetNonBlocking(int descriptor) {
const int flags = fcntl(descriptor, F_GETFL, 0);
if (flags == -1 || fcntl(descriptor, F_SETFL, flags | O_NONBLOCK) == -1) {
throw std::runtime_error(std::string("could not make the socket non-blocking: ")
+ std::strerror(errno));
}
}
bool Crafter::PollDescriptor(int descriptor, short events,
std::chrono::steady_clock::time_point deadline) {
for (;;) {
const auto left = std::chrono::duration_cast<std::chrono::milliseconds>(
deadline - std::chrono::steady_clock::now());
// A deadline already in the past still gets one non-blocking look, so
// a zero timeout means "is it ready right now" rather than "give up".
const int wait = left.count() > 0 ? static_cast<int>(left.count()) : 0;
pollfd descriptors{ .fd = descriptor, .events = events, .revents = 0 };
const int ready = poll(&descriptors, 1, wait);
if (ready < 0) {
if (errno == EINTR) continue;
throw std::runtime_error(std::string("poll failed: ") + std::strerror(errno));
}
if (ready == 0) return false;
return true;
}
}
PlainStream::PlainStream(int descriptor) : descriptor(descriptor) {
SetNonBlocking(descriptor);
}
StreamStatus PlainStream::ReadSome(char* buffer, std::size_t size,
std::chrono::milliseconds timeout,
std::size_t& read) {
read = 0;
const auto deadline = std::chrono::steady_clock::now() + timeout;
for (;;) {
const auto got = recv(descriptor, buffer, size, 0);
if (got > 0) {
read = static_cast<std::size_t>(got);
return StreamStatus::Data;
}
if (got == 0) return StreamStatus::Closed;
if (errno == EINTR) continue;
if (errno == EAGAIN || errno == EWOULDBLOCK) {
if (!PollDescriptor(descriptor, POLLIN, deadline)) return StreamStatus::TimedOut;
continue;
}
// A reset is how a peer that stopped caring shows up; it is an end of
// connection rather than something worth a diagnostic.
if (errno == ECONNRESET) return StreamStatus::Closed;
throw std::runtime_error(std::string("recv failed: ") + std::strerror(errno));
}
}
void PlainStream::Write(const void* buffer, std::size_t size,
std::chrono::milliseconds timeout) {
const auto deadline = std::chrono::steady_clock::now() + timeout;
const char* data = reinterpret_cast<const char*>(buffer);
std::size_t sent = 0;
while (sent < size) {
// MSG_NOSIGNAL: a peer that closed early must surface as EPIPE here,
// not as a SIGPIPE that takes the process down.
const auto wrote = send(descriptor, data + sent, size - sent, MSG_NOSIGNAL);
if (wrote > 0) {
sent += static_cast<std::size_t>(wrote);
continue;
}
if (wrote == 0) throw std::runtime_error("the peer closed the connection");
if (errno == EINTR) continue;
if (errno == EAGAIN || errno == EWOULDBLOCK) {
if (!PollDescriptor(descriptor, POLLOUT, deadline)) {
throw std::runtime_error("timed out writing to the peer");
}
continue;
}
throw std::runtime_error(std::string("send failed: ") + std::strerror(errno));
}
}
void PlainStream::Shutdown() noexcept {
shutdown(descriptor, SHUT_WR);
}

View file

@ -0,0 +1,721 @@
//SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
module;
#include <poll.h>
#include <arpa/inet.h>
#include <signal.h>
#include <pthread.h>
#include <cerrno>
#include <climits>
#include <openssl/bio.h>
#include <openssl/err.h>
#include <openssl/evp.h>
#include <openssl/pem.h>
#include <openssl/ssl.h>
#include <openssl/sslerr.h>
#include <openssl/x509v3.h>
module Crafter.Network:TLS_impl;
import :TLS;
import :Stream;
import std;
using namespace Crafter;
namespace {
// ── SIGPIPE ──────────────────────────────────────────────────────────
// OpenSSL's socket BIO writes with write(2), not send(2), so it cannot
// pass MSG_NOSIGNAL the way PlainStream does. Writing to a peer that has
// gone therefore raises SIGPIPE and — with the default disposition — kills
// the process. That is not an edge case: it happens on every teardown
// where the far side closed first, because SSL_shutdown still tries to put
// a close_notify on the wire.
//
// A library must not install a process-wide SIG_IGN on its caller's
// behalf; that would silently change how the caller's own writes behave.
// SIGPIPE from write(2) is delivered to the thread that wrote, so block it
// for this thread across the call instead, and drain any instance that
// went pending while it was blocked so it cannot fire on unblock.
class SigPipeGuard {
public:
SigPipeGuard() {
sigset_t pipeOnly;
sigemptyset(&pipeOnly);
sigaddset(&pipeOnly, SIGPIPE);
sigset_t previous;
// If the caller already blocks SIGPIPE, leave everything alone —
// any pending instance may be theirs to consume, not ours.
blocked = pthread_sigmask(SIG_BLOCK, &pipeOnly, &previous) == 0
&& sigismember(&previous, SIGPIPE) == 0;
restore = previous;
}
~SigPipeGuard() {
if (!blocked) return;
sigset_t pipeOnly;
sigemptyset(&pipeOnly);
sigaddset(&pipeOnly, SIGPIPE);
const timespec immediately{ .tv_sec = 0, .tv_nsec = 0 };
while (sigtimedwait(&pipeOnly, nullptr, &immediately) >= 0) {}
pthread_sigmask(SIG_SETMASK, &restore, nullptr);
}
SigPipeGuard(const SigPipeGuard&) = delete;
private:
sigset_t restore{};
bool blocked = false;
};
// ── OpenSSL plumbing ─────────────────────────────────────────────────
template <typename T, void (*Release)(T*)>
struct Releaser {
void operator()(T* pointer) const noexcept { if (pointer) Release(pointer); }
};
template <typename T, void (*Release)(T*)>
using Owned = std::unique_ptr<T, Releaser<T, Release>>;
using OwnedBio = Owned<BIO, BIO_free_all>;
using OwnedKey = Owned<EVP_PKEY, EVP_PKEY_free>;
using OwnedCertificate = Owned<X509, X509_free>;
// Drain OpenSSL's per-thread error queue into a readable message. Without
// this every TLS failure reads as "handshake failed" with no hint as to
// whether it was the certificate, the version, or the peer hanging up.
std::string Describe(std::string_view what) {
std::string message(what);
bool first = true;
while (const unsigned long code = ERR_get_error()) {
char buffer[256];
ERR_error_string_n(code, buffer, sizeof(buffer));
message += first ? ": " : "; ";
message += buffer;
first = false;
}
return message;
}
// True once OpenSSL has told us the peer vanished without a close_notify.
// For HTTP/1.1 that is an end of connection like any other — the message
// parser is the thing that decides whether it arrived too early.
bool UnexpectedEof() {
const unsigned long code = ERR_peek_error();
return ERR_GET_LIB(code) == ERR_LIB_SSL
&& ERR_GET_REASON(code) == SSL_R_UNEXPECTED_EOF_WHILE_READING;
}
std::string BioToString(BIO* bio) {
char* data = nullptr;
const long length = BIO_get_mem_data(bio, &data);
if (length <= 0 || data == nullptr) return {};
return std::string(data, static_cast<std::size_t>(length));
}
bool IsIpLiteral(const std::string& name) {
in_addr v4{};
in6_addr v6{};
return inet_pton(AF_INET, name.c_str(), &v4) == 1
|| inet_pton(AF_INET6, name.c_str(), &v6) == 1;
}
// ── ALPN ─────────────────────────────────────────────────────────────
// Wire form is a sequence of length-prefixed protocol names.
std::vector<unsigned char> EncodeAlpn(const std::vector<std::string>& protocols) {
std::vector<unsigned char> wire;
for (const std::string& protocol : protocols) {
if (protocol.empty() || protocol.size() > 255) {
throw TLSException("ALPN protocol names must be 1..255 bytes: '" + protocol + "'");
}
wire.push_back(static_cast<unsigned char>(protocol.size()));
wire.insert(wire.end(), protocol.begin(), protocol.end());
}
return wire;
}
// Server-side selection, in *our* preference order rather than the
// client's: the server is the side that knows what it can actually parse.
// No overlap is a fatal no_application_protocol alert (RFC 7301 §3.2) —
// letting the connection through would mean answering HTTP/2 with an
// HTTP/1.1 response and confusing both ends.
int SelectAlpn(SSL*, const unsigned char** out, unsigned char* outLength,
const unsigned char* in, unsigned int inLength, void* argument) {
const auto& preferred = *static_cast<const std::vector<std::string>*>(argument);
for (const std::string& candidate : preferred) {
for (unsigned int offset = 0; offset < inLength;) {
const unsigned int length = in[offset];
if (offset + 1 + length > inLength) break; // malformed list
if (length == candidate.size()
&& std::memcmp(in + offset + 1, candidate.data(), length) == 0) {
*out = in + offset + 1;
*outLength = static_cast<unsigned char>(length);
return SSL_TLSEXT_ERR_OK;
}
offset += 1 + length;
}
}
return SSL_TLSEXT_ERR_ALERT_FATAL;
}
// ── Self-signed certificate ──────────────────────────────────────────
void AddExtension(X509* certificate, X509V3_CTX* context, int nid, const char* value) {
X509_EXTENSION* extension = X509V3_EXT_conf_nid(nullptr, context, nid, value);
if (extension == nullptr) {
throw TLSException(Describe("could not build certificate extension"));
}
const int added = X509_add_ext(certificate, extension, -1);
X509_EXTENSION_free(extension);
if (added != 1) throw TLSException(Describe("could not add certificate extension"));
}
TLSCertificatePem MakeSelfSignedCertificate() {
OwnedKey key(EVP_EC_gen("P-256"));
if (!key) throw TLSException(Describe("could not generate a P-256 key"));
OwnedCertificate certificate(X509_new());
if (!certificate) throw TLSException(Describe("could not allocate a certificate"));
// X509_set_version takes the zero-based version, so 2 is v3 — which is
// what the extensions below require.
X509_set_version(certificate.get(), 2);
ASN1_INTEGER_set(X509_get_serialNumber(certificate.get()), 1);
// Backdated an hour so a peer whose clock runs slightly behind ours
// does not reject a certificate we just minted.
X509_gmtime_adj(X509_getm_notBefore(certificate.get()), -3600);
X509_gmtime_adj(X509_getm_notAfter(certificate.get()), 10 * 24 * 60 * 60);
if (X509_set_pubkey(certificate.get(), key.get()) != 1) {
throw TLSException(Describe("could not set the certificate public key"));
}
X509_NAME* subject = X509_get_subject_name(certificate.get());
X509_NAME_add_entry_by_txt(subject, "CN", MBSTRING_ASC,
reinterpret_cast<const unsigned char*>("localhost"), -1, -1, 0);
// Self-signed: issuer is the subject.
X509_set_issuer_name(certificate.get(), subject);
X509V3_CTX extensionContext;
X509V3_set_ctx_nodb(&extensionContext);
X509V3_set_ctx(&extensionContext, certificate.get(), certificate.get(),
nullptr, nullptr, 0);
AddExtension(certificate.get(), &extensionContext, NID_basic_constraints,
"critical,CA:FALSE");
AddExtension(certificate.get(), &extensionContext, NID_key_usage,
"critical,digitalSignature,keyEncipherment");
AddExtension(certificate.get(), &extensionContext, NID_ext_key_usage, "serverAuth");
// The SANs are what a verifying client actually matches on; a bare CN
// has not been accepted by anything for years.
AddExtension(certificate.get(), &extensionContext, NID_subject_alt_name,
"DNS:localhost,IP:127.0.0.1,IP:::1");
if (X509_sign(certificate.get(), key.get(), EVP_sha256()) == 0) {
throw TLSException(Describe("could not sign the certificate"));
}
TLSCertificatePem pem;
{
OwnedBio bio(BIO_new(BIO_s_mem()));
if (!bio || PEM_write_bio_X509(bio.get(), certificate.get()) != 1) {
throw TLSException(Describe("could not encode the certificate as PEM"));
}
pem.certificate = BioToString(bio.get());
}
{
OwnedBio bio(BIO_new(BIO_s_mem()));
if (!bio || PEM_write_bio_PrivateKey(bio.get(), key.get(), nullptr, nullptr, 0,
nullptr, nullptr) != 1) {
throw TLSException(Describe("could not encode the private key as PEM"));
}
pem.privateKey = BioToString(bio.get());
}
return pem;
}
// ── Credential loading ───────────────────────────────────────────────
OwnedBio MemoryBio(const std::string& contents) {
if (contents.size() > static_cast<std::size_t>(INT_MAX)) {
throw TLSException("PEM blob is implausibly large");
}
OwnedBio bio(BIO_new_mem_buf(contents.data(), static_cast<int>(contents.size())));
if (!bio) throw TLSException(Describe("could not wrap the PEM blob"));
return bio;
}
// Leaf first, then any intermediates, exactly as OpenSSL's own
// *_chain_file loader treats a PEM bundle.
void UseCertificateChainPem(SSL_CTX* context, const std::string& pem) {
OwnedBio bio = MemoryBio(pem);
OwnedCertificate leaf(PEM_read_bio_X509(bio.get(), nullptr, nullptr, nullptr));
if (!leaf) throw TLSException(Describe("could not read the certificate PEM"));
if (SSL_CTX_use_certificate(context, leaf.get()) != 1) {
throw TLSException(Describe("could not install the certificate"));
}
SSL_CTX_clear_chain_certs(context);
for (;;) {
OwnedCertificate extra(PEM_read_bio_X509(bio.get(), nullptr, nullptr, nullptr));
if (!extra) break;
// add0 takes ownership on success, so the pointer is released.
if (SSL_CTX_add0_chain_cert(context, extra.get()) != 1) {
throw TLSException(Describe("could not install a chain certificate"));
}
(void)extra.release();
}
// PEM_read_bio_X509 leaves a "no start line" error behind when it runs
// out of certificates; that is the loop's exit condition, not a fault.
ERR_clear_error();
}
void UsePrivateKeyPem(SSL_CTX* context, const std::string& pem) {
OwnedBio bio = MemoryBio(pem);
OwnedKey key(PEM_read_bio_PrivateKey(bio.get(), nullptr, nullptr, nullptr));
if (!key) throw TLSException(Describe("could not read the private key PEM"));
if (SSL_CTX_use_PrivateKey(context, key.get()) != 1) {
throw TLSException(Describe("could not install the private key"));
}
}
// A trust anchor path is either a PEM bundle or a hashed directory of
// them; OpenSSL wants to be told which, so look.
void LoadTrustAnchorPath(SSL_CTX* context, const std::string& path) {
std::error_code error;
const bool directory = std::filesystem::is_directory(path, error);
const int loaded = directory
? SSL_CTX_load_verify_locations(context, nullptr, path.c_str())
: SSL_CTX_load_verify_locations(context, path.c_str(), nullptr);
if (loaded != 1) {
throw TLSException(Describe("could not load trust anchors from '" + path + "'"));
}
}
void LoadTrustAnchorPem(SSL_CTX* context, const std::string& pem) {
X509_STORE* store = SSL_CTX_get_cert_store(context);
OwnedBio bio = MemoryBio(pem);
std::size_t added = 0;
for (;;) {
OwnedCertificate anchor(PEM_read_bio_X509(bio.get(), nullptr, nullptr, nullptr));
if (!anchor) break;
if (X509_STORE_add_cert(store, anchor.get()) != 1) {
throw TLSException(Describe("could not add a trust anchor"));
}
++added;
}
ERR_clear_error();
if (added == 0) throw TLSException("caPem contained no certificate");
}
void ApplyCommonOptions(SSL_CTX* context) {
// TLS 1.2 floor: 1.0/1.1 are deprecated (RFC 8996) and nothing we want
// to talk to needs them.
if (SSL_CTX_set_min_proto_version(context, TLS1_2_VERSION) != 1) {
throw TLSException(Describe("could not require TLS 1.2 or newer"));
}
// Partial writes plus a moving write buffer: our Write() loops over
// its own offset, so it must be allowed to make progress a record at a
// time instead of being forced to re-present a byte-identical buffer.
SSL_CTX_set_mode(context, SSL_MODE_ENABLE_PARTIAL_WRITE
| SSL_MODE_ACCEPT_MOVING_WRITE_BUFFER);
// Renegotiation buys nothing here and is a cheap way for a peer to
// make us do asymmetric crypto on demand.
SSL_CTX_set_options(context, SSL_OP_NO_RENEGOTIATION);
}
}
// ── TLSContext ───────────────────────────────────────────────────────────
struct TLSContext::Impl {
SSL_CTX* context = nullptr;
bool verifyPeer = true;
std::string serverName;
// Server preference list; SelectAlpn holds a pointer to it, so it must
// outlive every SSL made from this context — which it does, being owned
// by the shared_ptr'd TLSContext.
std::vector<std::string> alpn;
~Impl() { if (context) SSL_CTX_free(context); }
};
TLSContext::TLSContext() : impl(std::make_unique<Impl>()) {}
TLSContext::~TLSContext() = default;
std::shared_ptr<TLSContext> TLSContext::Server(const TLSServerCredentials& credentials) {
std::shared_ptr<TLSContext> wrapper(new TLSContext());
Impl& state = *wrapper->impl;
state.context = SSL_CTX_new(TLS_server_method());
if (state.context == nullptr) throw TLSException(Describe("could not create a TLS context"));
ApplyCommonOptions(state.context);
if (!credentials.certPath.empty()) {
if (credentials.keyPath.empty()) {
throw TLSException("certPath was given without a matching keyPath");
}
if (SSL_CTX_use_certificate_chain_file(state.context, credentials.certPath.c_str()) != 1) {
throw TLSException(Describe("could not load the certificate '"
+ credentials.certPath + "'"));
}
if (SSL_CTX_use_PrivateKey_file(state.context, credentials.keyPath.c_str(),
SSL_FILETYPE_PEM) != 1) {
throw TLSException(Describe("could not load the private key '"
+ credentials.keyPath + "'"));
}
} else if (!credentials.certPem.empty()) {
if (credentials.keyPem.empty()) {
throw TLSException("certPem was given without a matching keyPem");
}
UseCertificateChainPem(state.context, credentials.certPem);
UsePrivateKeyPem(state.context, credentials.keyPem);
} else if (credentials.selfSigned) {
const TLSCertificatePem& pem = GetSelfSignedCertificatePem();
UseCertificateChainPem(state.context, pem.certificate);
UsePrivateKeyPem(state.context, pem.privateKey);
} else {
throw TLSException("no server certificate: set certPath/keyPath, certPem/keyPem, "
"or selfSigned for a development certificate");
}
if (SSL_CTX_check_private_key(state.context) != 1) {
throw TLSException(Describe("the private key does not match the certificate"));
}
if (credentials.requireClientCertificate) {
if (!credentials.clientCaPath.empty()) {
LoadTrustAnchorPath(state.context, credentials.clientCaPath);
// Advertise the acceptable issuers so the client can choose a
// certificate instead of guessing.
std::error_code error;
if (!std::filesystem::is_directory(credentials.clientCaPath, error)) {
if (STACK_OF(X509_NAME)* names =
SSL_load_client_CA_file(credentials.clientCaPath.c_str())) {
SSL_CTX_set_client_CA_list(state.context, names);
}
}
} else if (SSL_CTX_set_default_verify_paths(state.context) != 1) {
throw TLSException(Describe("could not load the system trust store"));
}
SSL_CTX_set_verify(state.context,
SSL_VERIFY_PEER | SSL_VERIFY_FAIL_IF_NO_PEER_CERT, nullptr);
}
state.alpn = credentials.alpnProtocols;
if (!state.alpn.empty()) {
// Validate the names now rather than inside the handshake callback,
// where there is nowhere useful to report a bad configuration.
(void)EncodeAlpn(state.alpn);
SSL_CTX_set_alpn_select_cb(state.context, SelectAlpn, &state.alpn);
}
return wrapper;
}
std::shared_ptr<TLSContext> TLSContext::Client(const TLSClientCredentials& credentials) {
std::shared_ptr<TLSContext> wrapper(new TLSContext());
Impl& state = *wrapper->impl;
state.context = SSL_CTX_new(TLS_client_method());
if (state.context == nullptr) throw TLSException(Describe("could not create a TLS context"));
ApplyCommonOptions(state.context);
state.verifyPeer = !credentials.insecureNoServerValidation;
state.serverName = credentials.serverName;
if (state.verifyPeer) {
if (SSL_CTX_set_default_verify_paths(state.context) != 1) {
throw TLSException(Describe("could not load the system trust store"));
}
if (!credentials.caPath.empty()) LoadTrustAnchorPath(state.context, credentials.caPath);
if (!credentials.caPem.empty()) LoadTrustAnchorPem(state.context, credentials.caPem);
SSL_CTX_set_verify(state.context, SSL_VERIFY_PEER, nullptr);
} else {
// The handshake still completes and SSL_get_verify_result still
// reports what it found; nothing acts on it.
SSL_CTX_set_verify(state.context, SSL_VERIFY_NONE, nullptr);
}
if (!credentials.certPath.empty()) {
if (credentials.keyPath.empty()) {
throw TLSException("certPath was given without a matching keyPath");
}
if (SSL_CTX_use_certificate_chain_file(state.context, credentials.certPath.c_str()) != 1) {
throw TLSException(Describe("could not load the client certificate '"
+ credentials.certPath + "'"));
}
if (SSL_CTX_use_PrivateKey_file(state.context, credentials.keyPath.c_str(),
SSL_FILETYPE_PEM) != 1) {
throw TLSException(Describe("could not load the client private key '"
+ credentials.keyPath + "'"));
}
if (SSL_CTX_check_private_key(state.context) != 1) {
throw TLSException(Describe("the client key does not match the client certificate"));
}
}
if (!credentials.alpnProtocols.empty()) {
const std::vector<unsigned char> wire = EncodeAlpn(credentials.alpnProtocols);
if (SSL_CTX_set_alpn_protos(state.context, wire.data(),
static_cast<unsigned int>(wire.size())) != 0) {
throw TLSException(Describe("could not set the ALPN protocol list"));
}
}
return wrapper;
}
// ── TLSStream ────────────────────────────────────────────────────────────
struct TLSStream::Impl {
std::shared_ptr<TLSContext> context;
SSL* ssl = nullptr;
int descriptor = -1;
std::string protocol;
bool shutdownSent = false;
~Impl() { if (ssl) SSL_free(ssl); }
// Drive SSL_connect/SSL_accept to completion, polling for whichever
// direction OpenSSL is waiting on. The deadline covers the whole
// handshake, not each poll, so a peer that dribbles records cannot
// stretch it indefinitely.
void Handshake(bool client, std::chrono::milliseconds timeout) {
const SigPipeGuard noSigPipe;
const auto deadline = std::chrono::steady_clock::now() + timeout;
for (;;) {
ERR_clear_error();
const int result = client ? SSL_connect(ssl) : SSL_accept(ssl);
if (result == 1) break;
const int error = SSL_get_error(ssl, result);
if (error == SSL_ERROR_WANT_READ || error == SSL_ERROR_WANT_WRITE) {
const short events = error == SSL_ERROR_WANT_READ ? POLLIN : POLLOUT;
if (!PollDescriptor(descriptor, events, deadline)) {
throw TLSException("TLS handshake timed out");
}
continue;
}
// Certificate problems are the failure people actually hit, and
// OpenSSL's generic queue message for them ("certificate verify
// failed") does not say which check tripped.
const long verified = SSL_get_verify_result(ssl);
if (verified != X509_V_OK) {
throw TLSException(std::string("TLS certificate rejected: ")
+ X509_verify_cert_error_string(verified));
}
if (error == SSL_ERROR_ZERO_RETURN
|| (error == SSL_ERROR_SYSCALL && result == 0)
|| (error == SSL_ERROR_SSL && UnexpectedEof())) {
throw TLSException("the peer closed the connection during the TLS handshake");
}
if (error == SSL_ERROR_SYSCALL) {
throw TLSException(std::string("TLS handshake failed: ") + std::strerror(errno));
}
throw TLSException(Describe("TLS handshake failed"));
}
const unsigned char* selected = nullptr;
unsigned int length = 0;
SSL_get0_alpn_selected(ssl, &selected, &length);
if (selected != nullptr && length != 0) {
protocol.assign(reinterpret_cast<const char*>(selected), length);
}
}
};
TLSStream::TLSStream() : impl(std::make_unique<Impl>()) {}
TLSStream::~TLSStream() {
Shutdown();
}
std::unique_ptr<TLSStream> TLSStream::Connect(int descriptor,
std::shared_ptr<TLSContext> context,
const std::string& hostName,
std::chrono::milliseconds timeout) {
if (!context) throw TLSException("no TLS context");
SetNonBlocking(descriptor);
std::unique_ptr<TLSStream> stream(new TLSStream());
TLSContext::Impl& configuration = *context->impl;
stream->impl->context = std::move(context);
stream->impl->descriptor = descriptor;
SSL* ssl = SSL_new(configuration.context);
if (ssl == nullptr) throw TLSException(Describe("could not create a TLS session"));
stream->impl->ssl = ssl;
if (SSL_set_fd(ssl, descriptor) != 1) {
throw TLSException(Describe("could not attach the socket to the TLS session"));
}
const std::string& name = configuration.serverName.empty()
? hostName : configuration.serverName;
const bool literal = !name.empty() && IsIpLiteral(name);
// SNI carries host names only — an IP literal there is a protocol
// violation and some servers reject the handshake outright (RFC 6066 §3).
if (!name.empty() && !literal && SSL_set_tlsext_host_name(ssl, name.c_str()) != 1) {
throw TLSException(Describe("could not set the SNI host name"));
}
if (configuration.verifyPeer) {
if (name.empty()) {
throw TLSException("certificate verification needs a name to check against; "
"set serverName or use insecureNoServerValidation");
}
SSL_set_hostflags(ssl, X509_CHECK_FLAG_NO_PARTIAL_WILDCARDS);
const int named = literal
? X509_VERIFY_PARAM_set1_ip_asc(SSL_get0_param(ssl), name.c_str())
: SSL_set1_host(ssl, name.c_str());
if (named != 1) {
throw TLSException("could not use '" + name + "' as the name to verify");
}
}
SSL_set_connect_state(ssl);
stream->impl->Handshake(true, timeout);
return stream;
}
std::unique_ptr<TLSStream> TLSStream::Accept(int descriptor,
std::shared_ptr<TLSContext> context,
std::chrono::milliseconds timeout) {
if (!context) throw TLSException("no TLS context");
SetNonBlocking(descriptor);
std::unique_ptr<TLSStream> stream(new TLSStream());
TLSContext::Impl& configuration = *context->impl;
stream->impl->context = std::move(context);
stream->impl->descriptor = descriptor;
SSL* ssl = SSL_new(configuration.context);
if (ssl == nullptr) throw TLSException(Describe("could not create a TLS session"));
stream->impl->ssl = ssl;
if (SSL_set_fd(ssl, descriptor) != 1) {
throw TLSException(Describe("could not attach the socket to the TLS session"));
}
SSL_set_accept_state(ssl);
stream->impl->Handshake(false, timeout);
return stream;
}
StreamStatus TLSStream::ReadSome(char* buffer, std::size_t size,
std::chrono::milliseconds timeout,
std::size_t& read) {
read = 0;
if (size == 0) return StreamStatus::Data;
// A read can put bytes on the wire too — a TLS 1.3 key update, or an alert
// in response to something we refuse.
const SigPipeGuard noSigPipe;
const auto deadline = std::chrono::steady_clock::now() + timeout;
const int wanted = static_cast<int>(std::min<std::size_t>(size, INT_MAX));
for (;;) {
ERR_clear_error();
const int got = SSL_read(impl->ssl, buffer, wanted);
if (got > 0) {
read = static_cast<std::size_t>(got);
return StreamStatus::Data;
}
const int error = SSL_get_error(impl->ssl, got);
switch (error) {
case SSL_ERROR_WANT_READ:
if (!PollDescriptor(impl->descriptor, POLLIN, deadline)) {
return StreamStatus::TimedOut;
}
continue;
// A read can need the socket writable: TLS 1.3 key updates and
// (where allowed) renegotiation both send records mid-read.
case SSL_ERROR_WANT_WRITE:
if (!PollDescriptor(impl->descriptor, POLLOUT, deadline)) {
return StreamStatus::TimedOut;
}
continue;
case SSL_ERROR_ZERO_RETURN:
return StreamStatus::Closed; // close_notify: an orderly end
case SSL_ERROR_SYSCALL:
if (errno == EINTR) continue;
if (got == 0 || errno == 0 || errno == ECONNRESET) return StreamStatus::Closed;
throw TLSException(std::string("TLS read failed: ") + std::strerror(errno));
case SSL_ERROR_SSL:
if (UnexpectedEof()) return StreamStatus::Closed;
throw TLSException(Describe("TLS read failed"));
default:
throw TLSException(Describe("TLS read failed"));
}
}
}
void TLSStream::Write(const void* buffer, std::size_t size,
std::chrono::milliseconds timeout) {
const SigPipeGuard noSigPipe;
const auto deadline = std::chrono::steady_clock::now() + timeout;
const char* data = reinterpret_cast<const char*>(buffer);
std::size_t sent = 0;
while (sent < size) {
const int wanted = static_cast<int>(std::min<std::size_t>(size - sent, INT_MAX));
ERR_clear_error();
const int wrote = SSL_write(impl->ssl, data + sent, wanted);
if (wrote > 0) {
sent += static_cast<std::size_t>(wrote);
continue;
}
const int error = SSL_get_error(impl->ssl, wrote);
switch (error) {
case SSL_ERROR_WANT_READ:
if (!PollDescriptor(impl->descriptor, POLLIN, deadline)) {
throw TLSException("timed out writing to the TLS peer");
}
continue;
case SSL_ERROR_WANT_WRITE:
if (!PollDescriptor(impl->descriptor, POLLOUT, deadline)) {
throw TLSException("timed out writing to the TLS peer");
}
continue;
case SSL_ERROR_ZERO_RETURN:
throw TLSException("the TLS peer closed the connection");
case SSL_ERROR_SYSCALL:
if (errno == EINTR) continue;
throw TLSException(std::string("TLS write failed: ")
+ (errno == 0 ? "the peer closed the connection"
: std::strerror(errno)));
default:
throw TLSException(Describe("TLS write failed"));
}
}
}
void TLSStream::Shutdown() noexcept {
if (!impl || impl->ssl == nullptr || impl->shutdownSent) return;
impl->shutdownSent = true;
// The likeliest SIGPIPE of the lot: by the time a connection is being torn
// down the peer has often gone already.
const SigPipeGuard noSigPipe;
// One attempt only: close_notify goes out, and we deliberately do not
// wait for the peer's. Waiting means blocking a teardown path on a peer
// that may never answer, and every framing decision has already been made
// by the time we get here.
ERR_clear_error();
SSL_shutdown(impl->ssl);
ERR_clear_error();
}
int TLSStream::Descriptor() const noexcept {
return impl ? impl->descriptor : -1;
}
std::string_view TLSStream::Protocol() const noexcept {
return impl ? std::string_view(impl->protocol) : std::string_view();
}
std::string TLSStream::Version() const {
if (!impl || impl->ssl == nullptr) return {};
const char* version = SSL_get_version(impl->ssl);
return version == nullptr ? std::string() : std::string(version);
}
std::string TLSStream::PeerCertificateSubject() const {
if (!impl || impl->ssl == nullptr) return {};
OwnedCertificate peer(SSL_get1_peer_certificate(impl->ssl));
if (!peer) return {};
char buffer[512] = {};
X509_NAME_oneline(X509_get_subject_name(peer.get()), buffer, sizeof(buffer));
return std::string(buffer);
}
const TLSCertificatePem& Crafter::GetSelfSignedCertificatePem() {
// Generated once per process so every listener presents the same
// certificate: a client that was handed it as a trust anchor keeps
// working across reconnects.
static std::mutex mutex;
static std::optional<TLSCertificatePem> cached;
std::lock_guard lock(mutex);
if (!cached) cached = MakeSelfSignedCertificate();
return *cached;
}

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;

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;

View file

@ -5,12 +5,13 @@ export module Crafter.Network:ListenerHTTP1;
import std;
import :HTTP;
import :HTTP1;
import :TLS;
#ifndef CRAFTER_NETWORK_BROWSER
namespace Crafter {
// HTTP/1.1 server over plain TCP. Same route map and `fallback` shape as
// ListenerHTTP, so a handler can be registered with both and served over
// either protocol.
// HTTP/1.1 server over TCP, plaintext or TLS. Same route map and
// `fallback` shape as ListenerHTTP, so a handler can be registered with
// both and served over either protocol.
//
// Each accepted connection gets its own thread and is served
// sequentially until the peer closes it, `Connection: close` is seen, or
@ -18,13 +19,16 @@ namespace Crafter {
// across connections but never for two requests on the same one, which
// is what HTTP/1.1 response ordering requires. A dedicated thread rather
// than a ThreadPool task is deliberate: keep-alive connections are idle
// most of their life and would otherwise pin every pool thread.
// most of their life and would otherwise pin every pool thread. The TLS
// handshake runs on that same per-connection thread, so a peer that
// stalls mid-handshake cannot hold up the accept loop either.
//
// Implemented: keep-alive and pipelining, content-length and chunked
// request bodies, `Expect: 100-continue`, HEAD (headers only), automatic
// `Date`, and 400/404/500 error responses. Not implemented: TLS,
// CONNECT tunnels, Upgrade, and chunked *responses* (handlers return a
// complete body, so responses are always content-length framed).
// Implemented: TLS via libssl (pass TLSServerCredentials), keep-alive and
// pipelining, content-length and chunked request bodies, `Expect:
// 100-continue`, HEAD (headers only), automatic `Date`, and 400/404/500
// error responses. Not implemented: CONNECT tunnels, Upgrade, and chunked
// *responses* (handlers return a complete body, so responses are always
// content-length framed).
export class ListenerHTTP1 {
public:
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes;
@ -45,6 +49,9 @@ namespace Crafter {
// against a peer holding a thread forever.
std::chrono::milliseconds keepAliveTimeout{15000};
std::chrono::milliseconds requestTimeout{30000};
// How long a TLS handshake may take, on an https:// listener. A peer
// that connects and then says nothing is dropped after this.
std::chrono::milliseconds handshakeTimeout{15000};
// Limits applied to incoming requests.
HTTP1::MessageLimits limits;
@ -55,6 +62,21 @@ namespace Crafter {
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes,
std::function<HTTPResponse(const HTTPRequest&)> fallback);
// https://. Every accepted connection is wrapped in TLS before a byte
// of HTTP is read; requests reach handlers with `scheme` set to
// "https". Throws TLSException from the constructor if the credentials
// do not yield a usable certificate, so a misconfigured server never
// reaches the point of listening.
ListenerHTTP1(std::uint16_t port,
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes,
TLSServerCredentials credentials);
// TLS plus a fallback handler.
ListenerHTTP1(std::uint16_t port,
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes,
std::function<HTTPResponse(const HTTPRequest&)> fallback,
TLSServerCredentials credentials);
~ListenerHTTP1();
ListenerHTTP1(const ListenerHTTP1&) = delete;
ListenerHTTP1(ListenerHTTP1&&) noexcept;
@ -69,6 +91,13 @@ namespace Crafter {
std::size_t ConnectionCount() const;
// Connections accepted since construction.
std::uint64_t AcceptedCount() const;
// Connections that were accepted but never got as far as HTTP because
// the TLS handshake failed — an untrusted client certificate, a peer
// with no protocol in common, a port scanner. Always 0 on a plaintext
// listener.
std::uint64_t HandshakeFailureCount() const;
// Whether this listener speaks https://.
bool Secure() const noexcept;
private:
struct Impl;
@ -92,6 +121,15 @@ namespace Crafter {
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes,
std::function<HTTPResponse(const HTTPRequest&)> fallback);
// TLS, with and without a fallback handler.
ListenerAsyncHTTP1(std::uint16_t port,
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes,
TLSServerCredentials credentials);
ListenerAsyncHTTP1(std::uint16_t port,
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes,
std::function<HTTPResponse(const HTTPRequest&)> fallback,
TLSServerCredentials credentials);
~ListenerAsyncHTTP1();
void Stop();
};

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

View file

@ -0,0 +1,179 @@
//SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
export module Crafter.Network:TLS;
import std;
import :Stream;
#ifndef CRAFTER_NETWORK_BROWSER
namespace Crafter {
// TLS over a TCP socket, via libssl (OpenSSL 3). This is the transport
// that turns `ClientHTTP1`/`ListenerHTTP1` into `https://` endpoints; it
// is deliberately protocol-agnostic, so anything else that owns a
// connected descriptor can wrap it the same way.
//
// No OpenSSL type appears below: the SSL_CTX and SSL live behind the Impl
// pointers, so importing this partition does not drag <openssl/*.h> into
// the consumer. TLS 1.2 is the floor, the platform's cipher defaults are
// used unchanged, and renegotiation is left to OpenSSL's own policy.
export class TLSException : public std::runtime_error {
public:
using std::runtime_error::runtime_error;
};
// A certificate and its private key, PEM-encoded.
export struct TLSCertificatePem {
std::string certificate;
std::string privateKey;
};
// The certificate the server presents. Exactly one source is used, in
// this order: certPath/keyPath, then certPem/keyPem, then selfSigned.
//
// selfSigned generates an ephemeral in-memory certificate (see
// GetSelfSignedCertificatePem) — for development, tests and LAN use. A
// client talking to it needs either insecureNoServerValidation or the
// certificate itself as a trust anchor.
export struct TLSServerCredentials {
// PEM files on disk. certPath may hold a chain (leaf first).
std::string certPath;
std::string keyPath;
// The same material inline, for callers that hold it in memory
// already (a secret store, a test) and would rather not touch disk.
std::string certPem;
std::string keyPem;
bool selfSigned = false;
// Mutual TLS. With requireClientCertificate set, a peer that presents
// no certificate — or one that does not chain to clientCaPath — is
// rejected during the handshake. clientCaPath is a PEM file or a
// directory of them; when it is empty the system trust store is used.
std::string clientCaPath;
bool requireClientCertificate = false;
// Protocols we are willing to speak, in server preference order. A
// client that offers ALPN and none of these is rejected with
// no_application_protocol (RFC 7301 §3.2) rather than being let
// through to speak something we cannot parse. A client that offers no
// ALPN at all is accepted — plenty of tooling still does not send it.
std::vector<std::string> alpnProtocols = { "http/1.1" };
};
// How the client checks the server, and what it presents itself.
//
// The default verifies the chain against the system trust store *and* the
// hostname, which is the only combination that is actually safe; a chain
// check without a name check accepts any valid certificate for any name.
export struct TLSClientCredentials {
// Skip both checks. Development only — it accepts any certificate,
// including an attacker's.
bool insecureNoServerValidation = false;
// Extra trust anchor: a PEM file or a directory of them. Added to the
// system store rather than replacing it. This is how you talk to a
// self-signed listener without giving up verification — hand the
// client the server's certificate.
std::string caPath;
// The same, inline.
std::string caPem;
// Overrides the name used for SNI and hostname verification. Empty
// means the host being connected to, which is what you want unless
// you are dialling an address that differs from the certificate name
// (a tunnel, a pinned IP).
std::string serverName;
// Client certificate for mutual TLS. Ignored when the server does not
// ask for one.
std::string certPath;
std::string keyPath;
// Protocols to offer, in client preference order. Empty sends no ALPN
// extension at all.
std::vector<std::string> alpnProtocols = { "http/1.1" };
};
// A configured SSL_CTX. Shared by every connection it produces — one per
// listener, one per client — because the expensive parts (parsing the
// certificate, loading the trust store) are per-context, and OpenSSL 3
// lets an SSL_CTX be used concurrently from many threads.
//
// Held by shared_ptr: a TLSStream keeps its context alive, so a listener
// that goes away mid-connection does not pull the configuration out from
// under a session still using it.
export class TLSContext {
public:
static std::shared_ptr<TLSContext> Server(const TLSServerCredentials& credentials);
static std::shared_ptr<TLSContext> Client(const TLSClientCredentials& credentials);
~TLSContext();
TLSContext(const TLSContext&) = delete;
TLSContext& operator=(const TLSContext&) = delete;
private:
TLSContext();
struct Impl;
std::unique_ptr<Impl> impl;
friend class TLSStream;
};
// A TLS session over an already-connected descriptor. Non-owning, like
// PlainStream: the descriptor stays owned by its ClientTCP, and this only
// adds the record layer on top.
//
// Both factories complete the handshake before returning, so a stream you
// hold is a stream you can write to. They throw TLSException on
// certificate rejection, on a protocol mismatch, and on a peer that stops
// answering mid-handshake.
export class TLSStream final : public ByteStream {
public:
// Client side. `hostName` drives SNI and hostname verification unless
// the credentials overrode it with serverName; an IP literal sets no
// SNI (RFC 6066 forbids it) and is checked against the certificate's
// iPAddress SANs instead.
static std::unique_ptr<TLSStream> Connect(int descriptor,
std::shared_ptr<TLSContext> context,
const std::string& hostName,
std::chrono::milliseconds timeout);
// Server side, on a descriptor accept(2) just handed us.
static std::unique_ptr<TLSStream> Accept(int descriptor,
std::shared_ptr<TLSContext> context,
std::chrono::milliseconds timeout);
~TLSStream() override;
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;
std::string_view Protocol() const noexcept override;
bool Secure() const noexcept override { return true; }
// The negotiated protocol version, e.g. "TLSv1.3". For logging.
std::string Version() const;
// One-line subject of the peer's certificate, empty when it presented
// none. With requireClientCertificate a non-empty value is the
// authenticated client identity.
std::string PeerCertificateSubject() const;
private:
TLSStream();
struct Impl;
std::unique_ptr<Impl> impl;
};
// The process-wide ephemeral self-signed certificate used by
// TLSServerCredentials{selfSigned=true}, in PEM form. Generated on first
// call and then cached, so every listener in a process presents the same
// certificate and a client can be handed it as a trust anchor.
//
// ECDSA P-256, CN=localhost, SAN {DNS:localhost, IP:127.0.0.1, IP:::1},
// valid for 10 days. Development and tests only — it is regenerated on
// every process start and no peer has any reason to trust it.
export const TLSCertificatePem& GetSelfSignedCertificatePem();
}
#endif

View file

@ -21,6 +21,12 @@ export import :HTTP3;
// in the browser this job is already done by fetch() behind :ClientHTTP,
// and these partitions use exceptions and POSIX sockets.
export import :HTTP1;
// The byte-stream abstraction the HTTP/1.1 endpoints run over, and the libssl
// TLS transport that turns them into https://. Exported so callers can build
// credentials, and so anything else holding a connected socket can wrap it the
// same way.
export import :Stream;
export import :TLS;
export import :ClientHTTP1;
export import :ListenerHTTP1;
#endif

View file

@ -7,7 +7,7 @@ namespace fs = std::filesystem;
using namespace Crafter;
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> args) {
constexpr std::array<std::string_view, 13> networkInterfaces = {
constexpr std::array<std::string_view, 15> networkInterfaces = {
"interfaces/Crafter.Network",
"interfaces/Crafter.Network-ClientTCP",
"interfaces/Crafter.Network-ListenerTCP",
@ -15,6 +15,8 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
"interfaces/Crafter.Network-ListenerHTTP",
"interfaces/Crafter.Network-HTTP",
"interfaces/Crafter.Network-HTTP1",
"interfaces/Crafter.Network-Stream",
"interfaces/Crafter.Network-TLS",
"interfaces/Crafter.Network-ClientHTTP1",
"interfaces/Crafter.Network-ListenerHTTP1",
"interfaces/Crafter.Network-HTTP3",
@ -72,11 +74,13 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
return cfg;
}
constexpr std::array<std::string_view, 9> networkImplementations = {
constexpr std::array<std::string_view, 11> networkImplementations = {
"implementations/Crafter.Network-ClientTCP",
"implementations/Crafter.Network-ListenerTCP",
"implementations/Crafter.Network-ClientHTTP",
"implementations/Crafter.Network-ListenerHTTP",
"implementations/Crafter.Network-Stream",
"implementations/Crafter.Network-TLS",
"implementations/Crafter.Network-ClientHTTP1",
"implementations/Crafter.Network-ListenerHTTP1",
"implementations/Crafter.Network-ClientQUIC",
@ -108,9 +112,18 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
// linker at the actual output location.
msquic.libDirs = { "bin/Release" };
msquic.libs = { "msquic" };
std::array<fs::path, 13> ifaces;
// libssl/libcrypto — the TLS transport behind :TLS, i.e. https:// on the
// HTTP/1.1 client and listener. A system package rather than a built
// external: OpenSSL 3 is on every platform we target, and building it here
// would mean shipping a second TLS stack alongside the one msquic already
// links (quictls, which keeps its symbols to itself inside libmsquic.so).
cfg.linkFlags.push_back("-lssl");
cfg.linkFlags.push_back("-lcrypto");
std::array<fs::path, 15> ifaces;
std::ranges::copy(networkInterfaces, ifaces.begin());
std::array<fs::path, 9> impls;
std::array<fs::path, 11> impls;
std::ranges::copy(networkImplementations, impls.begin());
cfg.GetInterfacesAndImplementations(ifaces, impls);
@ -121,10 +134,13 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
cfg.AddTest("ShouldEchoWebTransport").Dependencies({ &cfg });
cfg.AddTest("ShouldFallbackUnknownRoutes").Dependencies({ &cfg });
cfg.AddTest("ShouldInteropCurlHTTP1").Dependencies({ &cfg });
cfg.AddTest("ShouldInteropCurlHTTPS1").Dependencies({ &cfg });
cfg.AddTest("ShouldNotDropEarlyStreams").Dependencies({ &cfg });
cfg.AddTest("ShouldParseHTTP1").Dependencies({ &cfg });
cfg.AddTest("ShouldRequireClientCertificateHTTPS1").Dependencies({ &cfg });
cfg.AddTest("ShouldSend").Dependencies({ &cfg });
cfg.AddTest("ShouldSendRecieveHTTP1").Dependencies({ &cfg });
cfg.AddTest("ShouldSendRecieveHTTPS1").Dependencies({ &cfg });
cfg.AddTest("ShouldSendRecieveKeepaliveHTTP1").Dependencies({ &cfg });
cfg.AddTest("ShouldSendRecieveLargeHTTP1").Dependencies({ &cfg });
cfg.AddTest("ShouldSendRecieveHTTP").Dependencies({ &cfg });

View file

@ -0,0 +1,257 @@
//SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// TLS interop against implementations that are not this library. Speaking
// HTTP/1.1 correctly to ourselves proves very little about the record layer —
// an OpenSSL client and an OpenSSL server can agree on a mistake.
//
// * curl drives our TLS listener, verifying our certificate properly with
// --cacert: keep-alive reuse, POST, HEAD, ALPN, and a status line.
// * ClientHTTP1 drives python3's http.server behind ssl.wrap_socket, which
// answers HTTP/1.0 with `Connection: close` — so the response is framed by
// close_notify rather than by content-length.
//
// Both peers are optional: a missing curl or python3 skips its half rather
// than failing, so the suite still runs on a bare machine.
#include <signal.h>
#include <unistd.h>
#include <sys/wait.h>
#include <stdio.h>
import Crafter.Network;
import std;
using namespace Crafter;
namespace {
int failures = 0;
void Check(bool condition, std::string_view what) {
if (!condition) {
std::println("FAIL: {}", what);
++failures;
}
}
bool HaveCommand(std::string_view name) {
const std::string probe = "command -v " + std::string(name) + " >/dev/null 2>&1";
return std::system(probe.c_str()) == 0;
}
std::string Run(const std::string& command) {
std::string output;
FILE* pipe = popen((command + " 2>&1").c_str(), "r");
if (pipe == nullptr) return output;
char buffer[4096];
while (std::size_t read = std::fread(buffer, 1, sizeof(buffer), pipe)) {
output.append(buffer, read);
}
pclose(pipe);
return output;
}
// A child process, killed when this goes out of scope.
class Child {
public:
explicit Child(std::vector<std::string> argv) {
std::vector<char*> raw;
for (auto& argument : argv) raw.push_back(argument.data());
raw.push_back(nullptr);
pid = fork();
if (pid == 0) {
freopen("/dev/null", "w", stdout);
freopen("/dev/null", "w", stderr);
execvp(raw[0], raw.data());
_exit(127);
}
}
~Child() {
if (pid > 0) {
kill(pid, SIGTERM);
int status = 0;
waitpid(pid, &status, 0);
}
}
Child(const Child&) = delete;
bool Started() const { return pid > 0; }
private:
pid_t pid = -1;
};
bool WaitForPort(std::uint16_t port, std::chrono::milliseconds budget) {
const auto deadline = std::chrono::steady_clock::now() + budget;
while (std::chrono::steady_clock::now() < deadline) {
try {
ClientTCP probe("localhost", port);
return true;
} catch (const std::exception&) {
std::this_thread::sleep_for(std::chrono::milliseconds(25));
}
}
return false;
}
// The development certificate on disk, so peer processes (curl, python)
// can be pointed at it.
struct CertificateFiles {
std::filesystem::path directory;
std::filesystem::path certificate;
std::filesystem::path privateKey;
CertificateFiles() {
directory = std::filesystem::temp_directory_path() / "crafter-network-https1-interop";
std::filesystem::create_directories(directory);
certificate = directory / "cert.pem";
privateKey = directory / "key.pem";
const TLSCertificatePem& pem = GetSelfSignedCertificatePem();
std::ofstream(certificate, std::ios::binary) << pem.certificate;
std::ofstream(privateKey, std::ios::binary) << pem.privateKey;
}
~CertificateFiles() {
std::error_code error;
std::filesystem::remove_all(directory, error);
}
CertificateFiles(const CertificateFiles&) = delete;
};
void CurlAgainstListener(const CertificateFiles& files) {
if (!HaveCommand("curl")) {
std::println("skipping the curl half: curl is not installed");
return;
}
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes;
routes["/hello"] = [](const HTTPRequest&) {
return CreateResponseHTTP("200", {{"content-type", "text/plain"}}, "Hello curl!");
};
routes["/echo"] = [](const HTTPRequest& request) {
return CreateResponseHTTP("200", request.method + ":" + request.body);
};
routes["/scheme"] = [](const HTTPRequest& request) {
return CreateResponseHTTP("200", request.scheme);
};
ListenerAsyncHTTP1 listener(8111, std::move(routes),
TLSServerCredentials{ .selfSigned = true });
Check(WaitForPort(8111, std::chrono::seconds(2)), "the HTTPS listener came up");
// --cacert, not --insecure: curl does the full chain and hostname
// check, so this is a real verification of what we present.
const std::string base = "https://localhost:8111";
const std::string curl = "curl -sS --http1.1 --cacert '" + files.certificate.string() + "' ";
Check(Run(curl + base + "/hello") == "Hello curl!", "curl GET over TLS");
Check(Run(curl + base + "/scheme") == "https", "the handler sees scheme=https");
// Two URLs in one invocation: curl reuses the TLS session, which only
// works if our framing let it know the first response ended.
const std::uint64_t before = listener.listener.AcceptedCount();
Check(Run(curl + base + "/hello " + base + "/hello") == "Hello curl!Hello curl!",
"curl got both responses");
Check(listener.listener.AcceptedCount() == before + 1,
"curl reused one TLS connection for both");
Check(Run(curl + "-d 'body text' " + base + "/echo") == "POST:body text",
"curl POST over TLS");
const std::string head = Run(curl + "-I " + base + "/hello");
Check(head.find("HTTP/1.1 200 OK") != std::string::npos, "curl HEAD status line");
Check(head.find("content-length: 11") != std::string::npos, "curl HEAD keeps content-length");
Check(head.find("Hello curl!") == std::string::npos, "curl HEAD carries no body");
Check(Run(curl + "-o /dev/null -w '%{http_code}' " + base + "/missing") == "404",
"curl reads the 404 status");
// curl offers h2 and http/1.1 by default; ours advertises only
// http/1.1, so ALPN has to land there. `-w %{...}` reports what the
// handshake actually agreed on rather than what we hoped for.
Check(Run(curl + "-o /dev/null -w '%{http_version}' " + base + "/hello") == "1.1",
"ALPN settled on HTTP/1.1");
// A client offering only h2 shares no protocol with us. RFC 7301 says
// that is a fatal no_application_protocol alert, not a downgrade.
if (Run("curl -sS --http2-prior-knowledge -o /dev/null -w '%{http_code}' --cacert '"
+ files.certificate.string() + "' " + base + "/hello").find("200")
== std::string::npos) {
Check(true, "an h2-only client is refused rather than mis-served");
} else {
Check(false, "an h2-only client is refused rather than mis-served");
}
listener.Stop();
}
void ClientAgainstPythonServer(const CertificateFiles& files) {
if (!HaveCommand("python3")) {
std::println("skipping the python half: python3 is not installed");
return;
}
const std::filesystem::path root = files.directory / "www";
std::filesystem::create_directories(root);
const std::string content = "served by python over TLS\n";
std::ofstream(root / "hello.txt", std::ios::binary) << content;
// http.server answers HTTP/1.0 with `Connection: close`, so over TLS
// the response body is framed by close_notify — the path our reader
// has to treat as an orderly end rather than a truncation.
const std::string script =
"import functools, http.server, ssl\n"
"context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)\n"
"context.load_cert_chain('" + files.certificate.string() + "', '"
+ files.privateKey.string() + "')\n"
"handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory='"
+ root.string() + "')\n"
"server = http.server.HTTPServer(('127.0.0.1', 8112), handler)\n"
"server.socket = context.wrap_socket(server.socket, server_side=True)\n"
"server.serve_forever()\n";
Child server({"python3", "-c", script});
Check(server.Started(), "python3 TLS http.server was spawned");
if (!WaitForPort(8112, std::chrono::seconds(10))) {
std::println("skipping the python half: the TLS http.server never came up");
return;
}
// Verified against the certificate on disk, exercising caPath (a file)
// rather than the caPem blob the self-contained test uses.
TLSClientCredentials credentials;
credentials.caPath = files.certificate.string();
ClientHTTP1 client("localhost", 8112, credentials);
HTTPResponse response = client.Send(CreateRequestHTTP("GET", "/hello.txt", "localhost:8112"));
Check(response.status == "200", "python TLS GET status");
Check(response.body == content, "python TLS GET body");
Check(!client.Connected(), "an HTTP/1.0 response closes the TLS connection");
HTTPResponse listing = client.Send(CreateRequestHTTP("GET", "/", "localhost:8112"));
Check(listing.status == "200", "python TLS directory listing status");
Check(listing.body.find("hello.txt") != std::string::npos,
"python TLS directory listing body");
HTTPResponse missing = client.Send(CreateRequestHTTP("GET", "/nothing-here", "localhost:8112"));
Check(missing.status == "404", "python TLS 404");
HTTPResponse head = client.Send(CreateRequestHTTP("HEAD", "/hello.txt", "localhost:8112"));
Check(head.status == "200", "python TLS HEAD status");
Check(head.body.empty(), "python TLS HEAD has no body");
}
}
int main() {
try {
CertificateFiles files;
CurlAgainstListener(files);
ClientAgainstPythonServer(files);
} catch (const std::exception& error) {
std::println("threw: {}", error.what());
return 1;
}
if (failures != 0) {
std::println("{} check(s) failed", failures);
return 1;
}
return 0;
}

View file

@ -0,0 +1,204 @@
//SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// Mutual TLS on the HTTP/1.1 listener, plus the raw :TLS stream API on its
// own. A server that asks for a client certificate is only useful if it also
// refuses the peers that do not have one, so both directions are asserted.
//
// The certificate authority and client certificate are minted with the openssl
// CLI: a client certificate needs the clientAuth extended key usage, which the
// library's built-in development certificate (serverAuth, for listeners) does
// not carry. Without openssl the mTLS half is skipped.
import Crafter.Network;
import std;
using namespace Crafter;
namespace {
int failures = 0;
void Check(bool condition, std::string_view what) {
if (!condition) {
std::println("FAIL: {}", what);
++failures;
}
}
bool HaveCommand(std::string_view name) {
const std::string probe = "command -v " + std::string(name) + " >/dev/null 2>&1";
return std::system(probe.c_str()) == 0;
}
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> Routes() {
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes;
routes["/"] = [](const HTTPRequest&) {
return CreateResponseHTTP("200", "authenticated");
};
return routes;
}
// A throwaway CA and a client certificate signed by it.
struct ClientIdentity {
std::filesystem::path directory;
std::filesystem::path authority;
std::filesystem::path certificate;
std::filesystem::path privateKey;
bool ok = false;
ClientIdentity() {
directory = std::filesystem::temp_directory_path() / "crafter-network-mtls";
std::error_code error;
std::filesystem::remove_all(directory, error);
std::filesystem::create_directories(directory);
authority = directory / "ca.pem";
certificate = directory / "client.pem";
privateKey = directory / "client-key.pem";
const std::string extensions = (directory / "client.ext").string();
std::ofstream(extensions, std::ios::binary)
<< "basicConstraints=critical,CA:FALSE\n"
<< "keyUsage=critical,digitalSignature\n"
<< "extendedKeyUsage=clientAuth\n";
const std::string command = std::format(
"set -e\n"
"openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes"
" -keyout '{0}/ca-key.pem' -out '{1}' -days 5 -subj '/CN=Crafter Test CA'"
" -addext 'basicConstraints=critical,CA:TRUE'"
" -addext 'keyUsage=critical,keyCertSign'\n"
"openssl req -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes"
" -keyout '{2}' -out '{0}/client.csr' -subj '/CN=crafter-test-client'\n"
"openssl x509 -req -in '{0}/client.csr' -CA '{1}' -CAkey '{0}/ca-key.pem'"
" -set_serial 2 -days 5 -extfile '{0}/client.ext' -out '{3}'\n",
directory.string(), authority.string(), privateKey.string(),
certificate.string());
ok = std::system((command + " >/dev/null 2>&1").c_str()) == 0;
}
~ClientIdentity() {
std::error_code error;
std::filesystem::remove_all(directory, error);
}
ClientIdentity(const ClientIdentity&) = delete;
};
// The :TLS layer without any HTTP on top — the case for anything else that
// owns a connected socket and wants a record layer over it.
void RawStreamAgainstListener() {
ListenerAsyncHTTP1 listener(8113, Routes(), TLSServerCredentials{ .selfSigned = true });
TLSClientCredentials credentials;
credentials.caPem = GetSelfSignedCertificatePem().certificate;
auto context = TLSContext::Client(credentials);
ClientTCP socket("localhost", 8113);
std::unique_ptr<TLSStream> stream =
TLSStream::Connect(socket.socketid, context, "localhost",
std::chrono::seconds(5));
Check(stream->Secure(), "a TLSStream reports itself as secure");
Check(stream->Protocol() == "http/1.1", "the raw stream negotiated ALPN");
Check(stream->Version().starts_with("TLS"), "a TLS version was negotiated");
// The listener presents the development certificate, whose subject is
// CN=localhost.
Check(stream->PeerCertificateSubject().find("localhost") != std::string::npos,
"the server certificate subject is readable");
// Hand-written HTTP/1.1 straight down the stream, to prove the record
// layer is usable on its own.
const std::string request = "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n";
stream->Write(request.data(), request.size(), std::chrono::seconds(5));
HTTP1::MessageParser parser(HTTP1::MessageKind::Response);
std::vector<char> chunk(4096);
while (!parser.Complete()) {
std::size_t read = 0;
const StreamStatus status =
stream->ReadSome(chunk.data(), chunk.size(), std::chrono::seconds(5), read);
if (status != StreamStatus::Data) break;
parser.Feed(chunk.data(), read);
}
Check(parser.Complete(), "a response arrived over the raw TLS stream");
if (parser.Complete()) {
HTTPResponse response = parser.TakeResponse();
Check(response.status == "200", "raw TLS stream response status");
Check(response.body == "authenticated", "raw TLS stream response body");
}
stream.reset();
listener.Stop();
}
void MutualTLS() {
if (!HaveCommand("openssl")) {
std::println("skipping the mTLS half: openssl is not installed");
return;
}
ClientIdentity identity;
if (!identity.ok) {
std::println("skipping the mTLS half: could not mint a client certificate");
return;
}
TLSServerCredentials server;
server.selfSigned = true;
server.requireClientCertificate = true;
server.clientCaPath = identity.authority.string();
ListenerAsyncHTTP1 listener(8114, Routes(), server);
// A client with a certificate the listener's CA vouches for.
{
TLSClientCredentials credentials;
credentials.caPem = GetSelfSignedCertificatePem().certificate;
credentials.certPath = identity.certificate.string();
credentials.keyPath = identity.privateKey.string();
ClientHTTP1 client("localhost", 8114, credentials);
HTTPResponse response = client.Send(CreateRequestHTTP("GET", "/", "localhost"));
Check(response.status == "200", "a client with a trusted certificate is served");
Check(response.body == "authenticated", "mTLS response body");
}
Check(listener.listener.HandshakeFailureCount() == 0,
"a valid client certificate is not a handshake failure");
// The same client, with no certificate at all. Under TLS 1.3 the
// server's rejection arrives after the client believes the handshake
// finished, so the failure can surface at connect *or* on the first
// exchange — either is a refusal, and neither may be a success.
{
TLSClientCredentials credentials;
credentials.caPem = GetSelfSignedCertificatePem().certificate;
ClientHTTP1 anonymous("localhost", 8114, credentials);
bool refused = false;
try {
anonymous.Send(CreateRequestHTTP("GET", "/", "localhost"));
} catch (const std::exception&) {
refused = true;
}
Check(refused, "a client with no certificate is refused");
}
for (int wait = 0; wait < 100; ++wait) {
if (listener.listener.HandshakeFailureCount() > 0) break;
std::this_thread::sleep_for(std::chrono::milliseconds(20));
}
Check(listener.listener.HandshakeFailureCount() == 1,
"the refused client is counted as a handshake failure");
listener.Stop();
}
}
int main() {
try {
RawStreamAgainstListener();
MutualTLS();
} catch (const std::exception& error) {
std::println("threw: {}", error.what());
return 1;
}
if (failures != 0) {
std::println("{} check(s) failed", failures);
return 1;
}
return 0;
}

View file

@ -0,0 +1,187 @@
//SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// The HTTP/1.1 round-trip of ShouldSendRecieveHTTP1, over TLS. The point is
// that nothing above the transport changed: the same routes, the same
// keep-alive reuse, the same 404/500 behaviour, now with libssl underneath.
//
// Also covers what only exists under TLS: ALPN, `scheme` reported as https,
// certificate verification against a private trust anchor, and the two ways
// verification is supposed to fail.
import Crafter.Network;
import std;
using namespace Crafter;
namespace {
int failures = 0;
void Check(bool condition, std::string_view what) {
if (!condition) {
std::println("FAIL: {}", what);
++failures;
}
}
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> Routes() {
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes;
routes["/"] = [](const HTTPRequest&) {
return CreateResponseHTTP("200", "Hello World!");
};
routes["/echo"] = [](const HTTPRequest& request) {
return CreateResponseHTTP("200", {{"content-type", "text/plain"}}, request.body);
};
routes["/scheme"] = [](const HTTPRequest& request) {
return CreateResponseHTTP("200", request.scheme);
};
routes["/query"] = [](const HTTPRequest& request) {
return CreateResponseHTTP("200", request.path);
};
routes["/boom"] = [](const HTTPRequest&) -> HTTPResponse {
throw std::runtime_error("handler exploded");
};
return routes;
}
}
int main() {
try {
// The listener mints an ephemeral certificate; the client is handed
// that same certificate as a trust anchor, so this exercises real
// chain *and* hostname verification rather than skipping both.
ListenerAsyncHTTP1 listener(8110, Routes(), TLSServerCredentials{ .selfSigned = true });
Check(listener.listener.Secure(), "the listener reports itself as https");
TLSClientCredentials credentials;
credentials.caPem = GetSelfSignedCertificatePem().certificate;
ClientHTTP1 client("localhost", 8110, credentials);
Check(client.Secure(), "the client reports itself as https");
HTTPResponse hello = client.Send(CreateRequestHTTP("GET", "/", "localhost"));
Check(hello.status == "200", "GET / status");
Check(hello.body == "Hello World!", "GET / body");
Check(hello.headers.contains("date"), "the server stamps a Date header");
Check(hello.headers.at("content-length") == "12", "content-length matches the body");
// ALPN is the whole reason a TLS server can tell HTTP/1.1 from h2
// before reading a byte, so assert it actually got negotiated.
Check(client.Protocol() == "http/1.1", "ALPN negotiated http/1.1");
HTTPResponse echoed = client.Send(CreateRequestHTTP("POST", "/echo", "localhost",
std::string("ping pong")));
Check(echoed.status == "200", "POST /echo status");
Check(echoed.body == "ping pong", "POST /echo returns the request body");
Check(echoed.headers.at("content-type") == "text/plain", "handler headers survive");
// Origin-form targets carry no scheme; the transport has to supply it.
HTTPResponse scheme = client.Send(CreateRequestHTTP("GET", "/scheme", "localhost"));
Check(scheme.body == "https", "the handler sees scheme=https over TLS");
HTTPResponse query = client.Send(CreateRequestHTTP("GET", "/query?a=1&b=2", "localhost"));
Check(query.status == "200", "query-string request routes to the bare path");
Check(query.body == "/query?a=1&b=2", "the handler sees the full target");
HTTPResponse missing = client.Send(CreateRequestHTTP("GET", "/nope", "localhost"));
Check(missing.status == "404", "unknown route is a 404");
HTTPResponse head = client.Send(CreateRequestHTTP("HEAD", "/", "localhost"));
Check(head.status == "200", "HEAD status");
Check(head.body.empty(), "HEAD has no body");
Check(head.headers.at("content-length") == "12", "HEAD still advertises the length");
HTTPResponse boom = client.Send(CreateRequestHTTP("GET", "/boom", "localhost"));
Check(boom.status == "500", "a throwing handler yields 500");
Check(boom.body.find("handler exploded") != std::string::npos, "500 carries the reason");
// A body big enough to span many TLS records, to catch a Write() that
// mishandles a partial SSL_write.
const std::string large(512 * 1024, 'z');
HTTPResponse bulk = client.Send(CreateRequestHTTP("POST", "/echo", "localhost", large));
Check(bulk.status == "200", "large POST status");
Check(bulk.body == large, "a body spanning many TLS records survives intact");
// Every exchange above shared one TLS session — no rehandshaking per
// request, which is what makes keep-alive worth having here.
Check(listener.listener.AcceptedCount() == 1, "the whole test used a single connection");
Check(client.Connected(), "the connection is still pooled");
Check(listener.listener.HandshakeFailureCount() == 0, "no handshake failed");
// ── Verification has to actually fail when it should ──────────────
// Default credentials: system trust store only, so a self-signed
// certificate must be rejected rather than quietly accepted.
{
ClientHTTP1 strict("localhost", 8110, TLSClientCredentials{});
bool rejected = false;
try {
strict.Send(CreateRequestHTTP("GET", "/", "localhost"));
} catch (const TLSException&) {
rejected = true;
}
Check(rejected, "an untrusted self-signed certificate is rejected");
Check(!strict.Connected(), "a rejected connection is not left pooled");
}
// Right certificate, wrong name: the chain checks out but the SANs say
// localhost, so the name check has to catch it. Verifying the chain
// without the name is the classic way TLS gets deployed insecurely.
{
TLSClientCredentials mismatched;
mismatched.caPem = GetSelfSignedCertificatePem().certificate;
mismatched.serverName = "not-localhost.invalid";
ClientHTTP1 wrongName("localhost", 8110, mismatched);
bool rejected = false;
try {
wrongName.Send(CreateRequestHTTP("GET", "/", "localhost"));
} catch (const TLSException&) {
rejected = true;
}
Check(rejected, "a certificate for the wrong name is rejected");
}
// insecureNoServerValidation is the dev escape hatch; it has to work,
// because the alternative is people shipping their own worse one.
{
ClientHTTP1 insecure("localhost", 8110,
TLSClientCredentials{ .insecureNoServerValidation = true });
HTTPResponse response = insecure.Send(CreateRequestHTTP("GET", "/", "localhost"));
Check(response.body == "Hello World!", "insecureNoServerValidation talks to the same server");
}
// A plaintext client against a TLS listener: its request line is not a
// TLS record, so the handshake fails and the server counts it. This is
// what a port scanner or a misconfigured caller looks like, and it
// must not disturb anything else.
{
const std::uint64_t before = listener.listener.HandshakeFailureCount();
try {
ClientHTTP1 plaintext("localhost", 8110);
plaintext.Send(CreateRequestHTTP("GET", "/", "localhost"));
} catch (const std::exception&) {
// Expected: the listener drops it without answering.
}
// The handshake is rejected on the connection thread, so give it a
// moment to record the failure before reading the counter.
for (int wait = 0; wait < 100; ++wait) {
if (listener.listener.HandshakeFailureCount() > before) break;
std::this_thread::sleep_for(std::chrono::milliseconds(20));
}
Check(listener.listener.HandshakeFailureCount() == before + 1,
"a plaintext peer is counted as a handshake failure");
}
// And the TLS listener still serves after all that.
HTTPResponse after = client.Send(CreateRequestHTTP("GET", "/", "localhost"));
Check(after.body == "Hello World!", "the listener still serves after a bad peer");
listener.Stop();
} catch (const std::exception& error) {
std::println("threw: {}", error.what());
return 1;
}
if (failures != 0) {
std::println("{} check(s) failed", failures);
return 1;
}
return 0;
}