diff --git a/README.md b/README.md index e6201b5..efaf1e7 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. `https://` on both sides via libssl, including ALPN and mutual TLS — 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. Plaintext only — 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,10 +29,8 @@ 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, 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:ClientHTTP1`: HTTP/1.1 client over TCP (native only) +- `Crafter.Network:ListenerHTTP1`: HTTP/1.1 server over TCP (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. @@ -97,7 +95,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 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. +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. #### ClientHTTP1 ```cpp @@ -108,7 +106,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 the scheme default — 443 under TLS, 80 without). +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). #### ListenerHTTP1 ```cpp @@ -125,52 +123,13 @@ 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: 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. +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. 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. -#### HTTPS +#### No TLS -`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. +`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. #### HTTP/1.1 wire format on its own @@ -283,11 +242,8 @@ 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` 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. +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. ## Dependencies @@ -295,8 +251,7 @@ 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`. -- **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. +- The HTTP/1.1 stack has no dependencies beyond POSIX sockets — no TLS library, no msquic. - **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 134f83d..7ffbcf1 100644 --- a/implementations/Crafter.Network-ClientHTTP1.cpp +++ b/implementations/Crafter.Network-ClientHTTP1.cpp @@ -2,6 +2,8 @@ //SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® module; +#include +#include #include module Crafter.Network:ClientHTTP1_impl; @@ -9,19 +11,41 @@ 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, bool secure) { - if (port == (secure ? 443 : 80)) return host; + std::string DefaultAuthority(const std::string& host, std::uint16_t port) { + if (port == 80) return host; return host + ":" + std::to_string(port); } } @@ -29,34 +53,13 @@ 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(std::chrono::milliseconds handshakeTimeout) { + void Connect() { 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(); } @@ -66,19 +69,14 @@ struct ClientHTTP1::Impl { HTTPResponse Exchange(const std::string& wire, std::string_view method, const HTTP1::MessageLimits& limits, std::chrono::milliseconds timeout, bool& received) { - stream->Write(wire.data(), wire.size(), timeout); + tcp->Send(wire.data(), static_cast(wire.size())); HTTP1::MessageParser parser(HTTP1::MessageKind::Response, limits); parser.SetRequestMethod(method); std::vector chunk(16 * 1024); while (!parser.Complete()) { - 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) { + const std::size_t read = ReadSome(tcp->socketid, chunk.data(), chunk.size(), timeout); + if (read == 0) { // Peer closed. Completes a response framed by close; // anything else throws out of Finish(). parser.Finish(); @@ -102,17 +100,6 @@ 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; @@ -120,23 +107,13 @@ 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, Secure()); - } + if (prepared.authority.empty()) prepared.authority = DefaultAuthority(host, port); const std::string wire = HTTP1::SerializeRequest(prepared); const std::string method = prepared.method.empty() ? std::string("GET") : prepared.method; @@ -146,7 +123,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(handshakeTimeout); + if (!reused) impl->Connect(); bool received = false; try { diff --git a/implementations/Crafter.Network-ListenerHTTP1.cpp b/implementations/Crafter.Network-ListenerHTTP1.cpp index e4d29cd..45d764e 100644 --- a/implementations/Crafter.Network-ListenerHTTP1.cpp +++ b/implementations/Crafter.Network-ListenerHTTP1.cpp @@ -2,6 +2,7 @@ //SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® module; +#include #include #include @@ -11,12 +12,39 @@ 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 { @@ -28,9 +56,6 @@ 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. @@ -38,7 +63,6 @@ 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. @@ -64,13 +88,7 @@ struct ListenerHTTP1::Impl { this->accepted.fetch_add(1); connection->thread = std::thread([this, pointer] { try { - // 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); + Serve(*pointer->client); } catch (...) { // A connection dying must never take the server with it. } @@ -94,22 +112,8 @@ struct ListenerHTTP1::Impl { // would abort the process. } - // 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); + void Send(ClientTCP& client, const std::string& wire) { + client.Send(wire.data(), static_cast(wire.size())); } HTTPResponse Dispatch(const HTTPRequest& request) { @@ -134,12 +138,8 @@ struct ListenerHTTP1::Impl { // Serve one connection until the peer goes away, asks to close, stalls, // or sends something we refuse to parse. - void Serve(ByteStream& stream) { + void Serve(ClientTCP& client) { 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 StreamStatus status = stream.ReadSome(chunk.data(), chunk.size(), - timeout, read); - if (status == StreamStatus::TimedOut) { + const ReadStatus status = ReadSome(client.socketid, chunk.data(), chunk.size(), + timeout, read); + if (status == ReadStatus::TimedOut) { // An idle keep-alive connection is simply dropped; // a half-sent request earns a 408 first. if (!idle) { try { - Send(stream, HTTP1::SerializeResponse( + Send(client, HTTP1::SerializeResponse( CreateResponseHTTP("408", "Request Timeout"), { .keepAlive = false })); } catch (...) {} } return; } - if (status == StreamStatus::Closed) { + if (status == ReadStatus::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(stream, HTTP1::SerializeContinue()); + Send(client, HTTP1::SerializeContinue()); parser.ContinueSent(); } } } catch (const HTTP1::HTTP1ProtocolError& error) { try { - Send(stream, HTTP1::SerializeResponse( + Send(client, HTTP1::SerializeResponse( CreateResponseHTTP("400", std::string(error.what())), { .keepAlive = false })); } catch (...) {} @@ -210,7 +210,7 @@ struct ListenerHTTP1::Impl { } try { - Send(stream, HTTP1::SerializeResponse(response, { + Send(client, HTTP1::SerializeResponse(response, { .keepAlive = keepAlive, // HTTP/1.0 peers only reuse a connection they were told // stays open. @@ -235,9 +235,7 @@ struct ListenerHTTP1::Impl { ListenerHTTP1::ListenerHTTP1(std::uint16_t port, std::unordered_map> 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(port, std::move(routes), {}) {} ListenerHTTP1::ListenerHTTP1(std::uint16_t port, @@ -254,31 +252,11 @@ 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)) { @@ -288,15 +266,7 @@ ListenerHTTP1::ListenerHTTP1(ListenerHTTP1&& other) noexcept } ListenerHTTP1::~ListenerHTTP1() { - // 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 (...) { - } + if (impl) Stop(); } void ListenerHTTP1::Listen() { @@ -343,14 +313,6 @@ 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)) @@ -364,26 +326,8 @@ 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() { - try { - Stop(); - } catch (...) { - } + Stop(); } void ListenerAsyncHTTP1::Stop() { diff --git a/implementations/Crafter.Network-Stream.cpp b/implementations/Crafter.Network-Stream.cpp deleted file mode 100644 index 33e7376..0000000 --- a/implementations/Crafter.Network-Stream.cpp +++ /dev/null @@ -1,99 +0,0 @@ -//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 deleted file mode 100644 index 4977021..0000000 --- a/implementations/Crafter.Network-TLS.cpp +++ /dev/null @@ -1,721 +0,0 @@ -//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 87ff2c5..7244221 100644 --- a/interfaces/Crafter.Network-ClientHTTP1.cppm +++ b/interfaces/Crafter.Network-ClientHTTP1.cppm @@ -5,14 +5,13 @@ export module Crafter.Network:ClientHTTP1; import std; import :HTTP; import :HTTP1; -import :TLS; #ifndef CRAFTER_NETWORK_BROWSER namespace Crafter { - // 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. + // 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. // // The connection is persistent: the first Send() dials, and later calls // reuse the socket unless the peer asked for it to be closed @@ -26,12 +25,9 @@ namespace Crafter { // Thread-affinity matches ClientHTTP: one ClientHTTP1 serves one caller // at a time; distinct instances are independent. // - // `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). + // 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. export class ClientHTTP1 { public: std::string host; @@ -40,21 +36,13 @@ 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 - // — HTTP/1.1 request targets are origin-form and the transport was - // already decided by the constructor. + // the host:port this client was constructed with; `scheme` is + // ignored (the transport is plaintext). HTTPResponse Send(const HTTPRequest& request); // Send a request and deliver the response (or the error text) via @@ -67,14 +55,6 @@ 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(); @@ -83,10 +63,6 @@ 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 a4a62cb..db84c02 100644 --- a/interfaces/Crafter.Network-HTTP1.cppm +++ b/interfaces/Crafter.Network-HTTP1.cppm @@ -299,14 +299,6 @@ 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(); @@ -348,7 +340,7 @@ namespace Crafter::HTTP1 { HTTPRequest request; request.method = std::move(method); request.path = std::move(path); - request.scheme = scheme.empty() ? defaultScheme : std::move(scheme); + request.scheme = scheme.empty() ? std::string("http") : 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 @@ -767,10 +759,6 @@ 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 69ba51c..7f2454e 100644 --- a/interfaces/Crafter.Network-ListenerHTTP1.cppm +++ b/interfaces/Crafter.Network-ListenerHTTP1.cppm @@ -5,13 +5,12 @@ export module Crafter.Network:ListenerHTTP1; import std; import :HTTP; import :HTTP1; -import :TLS; #ifndef CRAFTER_NETWORK_BROWSER namespace Crafter { - // 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. + // 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. // // Each accepted connection gets its own thread and is served // sequentially until the peer closes it, `Connection: close` is seen, or @@ -19,16 +18,13 @@ 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. The TLS - // handshake runs on that same per-connection thread, so a peer that - // stalls mid-handshake cannot hold up the accept loop either. + // most of their life and would otherwise pin every pool thread. // - // 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). + // 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). export class ListenerHTTP1 { public: std::unordered_map> routes; @@ -49,9 +45,6 @@ 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; @@ -62,21 +55,6 @@ 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; @@ -91,13 +69,6 @@ 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; @@ -121,15 +92,6 @@ 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 deleted file mode 100644 index a583094..0000000 --- a/interfaces/Crafter.Network-Stream.cppm +++ /dev/null @@ -1,95 +0,0 @@ -//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 deleted file mode 100644 index 219e05b..0000000 --- a/interfaces/Crafter.Network-TLS.cppm +++ /dev/null @@ -1,179 +0,0 @@ -//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 92cd7a2..3ea16b9 100755 --- a/interfaces/Crafter.Network.cppm +++ b/interfaces/Crafter.Network.cppm @@ -21,12 +21,6 @@ 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 59fd6ec..c8180e2 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,8 +15,6 @@ 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", @@ -74,13 +72,11 @@ 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", @@ -112,18 +108,9 @@ extern "C" Configuration CrafterBuildProject(std::span a // linker at the actual output location. msquic.libDirs = { "bin/Release" }; msquic.libs = { "msquic" }; - - // 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::array ifaces; std::ranges::copy(networkInterfaces, ifaces.begin()); - std::array impls; + std::array impls; std::ranges::copy(networkImplementations, impls.begin()); cfg.GetInterfacesAndImplementations(ifaces, impls); @@ -134,13 +121,10 @@ 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 deleted file mode 100644 index 2182b36..0000000 --- a/tests/ShouldInteropCurlHTTPS1/main.cpp +++ /dev/null @@ -1,257 +0,0 @@ -//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 deleted file mode 100644 index cf56821..0000000 --- a/tests/ShouldRequireClientCertificateHTTPS1/main.cpp +++ /dev/null @@ -1,204 +0,0 @@ -//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 deleted file mode 100644 index fd0e907..0000000 --- a/tests/ShouldSendRecieveHTTPS1/main.cpp +++ /dev/null @@ -1,187 +0,0 @@ -//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; -}