diff --git a/README.md b/README.md index efaf1e7..e6201b5 100644 --- a/README.md +++ b/README.md @@ -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 pool–based 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 diff --git a/implementations/Crafter.Network-ClientHTTP1.cpp b/implementations/Crafter.Network-ClientHTTP1.cpp index 7ffbcf1..134f83d 100644 --- a/implementations/Crafter.Network-ClientHTTP1.cpp +++ b/implementations/Crafter.Network-ClientHTTP1.cpp @@ -2,8 +2,6 @@ //SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® module; -#include -#include #include 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(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(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 tls; std::unique_ptr tcp; + // Sits on top of `tcp` and must therefore be destroyed before it. + std::unique_ptr stream; + std::string protocol; - void Connect() { + void Connect(std::chrono::milliseconds handshakeTimeout) { tcp = std::make_unique(host, port); + try { + if (tls) { + stream = TLSStream::Connect(tcp->socketid, tls, host, handshakeTimeout); + } else { + stream = std::make_unique(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(wire.size())); + stream->Write(wire.data(), wire.size(), timeout); HTTP1::MessageParser parser(HTTP1::MessageKind::Response, limits); parser.SetRequestMethod(method); std::vector 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(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 { diff --git a/implementations/Crafter.Network-ListenerHTTP1.cpp b/implementations/Crafter.Network-ListenerHTTP1.cpp index 45d764e..e4d29cd 100644 --- a/implementations/Crafter.Network-ListenerHTTP1.cpp +++ b/implementations/Crafter.Network-ListenerHTTP1.cpp @@ -2,7 +2,6 @@ //SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® module; -#include #include #include @@ -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(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(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 listener; + // Null on a plaintext listener. One context for every connection: the + // certificate and trust store are parsed once, at construction. + std::shared_ptr 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> connections; std::atomic running{true}; std::atomic accepted{0}; + std::atomic 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 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(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 Wrap(ClientTCP& client) { + if (!tls) return std::make_unique(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 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> 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{}) {} 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> routes, + TLSServerCredentials credentials) + : ListenerHTTP1(port, std::move(routes), + std::function{}, std::move(credentials)) +{} + +ListenerHTTP1::ListenerHTTP1(std::uint16_t port, + std::unordered_map> routes, + std::function 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> 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> routes, + TLSServerCredentials credentials) + : listener(port, std::move(routes), std::move(credentials)) + , thread(&ListenerHTTP1::Listen, &listener) +{} + +ListenerAsyncHTTP1::ListenerAsyncHTTP1(std::uint16_t port, + std::unordered_map> routes, + std::function 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() { diff --git a/implementations/Crafter.Network-Stream.cpp b/implementations/Crafter.Network-Stream.cpp new file mode 100644 index 0000000..33e7376 --- /dev/null +++ b/implementations/Crafter.Network-Stream.cpp @@ -0,0 +1,99 @@ +//SPDX-License-Identifier: LGPL-3.0-only +//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +module; +#include +#include +#include +#include + +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( + 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(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(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(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(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); +} diff --git a/implementations/Crafter.Network-TLS.cpp b/implementations/Crafter.Network-TLS.cpp new file mode 100644 index 0000000..4977021 --- /dev/null +++ b/implementations/Crafter.Network-TLS.cpp @@ -0,0 +1,721 @@ +//SPDX-License-Identifier: LGPL-3.0-only +//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +module; +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +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 + struct Releaser { + void operator()(T* pointer) const noexcept { if (pointer) Release(pointer); } + }; + template + using Owned = std::unique_ptr>; + + using OwnedBio = Owned; + using OwnedKey = Owned; + using OwnedCertificate = Owned; + + // 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(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 EncodeAlpn(const std::vector& protocols) { + std::vector 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(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*>(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(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("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(INT_MAX)) { + throw TLSException("PEM blob is implausibly large"); + } + OwnedBio bio(BIO_new_mem_buf(contents.data(), static_cast(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 alpn; + + ~Impl() { if (context) SSL_CTX_free(context); } +}; + +TLSContext::TLSContext() : impl(std::make_unique()) {} +TLSContext::~TLSContext() = default; + +std::shared_ptr TLSContext::Server(const TLSServerCredentials& credentials) { + std::shared_ptr 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::Client(const TLSClientCredentials& credentials) { + std::shared_ptr 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 wire = EncodeAlpn(credentials.alpnProtocols); + if (SSL_CTX_set_alpn_protos(state.context, wire.data(), + static_cast(wire.size())) != 0) { + throw TLSException(Describe("could not set the ALPN protocol list")); + } + } + return wrapper; +} + +// ── TLSStream ──────────────────────────────────────────────────────────── +struct TLSStream::Impl { + std::shared_ptr 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(selected), length); + } + } +}; + +TLSStream::TLSStream() : impl(std::make_unique()) {} + +TLSStream::~TLSStream() { + Shutdown(); +} + +std::unique_ptr TLSStream::Connect(int descriptor, + std::shared_ptr context, + const std::string& hostName, + std::chrono::milliseconds timeout) { + if (!context) throw TLSException("no TLS context"); + SetNonBlocking(descriptor); + + std::unique_ptr 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::Accept(int descriptor, + std::shared_ptr context, + std::chrono::milliseconds timeout) { + if (!context) throw TLSException("no TLS context"); + SetNonBlocking(descriptor); + + std::unique_ptr 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(std::min(size, INT_MAX)); + for (;;) { + ERR_clear_error(); + const int got = SSL_read(impl->ssl, buffer, wanted); + if (got > 0) { + read = static_cast(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(buffer); + std::size_t sent = 0; + while (sent < size) { + const int wanted = static_cast(std::min(size - sent, INT_MAX)); + ERR_clear_error(); + const int wrote = SSL_write(impl->ssl, data + sent, wanted); + if (wrote > 0) { + sent += static_cast(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 cached; + std::lock_guard lock(mutex); + if (!cached) cached = MakeSelfSignedCertificate(); + return *cached; +} diff --git a/interfaces/Crafter.Network-ClientHTTP1.cppm b/interfaces/Crafter.Network-ClientHTTP1.cppm index 7244221..87ff2c5 100644 --- a/interfaces/Crafter.Network-ClientHTTP1.cppm +++ b/interfaces/Crafter.Network-ClientHTTP1.cppm @@ -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; diff --git a/interfaces/Crafter.Network-HTTP1.cppm b/interfaces/Crafter.Network-HTTP1.cppm index db84c02..a4a62cb 100644 --- a/interfaces/Crafter.Network-HTTP1.cppm +++ b/interfaces/Crafter.Network-HTTP1.cppm @@ -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; diff --git a/interfaces/Crafter.Network-ListenerHTTP1.cppm b/interfaces/Crafter.Network-ListenerHTTP1.cppm index 7f2454e..69ba51c 100644 --- a/interfaces/Crafter.Network-ListenerHTTP1.cppm +++ b/interfaces/Crafter.Network-ListenerHTTP1.cppm @@ -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> 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> routes, std::function 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> routes, + TLSServerCredentials credentials); + + // TLS plus a fallback handler. + ListenerHTTP1(std::uint16_t port, + std::unordered_map> routes, + std::function 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> routes, std::function fallback); + // TLS, with and without a fallback handler. + ListenerAsyncHTTP1(std::uint16_t port, + std::unordered_map> routes, + TLSServerCredentials credentials); + ListenerAsyncHTTP1(std::uint16_t port, + std::unordered_map> routes, + std::function fallback, + TLSServerCredentials credentials); + ~ListenerAsyncHTTP1(); void Stop(); }; diff --git a/interfaces/Crafter.Network-Stream.cppm b/interfaces/Crafter.Network-Stream.cppm new file mode 100644 index 0000000..a583094 --- /dev/null +++ b/interfaces/Crafter.Network-Stream.cppm @@ -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 diff --git a/interfaces/Crafter.Network-TLS.cppm b/interfaces/Crafter.Network-TLS.cppm new file mode 100644 index 0000000..219e05b --- /dev/null +++ b/interfaces/Crafter.Network-TLS.cppm @@ -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 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 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 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 Server(const TLSServerCredentials& credentials); + static std::shared_ptr Client(const TLSClientCredentials& credentials); + + ~TLSContext(); + TLSContext(const TLSContext&) = delete; + TLSContext& operator=(const TLSContext&) = delete; + + private: + TLSContext(); + struct Impl; + std::unique_ptr 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 Connect(int descriptor, + std::shared_ptr context, + const std::string& hostName, + std::chrono::milliseconds timeout); + + // Server side, on a descriptor accept(2) just handed us. + static std::unique_ptr Accept(int descriptor, + std::shared_ptr 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; + }; + + // 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 diff --git a/interfaces/Crafter.Network.cppm b/interfaces/Crafter.Network.cppm index 3ea16b9..92cd7a2 100755 --- a/interfaces/Crafter.Network.cppm +++ b/interfaces/Crafter.Network.cppm @@ -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 \ No newline at end of file diff --git a/project.cpp b/project.cpp index c8180e2..59fd6ec 100644 --- a/project.cpp +++ b/project.cpp @@ -7,7 +7,7 @@ namespace fs = std::filesystem; using namespace Crafter; extern "C" Configuration CrafterBuildProject(std::span args) { - constexpr std::array networkInterfaces = { + constexpr std::array networkInterfaces = { "interfaces/Crafter.Network", "interfaces/Crafter.Network-ClientTCP", "interfaces/Crafter.Network-ListenerTCP", @@ -15,6 +15,8 @@ extern "C" Configuration CrafterBuildProject(std::span 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 a return cfg; } - constexpr std::array networkImplementations = { + constexpr std::array 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 a // linker at the actual output location. msquic.libDirs = { "bin/Release" }; msquic.libs = { "msquic" }; - std::array 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 ifaces; std::ranges::copy(networkInterfaces, ifaces.begin()); - std::array impls; + std::array impls; std::ranges::copy(networkImplementations, impls.begin()); cfg.GetInterfacesAndImplementations(ifaces, impls); @@ -121,10 +134,13 @@ extern "C" Configuration CrafterBuildProject(std::span 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 }); diff --git a/tests/ShouldInteropCurlHTTPS1/main.cpp b/tests/ShouldInteropCurlHTTPS1/main.cpp new file mode 100644 index 0000000..2182b36 --- /dev/null +++ b/tests/ShouldInteropCurlHTTPS1/main.cpp @@ -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 +#include +#include +#include + +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 argv) { + std::vector 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> 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; +} diff --git a/tests/ShouldRequireClientCertificateHTTPS1/main.cpp b/tests/ShouldRequireClientCertificateHTTPS1/main.cpp new file mode 100644 index 0000000..cf56821 --- /dev/null +++ b/tests/ShouldRequireClientCertificateHTTPS1/main.cpp @@ -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> Routes() { + std::unordered_map> 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 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 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; +} diff --git a/tests/ShouldSendRecieveHTTPS1/main.cpp b/tests/ShouldSendRecieveHTTPS1/main.cpp new file mode 100644 index 0000000..fd0e907 --- /dev/null +++ b/tests/ShouldSendRecieveHTTPS1/main.cpp @@ -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> Routes() { + std::unordered_map> 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; +}