diff --git a/implementations/Crafter.Network-ClientHTTP1.cpp b/implementations/Crafter.Network-ClientHTTP1.cpp index 7ffbcf1..134f83d 100644 --- a/implementations/Crafter.Network-ClientHTTP1.cpp +++ b/implementations/Crafter.Network-ClientHTTP1.cpp @@ -2,8 +2,6 @@ //SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® module; -#include -#include #include module Crafter.Network:ClientHTTP1_impl; @@ -11,41 +9,19 @@ import :ClientHTTP1; import :ClientTCP; import :HTTP; import :HTTP1; +import :Stream; +import :TLS; import Crafter.Thread; import std; using namespace Crafter; namespace { - // Read whatever is available, waiting at most `timeout`. Returns 0 on a - // clean close by the peer. ClientTCP::RecieveSync() can't express a - // timeout — and a server that accepts the connection and then says - // nothing would hang Send() forever — so the read is done here. - std::size_t ReadSome(int socketid, char* buffer, std::size_t size, - std::chrono::milliseconds timeout) { - for (;;) { - pollfd pfd{ .fd = socketid, .events = POLLIN, .revents = 0 }; - const int ready = poll(&pfd, 1, static_cast(timeout.count())); - if (ready < 0) { - if (errno == EINTR) continue; - throw std::runtime_error(std::string("poll failed: ") + std::strerror(errno)); - } - if (ready == 0) throw std::runtime_error("timed out waiting for the response"); - - const auto read = recv(socketid, buffer, size, 0); - if (read < 0) { - if (errno == EINTR) continue; - throw std::runtime_error(std::string("recv failed: ") + std::strerror(errno)); - } - return static_cast(read); - } - } - // Host header value. The port is elided when it is the scheme default, // which is what every other HTTP/1.1 client on the wire does and what // virtual-host matching on the far side tends to expect. - std::string DefaultAuthority(const std::string& host, std::uint16_t port) { - if (port == 80) return host; + std::string DefaultAuthority(const std::string& host, std::uint16_t port, bool secure) { + if (port == (secure ? 443 : 80)) return host; return host + ":" + std::to_string(port); } } @@ -53,13 +29,34 @@ namespace { struct ClientHTTP1::Impl { std::string host; std::uint16_t port; + // Null for plaintext; shared by every connection this client dials, so the + // trust store is parsed once rather than per redial. + std::shared_ptr tls; std::unique_ptr tcp; + // Sits on top of `tcp` and must therefore be destroyed before it. + std::unique_ptr stream; + std::string protocol; - void Connect() { + void Connect(std::chrono::milliseconds handshakeTimeout) { tcp = std::make_unique(host, port); + try { + if (tls) { + stream = TLSStream::Connect(tcp->socketid, tls, host, handshakeTimeout); + } else { + stream = std::make_unique(tcp->socketid); + } + } catch (...) { + // A half-built connection must not be left pooled: the next Send() + // would treat it as reusable and read from a socket with no + // session on it. + tcp.reset(); + throw; + } + protocol = std::string(stream->Protocol()); } void Close() { + stream.reset(); tcp.reset(); } @@ -69,14 +66,19 @@ struct ClientHTTP1::Impl { HTTPResponse Exchange(const std::string& wire, std::string_view method, const HTTP1::MessageLimits& limits, std::chrono::milliseconds timeout, bool& received) { - tcp->Send(wire.data(), static_cast(wire.size())); + stream->Write(wire.data(), wire.size(), timeout); HTTP1::MessageParser parser(HTTP1::MessageKind::Response, limits); parser.SetRequestMethod(method); std::vector chunk(16 * 1024); while (!parser.Complete()) { - const std::size_t read = ReadSome(tcp->socketid, chunk.data(), chunk.size(), timeout); - if (read == 0) { + std::size_t read = 0; + const StreamStatus status = stream->ReadSome(chunk.data(), chunk.size(), + timeout, read); + if (status == StreamStatus::TimedOut) { + throw std::runtime_error("timed out waiting for the response"); + } + if (status == StreamStatus::Closed) { // Peer closed. Completes a response framed by close; // anything else throws out of Finish(). parser.Finish(); @@ -100,6 +102,17 @@ ClientHTTP1::ClientHTTP1(const char* host, std::uint16_t port) ClientHTTP1::ClientHTTP1(std::string host, std::uint16_t port) : ClientHTTP1(host.c_str(), port) {} +ClientHTTP1::ClientHTTP1(const char* host, std::uint16_t port, TLSClientCredentials credentials) + : host(host), port(port), impl(std::make_unique(std::string(host), port)) { + // Built here rather than on first Send() so bad credentials — an + // unreadable certificate, a trust anchor that is not a certificate — + // surface at construction, where the caller is still looking. + impl->tls = TLSContext::Client(credentials); +} + +ClientHTTP1::ClientHTTP1(std::string host, std::uint16_t port, TLSClientCredentials credentials) + : ClientHTTP1(host.c_str(), port, std::move(credentials)) {} + ClientHTTP1::ClientHTTP1(ClientHTTP1&&) noexcept = default; ClientHTTP1::~ClientHTTP1() = default; @@ -107,13 +120,23 @@ bool ClientHTTP1::Connected() const noexcept { return impl && impl->tcp != nullptr; } +bool ClientHTTP1::Secure() const noexcept { + return impl && impl->tls != nullptr; +} + +std::string_view ClientHTTP1::Protocol() const noexcept { + return impl ? std::string_view(impl->protocol) : std::string_view(); +} + void ClientHTTP1::Disconnect() { if (impl) impl->Close(); } HTTPResponse ClientHTTP1::Send(const HTTPRequest& request) { HTTPRequest prepared = request; - if (prepared.authority.empty()) prepared.authority = DefaultAuthority(host, port); + if (prepared.authority.empty()) { + prepared.authority = DefaultAuthority(host, port, Secure()); + } const std::string wire = HTTP1::SerializeRequest(prepared); const std::string method = prepared.method.empty() ? std::string("GET") : prepared.method; @@ -123,7 +146,7 @@ HTTPResponse ClientHTTP1::Send(const HTTPRequest& request) { // read fails. Nothing is replayed once a response byte has arrived. for (int attempt = 0;; ++attempt) { const bool reused = impl->tcp != nullptr; - if (!reused) impl->Connect(); + if (!reused) impl->Connect(handshakeTimeout); bool received = false; try { diff --git a/implementations/Crafter.Network-ListenerHTTP1.cpp b/implementations/Crafter.Network-ListenerHTTP1.cpp index 45d764e..c61e888 100644 --- a/implementations/Crafter.Network-ListenerHTTP1.cpp +++ b/implementations/Crafter.Network-ListenerHTTP1.cpp @@ -2,7 +2,6 @@ //SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® module; -#include #include #include @@ -12,39 +11,12 @@ import :ListenerTCP; import :ClientTCP; import :HTTP; import :HTTP1; +import :Stream; +import :TLS; import std; using namespace Crafter; -namespace { - enum class ReadStatus { Data, Closed, TimedOut }; - - // Wait for readable data with a deadline, so an idle or stalled peer - // releases the connection thread instead of holding it forever. - ReadStatus ReadSome(int socketid, char* buffer, std::size_t size, - std::chrono::milliseconds timeout, std::size_t& read) { - read = 0; - for (;;) { - pollfd pfd{ .fd = socketid, .events = POLLIN, .revents = 0 }; - const int ready = poll(&pfd, 1, static_cast(timeout.count())); - if (ready < 0) { - if (errno == EINTR) continue; - return ReadStatus::Closed; - } - if (ready == 0) return ReadStatus::TimedOut; - - const auto got = recv(socketid, buffer, size, 0); - if (got < 0) { - if (errno == EINTR) continue; - return ReadStatus::Closed; - } - if (got == 0) return ReadStatus::Closed; - read = static_cast(got); - return ReadStatus::Data; - } - } -} - // One accepted connection: the socket, the thread serving it, and a flag // the accept loop uses to join finished threads without blocking. struct HTTP1Connection { @@ -56,6 +28,9 @@ struct HTTP1Connection { struct ListenerHTTP1::Impl { ListenerHTTP1* owner = nullptr; std::unique_ptr listener; + // Null on a plaintext listener. One context for every connection: the + // certificate and trust store are parsed once, at construction. + std::shared_ptr tls; std::mutex mutex; // Signalled when a connection thread finishes, so Stop() can wait for // the last one instead of polling. @@ -63,6 +38,7 @@ struct ListenerHTTP1::Impl { std::vector> connections; std::atomic running{true}; std::atomic accepted{0}; + std::atomic handshakeFailures{0}; // Join threads whose connection has ended. Called from the accept loop // with `mutex` held, so a connection is never reaped mid-registration. @@ -88,7 +64,13 @@ struct ListenerHTTP1::Impl { this->accepted.fetch_add(1); connection->thread = std::thread([this, pointer] { try { - Serve(*pointer->client); + // The TLS handshake happens here, on the connection's own + // thread, so a peer that stalls halfway through it costs one + // thread rather than the whole accept loop. The stream is + // scoped so it — and its close_notify — go out before the + // socket underneath is released below. + std::unique_ptr stream = Wrap(*pointer->client); + if (stream) Serve(*stream); } catch (...) { // A connection dying must never take the server with it. } @@ -112,8 +94,22 @@ struct ListenerHTTP1::Impl { // would abort the process. } - void Send(ClientTCP& client, const std::string& wire) { - client.Send(wire.data(), static_cast(wire.size())); + // Put the transport on top of the accepted socket. A TLS handshake that + // fails is a normal event on a public port — a scanner, a client with no + // protocol in common, an untrusted client certificate — so it is counted + // and the connection dropped rather than logged or thrown. + std::unique_ptr Wrap(ClientTCP& client) { + if (!tls) return std::make_unique(client.socketid); + try { + return TLSStream::Accept(client.socketid, tls, owner->handshakeTimeout); + } catch (...) { + handshakeFailures.fetch_add(1); + return nullptr; + } + } + + void Send(ByteStream& stream, const std::string& wire) { + stream.Write(wire.data(), wire.size(), owner->requestTimeout); } HTTPResponse Dispatch(const HTTPRequest& request) { @@ -138,8 +134,12 @@ struct ListenerHTTP1::Impl { // Serve one connection until the peer goes away, asks to close, stalls, // or sends something we refuse to parse. - void Serve(ClientTCP& client) { + void Serve(ByteStream& stream) { HTTP1::MessageParser parser(HTTP1::MessageKind::Request, owner->limits); + // Origin-form targets carry no scheme, so the transport supplies it — + // handlers shared with ListenerHTTP see the same "https" they would + // over HTTP/3. + if (stream.Secure()) parser.SetDefaultScheme("https"); std::vector chunk(16 * 1024); bool keepAlive = true; @@ -151,21 +151,21 @@ struct ListenerHTTP1::Impl { const bool idle = parser.AtMessageBoundary(); const auto timeout = idle ? owner->keepAliveTimeout : owner->requestTimeout; std::size_t read = 0; - const ReadStatus status = ReadSome(client.socketid, chunk.data(), chunk.size(), - timeout, read); - if (status == ReadStatus::TimedOut) { + const StreamStatus status = stream.ReadSome(chunk.data(), chunk.size(), + timeout, read); + if (status == StreamStatus::TimedOut) { // An idle keep-alive connection is simply dropped; // a half-sent request earns a 408 first. if (!idle) { try { - Send(client, HTTP1::SerializeResponse( + Send(stream, HTTP1::SerializeResponse( CreateResponseHTTP("408", "Request Timeout"), { .keepAlive = false })); } catch (...) {} } return; } - if (status == ReadStatus::Closed) { + if (status == StreamStatus::Closed) { if (parser.AtMessageBoundary()) return; // clean end of connection parser.Finish(); // throws if truncated closed = true; @@ -174,13 +174,13 @@ struct ListenerHTTP1::Impl { parser.Feed(chunk.data(), read); // The peer is holding its body back until we say go. if (parser.ExpectsContinue()) { - Send(client, HTTP1::SerializeContinue()); + Send(stream, HTTP1::SerializeContinue()); parser.ContinueSent(); } } } catch (const HTTP1::HTTP1ProtocolError& error) { try { - Send(client, HTTP1::SerializeResponse( + Send(stream, HTTP1::SerializeResponse( CreateResponseHTTP("400", std::string(error.what())), { .keepAlive = false })); } catch (...) {} @@ -210,7 +210,7 @@ struct ListenerHTTP1::Impl { } try { - Send(client, HTTP1::SerializeResponse(response, { + Send(stream, HTTP1::SerializeResponse(response, { .keepAlive = keepAlive, // HTTP/1.0 peers only reuse a connection they were told // stays open. @@ -252,11 +252,30 @@ ListenerHTTP1::ListenerHTTP1(std::uint16_t port, }); } +ListenerHTTP1::ListenerHTTP1(std::uint16_t port, + std::unordered_map> routes, + TLSServerCredentials credentials) + : ListenerHTTP1(port, std::move(routes), {}, 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)) { @@ -313,6 +332,14 @@ std::uint64_t ListenerHTTP1::AcceptedCount() const { return impl ? impl->accepted.load() : 0; } +std::uint64_t ListenerHTTP1::HandshakeFailureCount() const { + return impl ? impl->handshakeFailures.load() : 0; +} + +bool ListenerHTTP1::Secure() const noexcept { + return impl && impl->tls != nullptr; +} + ListenerAsyncHTTP1::ListenerAsyncHTTP1(std::uint16_t port, std::unordered_map> routes) : listener(port, std::move(routes)) @@ -326,6 +353,21 @@ ListenerAsyncHTTP1::ListenerAsyncHTTP1(std::uint16_t port, , thread(&ListenerHTTP1::Listen, &listener) {} +ListenerAsyncHTTP1::ListenerAsyncHTTP1(std::uint16_t port, + std::unordered_map> routes, + TLSServerCredentials credentials) + : listener(port, std::move(routes), std::move(credentials)) + , thread(&ListenerHTTP1::Listen, &listener) +{} + +ListenerAsyncHTTP1::ListenerAsyncHTTP1(std::uint16_t port, + std::unordered_map> routes, + std::function fallback, + TLSServerCredentials credentials) + : listener(port, std::move(routes), std::move(fallback), std::move(credentials)) + , thread(&ListenerHTTP1::Listen, &listener) +{} + ListenerAsyncHTTP1::~ListenerAsyncHTTP1() { Stop(); } diff --git a/implementations/Crafter.Network-Stream.cpp b/implementations/Crafter.Network-Stream.cpp new file mode 100644 index 0000000..33e7376 --- /dev/null +++ b/implementations/Crafter.Network-Stream.cpp @@ -0,0 +1,99 @@ +//SPDX-License-Identifier: LGPL-3.0-only +//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +module; +#include +#include +#include +#include + +module Crafter.Network:Stream_impl; +import :Stream; +import std; + +using namespace Crafter; + +void Crafter::SetNonBlocking(int descriptor) { + const int flags = fcntl(descriptor, F_GETFL, 0); + if (flags == -1 || fcntl(descriptor, F_SETFL, flags | O_NONBLOCK) == -1) { + throw std::runtime_error(std::string("could not make the socket non-blocking: ") + + std::strerror(errno)); + } +} + +bool Crafter::PollDescriptor(int descriptor, short events, + std::chrono::steady_clock::time_point deadline) { + for (;;) { + const auto left = std::chrono::duration_cast( + deadline - std::chrono::steady_clock::now()); + // A deadline already in the past still gets one non-blocking look, so + // a zero timeout means "is it ready right now" rather than "give up". + const int wait = left.count() > 0 ? static_cast(left.count()) : 0; + + pollfd descriptors{ .fd = descriptor, .events = events, .revents = 0 }; + const int ready = poll(&descriptors, 1, wait); + if (ready < 0) { + if (errno == EINTR) continue; + throw std::runtime_error(std::string("poll failed: ") + std::strerror(errno)); + } + if (ready == 0) return false; + return true; + } +} + +PlainStream::PlainStream(int descriptor) : descriptor(descriptor) { + SetNonBlocking(descriptor); +} + +StreamStatus PlainStream::ReadSome(char* buffer, std::size_t size, + std::chrono::milliseconds timeout, + std::size_t& read) { + read = 0; + const auto deadline = std::chrono::steady_clock::now() + timeout; + for (;;) { + const auto got = recv(descriptor, buffer, size, 0); + if (got > 0) { + read = static_cast(got); + return StreamStatus::Data; + } + if (got == 0) return StreamStatus::Closed; + if (errno == EINTR) continue; + if (errno == EAGAIN || errno == EWOULDBLOCK) { + if (!PollDescriptor(descriptor, POLLIN, deadline)) return StreamStatus::TimedOut; + continue; + } + // A reset is how a peer that stopped caring shows up; it is an end of + // connection rather than something worth a diagnostic. + if (errno == ECONNRESET) return StreamStatus::Closed; + throw std::runtime_error(std::string("recv failed: ") + std::strerror(errno)); + } +} + +void PlainStream::Write(const void* buffer, std::size_t size, + std::chrono::milliseconds timeout) { + const auto deadline = std::chrono::steady_clock::now() + timeout; + const char* data = reinterpret_cast(buffer); + std::size_t sent = 0; + while (sent < size) { + // MSG_NOSIGNAL: a peer that closed early must surface as EPIPE here, + // not as a SIGPIPE that takes the process down. + const auto wrote = send(descriptor, data + sent, size - sent, MSG_NOSIGNAL); + if (wrote > 0) { + sent += static_cast(wrote); + continue; + } + if (wrote == 0) throw std::runtime_error("the peer closed the connection"); + if (errno == EINTR) continue; + if (errno == EAGAIN || errno == EWOULDBLOCK) { + if (!PollDescriptor(descriptor, POLLOUT, deadline)) { + throw std::runtime_error("timed out writing to the peer"); + } + continue; + } + throw std::runtime_error(std::string("send failed: ") + std::strerror(errno)); + } +} + +void PlainStream::Shutdown() noexcept { + shutdown(descriptor, SHUT_WR); +} diff --git a/implementations/Crafter.Network-TLS.cpp b/implementations/Crafter.Network-TLS.cpp new file mode 100644 index 0000000..deb4c9c --- /dev/null +++ b/implementations/Crafter.Network-TLS.cpp @@ -0,0 +1,669 @@ +//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 + +module Crafter.Network:TLS_impl; +import :TLS; +import :Stream; +import std; + +using namespace Crafter; + +namespace { + // ── 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 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; + 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 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; + // One attempt only: close_notify goes out, and we deliberately do not + // wait for the peer's. Waiting means blocking a teardown path on a peer + // that may never answer, and every framing decision has already been made + // by the time we get here. + ERR_clear_error(); + SSL_shutdown(impl->ssl); + ERR_clear_error(); +} + +int TLSStream::Descriptor() const noexcept { + return impl ? impl->descriptor : -1; +} + +std::string_view TLSStream::Protocol() const noexcept { + return impl ? std::string_view(impl->protocol) : std::string_view(); +} + +std::string TLSStream::Version() const { + if (!impl || impl->ssl == nullptr) return {}; + const char* version = SSL_get_version(impl->ssl); + return version == nullptr ? std::string() : std::string(version); +} + +std::string TLSStream::PeerCertificateSubject() const { + if (!impl || impl->ssl == nullptr) return {}; + OwnedCertificate peer(SSL_get1_peer_certificate(impl->ssl)); + if (!peer) return {}; + char buffer[512] = {}; + X509_NAME_oneline(X509_get_subject_name(peer.get()), buffer, sizeof(buffer)); + return std::string(buffer); +} + +const TLSCertificatePem& Crafter::GetSelfSignedCertificatePem() { + // Generated once per process so every listener presents the same + // certificate: a client that was handed it as a trust anchor keeps + // working across reconnects. + static std::mutex mutex; + static std::optional cached; + std::lock_guard lock(mutex); + if (!cached) cached = MakeSelfSignedCertificate(); + return *cached; +} diff --git a/interfaces/Crafter.Network-ClientHTTP1.cppm b/interfaces/Crafter.Network-ClientHTTP1.cppm index 7244221..87ff2c5 100644 --- a/interfaces/Crafter.Network-ClientHTTP1.cppm +++ b/interfaces/Crafter.Network-ClientHTTP1.cppm @@ -5,13 +5,14 @@ export module Crafter.Network:ClientHTTP1; import std; import :HTTP; import :HTTP1; +import :TLS; #ifndef CRAFTER_NETWORK_BROWSER namespace Crafter { - // HTTP/1.1 client over plain TCP, for peers that cannot speak HTTP/3. - // The request/response types are the ones the HTTP/3 client uses, so - // swapping ClientHTTP for ClientHTTP1 is a one-line change at the call - // site. + // HTTP/1.1 client over TCP, with or without TLS, for peers that cannot + // speak HTTP/3. The request/response types are the ones the HTTP/3 client + // uses, so swapping ClientHTTP for ClientHTTP1 is a one-line change at the + // call site. // // The connection is persistent: the first Send() dials, and later calls // reuse the socket unless the peer asked for it to be closed @@ -25,9 +26,12 @@ namespace Crafter { // Thread-affinity matches ClientHTTP: one ClientHTTP1 serves one caller // at a time; distinct instances are independent. // - // No TLS. This talks `http://`; for an encrypted transport use - // ClientHTTP (HTTP/3 over QUIC), or put a TLS-terminating proxy in - // front of the HTTP/1.1 endpoint. + // `http://` or `https://` is chosen by the constructor: pass + // TLSClientCredentials and every byte goes through libssl (see :TLS), + // leave them out and the transport is plaintext. TLS changes nothing + // above the transport — the same keep-alive, replay and framing rules + // apply, and `authority` still defaults to host:port with the scheme's + // default port elided (443 under TLS, 80 without). export class ClientHTTP1 { public: std::string host; @@ -36,13 +40,21 @@ namespace Crafter { ClientHTTP1(const char* host, std::uint16_t port); ClientHTTP1(std::string host, std::uint16_t port); + // https://. The credentials verify the server's certificate chain and + // its name against `host` by default; see TLSClientCredentials for + // self-signed peers, private trust anchors and client certificates. + // Throws TLSException if the certificate is rejected. + ClientHTTP1(const char* host, std::uint16_t port, TLSClientCredentials credentials); + ClientHTTP1(std::string host, std::uint16_t port, TLSClientCredentials credentials); + ~ClientHTTP1(); ClientHTTP1(const ClientHTTP1&) = delete; ClientHTTP1(ClientHTTP1&&) noexcept; // Send a request and read the full response. `authority` defaults to - // the host:port this client was constructed with; `scheme` is - // ignored (the transport is plaintext). + // the host:port this client was constructed with; `scheme` is ignored + // — HTTP/1.1 request targets are origin-form and the transport was + // already decided by the constructor. HTTPResponse Send(const HTTPRequest& request); // Send a request and deliver the response (or the error text) via @@ -55,6 +67,14 @@ namespace Crafter { // tests asserting that keep-alive actually kept the socket. bool Connected() const noexcept; + // Whether this client speaks https://. + bool Secure() const noexcept; + + // ALPN protocol the last connection negotiated, empty for plaintext + // or when the server offered no ALPN. For an https:// client with the + // default credentials this is "http/1.1". + std::string_view Protocol() const noexcept; + // Drop the pooled connection; the next Send() dials again. void Disconnect(); @@ -63,6 +83,10 @@ namespace Crafter { // How long to wait for the next piece of a response before giving // up on a server that accepted the connection and then went quiet. std::chrono::milliseconds timeout{30000}; + // How long the TLS handshake may take, on an https:// client. Separate + // from `timeout` because it covers a multi-round-trip exchange before + // any request has been written. + std::chrono::milliseconds handshakeTimeout{15000}; private: struct Impl; diff --git a/interfaces/Crafter.Network-HTTP1.cppm b/interfaces/Crafter.Network-HTTP1.cppm index db84c02..a4a62cb 100644 --- a/interfaces/Crafter.Network-HTTP1.cppm +++ b/interfaces/Crafter.Network-HTTP1.cppm @@ -299,6 +299,14 @@ namespace Crafter::HTTP1 { headRequest = EqualsIgnoreCase(requestMethod, "HEAD"); } + // Scheme reported for origin-form request targets, which carry none of + // their own. Only the transport knows — "https" once TLS is + // terminating the connection. An absolute-form target still wins, as + // it names its own scheme. Survives Reset(). + void SetDefaultScheme(std::string scheme) { + defaultScheme = std::move(scheme); + } + void Feed(const char* data, std::size_t size) { if (size != 0) buffer.append(data, size); Advance(); @@ -340,7 +348,7 @@ namespace Crafter::HTTP1 { HTTPRequest request; request.method = std::move(method); request.path = std::move(path); - request.scheme = scheme.empty() ? std::string("http") : std::move(scheme); + request.scheme = scheme.empty() ? defaultScheme : std::move(scheme); // Absolute-form targets carry their own authority and win over // Host (RFC 9112 §3.2.2); otherwise Host supplies it. Either way // it lives in the named field, matching the HTTP/3 shape, so it @@ -759,6 +767,10 @@ namespace Crafter::HTTP1 { std::string method; std::string path; std::string scheme; + // Not cleared by Reset(): the transport does not change under a + // connection, so it is told to the parser once and applies to every + // request on it. + std::string defaultScheme = "http"; std::string authority; std::string version; std::string status; diff --git a/interfaces/Crafter.Network-ListenerHTTP1.cppm b/interfaces/Crafter.Network-ListenerHTTP1.cppm index 7f2454e..69ba51c 100644 --- a/interfaces/Crafter.Network-ListenerHTTP1.cppm +++ b/interfaces/Crafter.Network-ListenerHTTP1.cppm @@ -5,12 +5,13 @@ export module Crafter.Network:ListenerHTTP1; import std; import :HTTP; import :HTTP1; +import :TLS; #ifndef CRAFTER_NETWORK_BROWSER namespace Crafter { - // HTTP/1.1 server over plain TCP. Same route map and `fallback` shape as - // ListenerHTTP, so a handler can be registered with both and served over - // either protocol. + // HTTP/1.1 server over TCP, plaintext or TLS. Same route map and + // `fallback` shape as ListenerHTTP, so a handler can be registered with + // both and served over either protocol. // // Each accepted connection gets its own thread and is served // sequentially until the peer closes it, `Connection: close` is seen, or @@ -18,13 +19,16 @@ namespace Crafter { // across connections but never for two requests on the same one, which // is what HTTP/1.1 response ordering requires. A dedicated thread rather // than a ThreadPool task is deliberate: keep-alive connections are idle - // most of their life and would otherwise pin every pool thread. + // most of their life and would otherwise pin every pool thread. The TLS + // handshake runs on that same per-connection thread, so a peer that + // stalls mid-handshake cannot hold up the accept loop either. // - // Implemented: keep-alive and pipelining, content-length and chunked - // request bodies, `Expect: 100-continue`, HEAD (headers only), automatic - // `Date`, and 400/404/500 error responses. Not implemented: TLS, - // CONNECT tunnels, Upgrade, and chunked *responses* (handlers return a - // complete body, so responses are always content-length framed). + // Implemented: TLS via libssl (pass TLSServerCredentials), keep-alive and + // pipelining, content-length and chunked request bodies, `Expect: + // 100-continue`, HEAD (headers only), automatic `Date`, and 400/404/500 + // error responses. Not implemented: CONNECT tunnels, Upgrade, and chunked + // *responses* (handlers return a complete body, so responses are always + // content-length framed). export class ListenerHTTP1 { public: std::unordered_map> routes; @@ -45,6 +49,9 @@ namespace Crafter { // against a peer holding a thread forever. std::chrono::milliseconds keepAliveTimeout{15000}; std::chrono::milliseconds requestTimeout{30000}; + // How long a TLS handshake may take, on an https:// listener. A peer + // that connects and then says nothing is dropped after this. + std::chrono::milliseconds handshakeTimeout{15000}; // Limits applied to incoming requests. HTTP1::MessageLimits limits; @@ -55,6 +62,21 @@ namespace Crafter { std::unordered_map> routes, std::function fallback); + // https://. Every accepted connection is wrapped in TLS before a byte + // of HTTP is read; requests reach handlers with `scheme` set to + // "https". Throws TLSException from the constructor if the credentials + // do not yield a usable certificate, so a misconfigured server never + // reaches the point of listening. + ListenerHTTP1(std::uint16_t port, + std::unordered_map> routes, + TLSServerCredentials credentials); + + // TLS plus a fallback handler. + ListenerHTTP1(std::uint16_t port, + std::unordered_map> routes, + std::function fallback, + TLSServerCredentials credentials); + ~ListenerHTTP1(); ListenerHTTP1(const ListenerHTTP1&) = delete; ListenerHTTP1(ListenerHTTP1&&) noexcept; @@ -69,6 +91,13 @@ namespace Crafter { std::size_t ConnectionCount() const; // Connections accepted since construction. std::uint64_t AcceptedCount() const; + // Connections that were accepted but never got as far as HTTP because + // the TLS handshake failed — an untrusted client certificate, a peer + // with no protocol in common, a port scanner. Always 0 on a plaintext + // listener. + std::uint64_t HandshakeFailureCount() const; + // Whether this listener speaks https://. + bool Secure() const noexcept; private: struct Impl; @@ -92,6 +121,15 @@ namespace Crafter { std::unordered_map> routes, std::function fallback); + // TLS, with and without a fallback handler. + ListenerAsyncHTTP1(std::uint16_t port, + std::unordered_map> routes, + TLSServerCredentials credentials); + ListenerAsyncHTTP1(std::uint16_t port, + std::unordered_map> routes, + std::function fallback, + TLSServerCredentials credentials); + ~ListenerAsyncHTTP1(); void Stop(); }; diff --git a/interfaces/Crafter.Network-Stream.cppm b/interfaces/Crafter.Network-Stream.cppm new file mode 100644 index 0000000..a583094 --- /dev/null +++ b/interfaces/Crafter.Network-Stream.cppm @@ -0,0 +1,95 @@ +//SPDX-License-Identifier: LGPL-3.0-only +//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +export module Crafter.Network:Stream; +import std; + +#ifndef CRAFTER_NETWORK_BROWSER +namespace Crafter { + // A reliable, ordered byte stream with deadlines on both directions. + // + // This exists so the HTTP/1.1 client and listener can be written once and + // run over either a bare socket or a TLS session: `PlainStream` below is + // the `http://` transport, `TLSStream` (in :TLS) the `https://` one. The + // HTTP/1.1 code holds a `ByteStream&` and never learns which it has. + // + // Every method takes its own timeout rather than the stream carrying one, + // because HTTP/1.1 uses different budgets for different states — a long + // idle keep-alive wait, a shorter one once a request has started. + export enum class StreamStatus { + Data, // `read` bytes are available in the buffer + Closed, // the peer closed its send side, cleanly + TimedOut, // nothing arrived before the deadline + }; + + export class ByteStream { + public: + virtual ~ByteStream() = default; + ByteStream() = default; + ByteStream(const ByteStream&) = delete; + ByteStream& operator=(const ByteStream&) = delete; + + // Read whatever is already available, waiting at most `timeout` for + // the first byte. Sets `read` and returns Data, or reports a clean + // close / a timeout. Throws on a transport error. + virtual StreamStatus ReadSome(char* buffer, std::size_t size, + std::chrono::milliseconds timeout, + std::size_t& read) = 0; + + // Write the whole buffer. Throws if it could not all be handed over + // within `timeout`. + virtual void Write(const void* buffer, std::size_t size, + std::chrono::milliseconds timeout) = 0; + + // Best-effort orderly close of our send side. Never throws — it runs + // on teardown paths where there is nothing useful to do with a + // failure. + virtual void Shutdown() noexcept = 0; + + // The underlying descriptor, so a listener can shutdown(2) it to wake + // a thread parked in poll(). + virtual int Descriptor() const noexcept = 0; + + // Negotiated ALPN protocol, empty when the transport has no notion of + // one (plaintext) or nothing was agreed. + virtual std::string_view Protocol() const noexcept { return {}; } + + // Whether the bytes are encrypted on the wire. + virtual bool Secure() const noexcept { return false; } + }; + + // Plaintext TCP. Non-owning: the descriptor stays owned by the ClientTCP + // (or whatever else) that opened it. + // + // The descriptor is switched to non-blocking on construction — both + // directions are driven by poll() against a deadline, which a blocking + // descriptor cannot express. That is also what lets a write time out + // instead of parking forever against a peer that has stopped reading. + export class PlainStream final : public ByteStream { + public: + explicit PlainStream(int descriptor); + + StreamStatus ReadSome(char* buffer, std::size_t size, + std::chrono::milliseconds timeout, + std::size_t& read) override; + void Write(const void* buffer, std::size_t size, + std::chrono::milliseconds timeout) override; + void Shutdown() noexcept override; + int Descriptor() const noexcept override { return descriptor; } + + private: + int descriptor; + }; + + // Put a descriptor into non-blocking mode. Exposed because :TLS needs the + // same thing for the descriptor it wraps. + export void SetNonBlocking(int descriptor); + + // Wait until `descriptor` is ready for `events` (a poll(2) event mask) or + // `deadline` passes; false means the deadline won. Retries across EINTR + // and throws on a real poll failure. Shared with :TLS, which has to poll + // for whichever direction OpenSSL asks for next. + export bool PollDescriptor(int descriptor, short events, + std::chrono::steady_clock::time_point deadline); +} +#endif diff --git a/interfaces/Crafter.Network-TLS.cppm b/interfaces/Crafter.Network-TLS.cppm new file mode 100644 index 0000000..219e05b --- /dev/null +++ b/interfaces/Crafter.Network-TLS.cppm @@ -0,0 +1,179 @@ +//SPDX-License-Identifier: LGPL-3.0-only +//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +export module Crafter.Network:TLS; +import std; +import :Stream; + +#ifndef CRAFTER_NETWORK_BROWSER +namespace Crafter { + // TLS over a TCP socket, via libssl (OpenSSL 3). This is the transport + // that turns `ClientHTTP1`/`ListenerHTTP1` into `https://` endpoints; it + // is deliberately protocol-agnostic, so anything else that owns a + // connected descriptor can wrap it the same way. + // + // No OpenSSL type appears below: the SSL_CTX and SSL live behind the Impl + // pointers, so importing this partition does not drag into + // the consumer. TLS 1.2 is the floor, the platform's cipher defaults are + // used unchanged, and renegotiation is left to OpenSSL's own policy. + export class TLSException : public std::runtime_error { + public: + using std::runtime_error::runtime_error; + }; + + // A certificate and its private key, PEM-encoded. + export struct TLSCertificatePem { + std::string certificate; + std::string privateKey; + }; + + // The certificate the server presents. Exactly one source is used, in + // this order: certPath/keyPath, then certPem/keyPem, then selfSigned. + // + // selfSigned generates an ephemeral in-memory certificate (see + // GetSelfSignedCertificatePem) — for development, tests and LAN use. A + // client talking to it needs either insecureNoServerValidation or the + // certificate itself as a trust anchor. + export struct TLSServerCredentials { + // PEM files on disk. certPath may hold a chain (leaf first). + std::string certPath; + std::string keyPath; + // The same material inline, for callers that hold it in memory + // already (a secret store, a test) and would rather not touch disk. + std::string certPem; + std::string keyPem; + bool selfSigned = false; + + // Mutual TLS. With requireClientCertificate set, a peer that presents + // no certificate — or one that does not chain to clientCaPath — is + // rejected during the handshake. clientCaPath is a PEM file or a + // directory of them; when it is empty the system trust store is used. + std::string clientCaPath; + bool requireClientCertificate = false; + + // Protocols we are willing to speak, in server preference order. A + // client that offers ALPN and none of these is rejected with + // no_application_protocol (RFC 7301 §3.2) rather than being let + // through to speak something we cannot parse. A client that offers no + // ALPN at all is accepted — plenty of tooling still does not send it. + std::vector alpnProtocols = { "http/1.1" }; + }; + + // How the client checks the server, and what it presents itself. + // + // The default verifies the chain against the system trust store *and* the + // hostname, which is the only combination that is actually safe; a chain + // check without a name check accepts any valid certificate for any name. + export struct TLSClientCredentials { + // Skip both checks. Development only — it accepts any certificate, + // including an attacker's. + bool insecureNoServerValidation = false; + + // Extra trust anchor: a PEM file or a directory of them. Added to the + // system store rather than replacing it. This is how you talk to a + // self-signed listener without giving up verification — hand the + // client the server's certificate. + std::string caPath; + // The same, inline. + std::string caPem; + + // Overrides the name used for SNI and hostname verification. Empty + // means the host being connected to, which is what you want unless + // you are dialling an address that differs from the certificate name + // (a tunnel, a pinned IP). + std::string serverName; + + // Client certificate for mutual TLS. Ignored when the server does not + // ask for one. + std::string certPath; + std::string keyPath; + + // Protocols to offer, in client preference order. Empty sends no ALPN + // extension at all. + std::vector alpnProtocols = { "http/1.1" }; + }; + + // A configured SSL_CTX. Shared by every connection it produces — one per + // listener, one per client — because the expensive parts (parsing the + // certificate, loading the trust store) are per-context, and OpenSSL 3 + // lets an SSL_CTX be used concurrently from many threads. + // + // Held by shared_ptr: a TLSStream keeps its context alive, so a listener + // that goes away mid-connection does not pull the configuration out from + // under a session still using it. + export class TLSContext { + public: + static std::shared_ptr Server(const TLSServerCredentials& credentials); + static std::shared_ptr Client(const TLSClientCredentials& credentials); + + ~TLSContext(); + TLSContext(const TLSContext&) = delete; + TLSContext& operator=(const TLSContext&) = delete; + + private: + TLSContext(); + struct Impl; + std::unique_ptr impl; + friend class TLSStream; + }; + + // A TLS session over an already-connected descriptor. Non-owning, like + // PlainStream: the descriptor stays owned by its ClientTCP, and this only + // adds the record layer on top. + // + // Both factories complete the handshake before returning, so a stream you + // hold is a stream you can write to. They throw TLSException on + // certificate rejection, on a protocol mismatch, and on a peer that stops + // answering mid-handshake. + export class TLSStream final : public ByteStream { + public: + // Client side. `hostName` drives SNI and hostname verification unless + // the credentials overrode it with serverName; an IP literal sets no + // SNI (RFC 6066 forbids it) and is checked against the certificate's + // iPAddress SANs instead. + static std::unique_ptr Connect(int descriptor, + std::shared_ptr context, + const std::string& hostName, + std::chrono::milliseconds timeout); + + // Server side, on a descriptor accept(2) just handed us. + static std::unique_ptr Accept(int descriptor, + std::shared_ptr context, + std::chrono::milliseconds timeout); + + ~TLSStream() override; + + StreamStatus ReadSome(char* buffer, std::size_t size, + std::chrono::milliseconds timeout, + std::size_t& read) override; + void Write(const void* buffer, std::size_t size, + std::chrono::milliseconds timeout) override; + void Shutdown() noexcept override; + int Descriptor() const noexcept override; + std::string_view Protocol() const noexcept override; + bool Secure() const noexcept override { return true; } + + // The negotiated protocol version, e.g. "TLSv1.3". For logging. + std::string Version() const; + // One-line subject of the peer's certificate, empty when it presented + // none. With requireClientCertificate a non-empty value is the + // authenticated client identity. + std::string PeerCertificateSubject() const; + + private: + TLSStream(); + struct Impl; + std::unique_ptr impl; + }; + + // The process-wide ephemeral self-signed certificate used by + // TLSServerCredentials{selfSigned=true}, in PEM form. Generated on first + // call and then cached, so every listener in a process presents the same + // certificate and a client can be handed it as a trust anchor. + // + // ECDSA P-256, CN=localhost, SAN {DNS:localhost, IP:127.0.0.1, IP:::1}, + // valid for 10 days. Development and tests only — it is regenerated on + // every process start and no peer has any reason to trust it. + export const TLSCertificatePem& GetSelfSignedCertificatePem(); +} +#endif diff --git a/interfaces/Crafter.Network.cppm b/interfaces/Crafter.Network.cppm index 3ea16b9..92cd7a2 100755 --- a/interfaces/Crafter.Network.cppm +++ b/interfaces/Crafter.Network.cppm @@ -21,6 +21,12 @@ export import :HTTP3; // in the browser this job is already done by fetch() behind :ClientHTTP, // and these partitions use exceptions and POSIX sockets. export import :HTTP1; +// The byte-stream abstraction the HTTP/1.1 endpoints run over, and the libssl +// TLS transport that turns them into https://. Exported so callers can build +// credentials, and so anything else holding a connected socket can wrap it the +// same way. +export import :Stream; +export import :TLS; export import :ClientHTTP1; export import :ListenerHTTP1; #endif \ No newline at end of file diff --git a/project.cpp b/project.cpp index c8180e2..6b2d238 100644 --- a/project.cpp +++ b/project.cpp @@ -7,7 +7,7 @@ namespace fs = std::filesystem; using namespace Crafter; extern "C" Configuration CrafterBuildProject(std::span args) { - constexpr std::array networkInterfaces = { + constexpr std::array networkInterfaces = { "interfaces/Crafter.Network", "interfaces/Crafter.Network-ClientTCP", "interfaces/Crafter.Network-ListenerTCP", @@ -15,6 +15,8 @@ extern "C" Configuration CrafterBuildProject(std::span a "interfaces/Crafter.Network-ListenerHTTP", "interfaces/Crafter.Network-HTTP", "interfaces/Crafter.Network-HTTP1", + "interfaces/Crafter.Network-Stream", + "interfaces/Crafter.Network-TLS", "interfaces/Crafter.Network-ClientHTTP1", "interfaces/Crafter.Network-ListenerHTTP1", "interfaces/Crafter.Network-HTTP3", @@ -72,11 +74,13 @@ extern "C" Configuration CrafterBuildProject(std::span a return cfg; } - constexpr std::array networkImplementations = { + constexpr std::array networkImplementations = { "implementations/Crafter.Network-ClientTCP", "implementations/Crafter.Network-ListenerTCP", "implementations/Crafter.Network-ClientHTTP", "implementations/Crafter.Network-ListenerHTTP", + "implementations/Crafter.Network-Stream", + "implementations/Crafter.Network-TLS", "implementations/Crafter.Network-ClientHTTP1", "implementations/Crafter.Network-ListenerHTTP1", "implementations/Crafter.Network-ClientQUIC", @@ -108,9 +112,18 @@ extern "C" Configuration CrafterBuildProject(std::span a // linker at the actual output location. msquic.libDirs = { "bin/Release" }; msquic.libs = { "msquic" }; - std::array ifaces; + + // libssl/libcrypto — the TLS transport behind :TLS, i.e. https:// on the + // HTTP/1.1 client and listener. A system package rather than a built + // external: OpenSSL 3 is on every platform we target, and building it here + // would mean shipping a second TLS stack alongside the one msquic already + // links (quictls, which keeps its symbols to itself inside libmsquic.so). + cfg.linkFlags.push_back("-lssl"); + cfg.linkFlags.push_back("-lcrypto"); + + std::array ifaces; std::ranges::copy(networkInterfaces, ifaces.begin()); - std::array impls; + std::array impls; std::ranges::copy(networkImplementations, impls.begin()); cfg.GetInterfacesAndImplementations(ifaces, impls);