Compare commits

...
Author SHA1 Message Date
0827e1f566 Merge pull request 'https' (#6) from claude/issue-3 into master
Reviewed-on: #6
2026-07-30 18:18:38 +02:00
catbot
14e0a6bab1 test(https): give the TLS tests ports of their own
The new tests reused ports the existing suite already binds — 8095 with
ShouldSurviveAbuseHTTP1, and 8097/8098 with ShouldFallbackUnknownRoutes'
plaintext and HTTP/3 listeners. SO_REUSEADDR does not let two live
listeners share a port, so under the parallel runner whichever bound
second failed, and which test that was came down to scheduling. Move the
TLS tests to 8110-8114, which nothing else uses.

That collision also showed up as a SIGABRT rather than a reported error,
so harden the path it took: ~ListenerHTTP1 calls Stop(), which joins
threads and touches sockets and can therefore throw. A second listener
failing to bind unwinds past a live first one, and a throw out of its
destructor mid-unwind terminates the process — turning a diagnosable bind
failure into a crash. Swallow it there, where there is nothing left to
report it to.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 20:34:23 +00:00
catbot
331cf0c66c docs: describe HTTPS, the TLS layer, and its system dependency
The HTTP/1.1 section promised the opposite of what the code now does — a
"No TLS" heading stating there was no plan to link a TLS stack into this
path. Replace it with what to actually pass, and lead with the part that
gets deployed wrong: verifying the chain without the hostname is not a
check, and a private trust anchor is the answer for a self-signed peer
rather than insecureNoServerValidation.

Also document :Stream and :TLS as modules in their own right — TLSStream
is a ByteStream over any descriptor, not something only HTTP can use —
and record libssl as a system dependency, including why it is not
vendored the way msquic is.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 20:23:40 +00:00
catbot
b7fe39871b fix(tls): stop a peer that closed first from killing the process
OpenSSL's socket BIO writes with write(2) rather than send(2), so unlike
PlainStream it cannot pass MSG_NOSIGNAL. Writing to a peer that is gone
therefore raised SIGPIPE, and with the default disposition that takes the
whole process down.

This is not an edge case. It fires on any teardown where the far side
closed first, because SSL_shutdown still tries to put a close_notify on
the wire — which is exactly what ShouldSendRecieveHTTPS1 does when it
drops a client whose certificate check failed. The test died on SIGPIPE
with every assertion passing.

Installing a process-wide SIG_IGN would fix it by changing how the
caller's own writes report failure, which a library has no business
doing. SIGPIPE from write(2) is delivered to the writing thread, so block
it for that thread across each OpenSSL call instead and drain any pending
instance before unblocking. A caller who already blocks SIGPIPE is left
untouched — a pending signal there may be theirs to consume.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 20:18:51 +00:00
catbot
39ff5806ed test(https): cover the TLS transport from three angles
ShouldSendRecieveHTTPS1 replays the plaintext round-trip over TLS, so a
regression in the transport shows up as an HTTP failure rather than
nothing at all, and adds what only exists under TLS: ALPN, scheme=https
reaching handlers, a body spanning many records, and the two ways
verification must fail — an untrusted self-signed certificate, and a
trusted certificate presented for the wrong name. A plaintext peer
knocking on the TLS port is asserted to be counted and shrugged off.

ShouldInteropCurlHTTPS1 puts real implementations on the other end, since
two OpenSSL peers can agree on a mistake. curl verifies our certificate
with --cacert rather than --insecure, and an h2-only curl is asserted to
be refused rather than mis-served. python3's http.server behind
ssl.wrap_socket answers HTTP/1.0 with Connection: close, which frames the
body by close_notify — the path a reader is most likely to get wrong.

ShouldRequireClientCertificateHTTPS1 covers mutual TLS both ways, and
drives TLSStream directly with hand-written HTTP/1.1 to keep the :TLS
layer honest as something usable without :ClientHTTP1 on top.

Also fix a delegation that `{}` no longer disambiguates now that a
three-argument TLS constructor exists alongside the fallback one.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 20:17:07 +00:00
catbot
9c22cbe09e feat(tls): add a libssl TLS transport and an https:// HTTP/1.1 stack
HTTP/1.1 was plaintext-only, which left `https://` to either an HTTP/3
listener or a terminating proxy in front. Neither helps the callers this
stack exists for — curl scripts, CI tooling, old proxies — so wrap the
transport in libssl instead.

Two new partitions:

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 20:13:30 +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;
}