https #6
11 changed files with 1299 additions and 99 deletions
feat(tls): add a libssl TLS transport and an https:// HTTP/1.1 stack
HTTP/1.1 was plaintext-only, which left `https://` to either an HTTP/3
listener or a terminating proxy in front. Neither helps the callers this
stack exists for — curl scripts, CI tooling, old proxies — so wrap the
transport in libssl instead.
Two new partitions:
:Stream a ByteStream with per-call deadlines on both directions, plus
the plaintext socket implementation. The HTTP/1.1 client and
listener now hold a ByteStream& and never learn which
transport they have, which is what lets one code path serve
both schemes.
:TLS TLSContext/TLSStream over OpenSSL 3, with credentials for both
roles: chain and hostname verification on by default, private
trust anchors, client certificates, mutual TLS, ALPN, and an
in-process self-signed certificate for development.
Both descriptors go non-blocking and every read and write is driven by
poll() against a deadline. That is required for TLS — a blocking
descriptor cannot express a handshake timeout — and it means a plaintext
write can now time out too, instead of parking forever against a peer
that stopped reading.
ClientHTTP1 and ListenerHTTP1 gain credential-taking constructors; the
existing ones still speak http://. The listener handshakes on the
connection's own thread, so a peer that stalls mid-handshake costs one
thread rather than the accept loop, and a failed handshake is counted
rather than logged — on a public port it is ordinary traffic.
MessageParser gains SetDefaultScheme so origin-form targets report the
scheme the transport actually used; handlers shared with ListenerHTTP now
see the same "https" they would over HTTP/3.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
commit
9c22cbe09e
|
|
@ -2,8 +2,6 @@
|
||||||
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||||
|
|
||||||
module;
|
module;
|
||||||
#include <poll.h>
|
|
||||||
#include <sys/socket.h>
|
|
||||||
#include <cerrno>
|
#include <cerrno>
|
||||||
|
|
||||||
module Crafter.Network:ClientHTTP1_impl;
|
module Crafter.Network:ClientHTTP1_impl;
|
||||||
|
|
@ -11,41 +9,19 @@ import :ClientHTTP1;
|
||||||
import :ClientTCP;
|
import :ClientTCP;
|
||||||
import :HTTP;
|
import :HTTP;
|
||||||
import :HTTP1;
|
import :HTTP1;
|
||||||
|
import :Stream;
|
||||||
|
import :TLS;
|
||||||
import Crafter.Thread;
|
import Crafter.Thread;
|
||||||
import std;
|
import std;
|
||||||
|
|
||||||
using namespace Crafter;
|
using namespace Crafter;
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
// Read whatever is available, waiting at most `timeout`. Returns 0 on a
|
|
||||||
// clean close by the peer. ClientTCP::RecieveSync() can't express a
|
|
||||||
// timeout — and a server that accepts the connection and then says
|
|
||||||
// nothing would hang Send() forever — so the read is done here.
|
|
||||||
std::size_t ReadSome(int socketid, char* buffer, std::size_t size,
|
|
||||||
std::chrono::milliseconds timeout) {
|
|
||||||
for (;;) {
|
|
||||||
pollfd pfd{ .fd = socketid, .events = POLLIN, .revents = 0 };
|
|
||||||
const int ready = poll(&pfd, 1, static_cast<int>(timeout.count()));
|
|
||||||
if (ready < 0) {
|
|
||||||
if (errno == EINTR) continue;
|
|
||||||
throw std::runtime_error(std::string("poll failed: ") + std::strerror(errno));
|
|
||||||
}
|
|
||||||
if (ready == 0) throw std::runtime_error("timed out waiting for the response");
|
|
||||||
|
|
||||||
const auto read = recv(socketid, buffer, size, 0);
|
|
||||||
if (read < 0) {
|
|
||||||
if (errno == EINTR) continue;
|
|
||||||
throw std::runtime_error(std::string("recv failed: ") + std::strerror(errno));
|
|
||||||
}
|
|
||||||
return static_cast<std::size_t>(read);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Host header value. The port is elided when it is the scheme default,
|
// 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
|
// 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.
|
// virtual-host matching on the far side tends to expect.
|
||||||
std::string DefaultAuthority(const std::string& host, std::uint16_t port) {
|
std::string DefaultAuthority(const std::string& host, std::uint16_t port, bool secure) {
|
||||||
if (port == 80) return host;
|
if (port == (secure ? 443 : 80)) return host;
|
||||||
return host + ":" + std::to_string(port);
|
return host + ":" + std::to_string(port);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -53,13 +29,34 @@ namespace {
|
||||||
struct ClientHTTP1::Impl {
|
struct ClientHTTP1::Impl {
|
||||||
std::string host;
|
std::string host;
|
||||||
std::uint16_t port;
|
std::uint16_t port;
|
||||||
|
// Null for plaintext; shared by every connection this client dials, so the
|
||||||
|
// trust store is parsed once rather than per redial.
|
||||||
|
std::shared_ptr<TLSContext> tls;
|
||||||
std::unique_ptr<ClientTCP> tcp;
|
std::unique_ptr<ClientTCP> tcp;
|
||||||
|
// Sits on top of `tcp` and must therefore be destroyed before it.
|
||||||
|
std::unique_ptr<ByteStream> stream;
|
||||||
|
std::string protocol;
|
||||||
|
|
||||||
void Connect() {
|
void Connect(std::chrono::milliseconds handshakeTimeout) {
|
||||||
tcp = std::make_unique<ClientTCP>(host, port);
|
tcp = std::make_unique<ClientTCP>(host, port);
|
||||||
|
try {
|
||||||
|
if (tls) {
|
||||||
|
stream = TLSStream::Connect(tcp->socketid, tls, host, handshakeTimeout);
|
||||||
|
} else {
|
||||||
|
stream = std::make_unique<PlainStream>(tcp->socketid);
|
||||||
|
}
|
||||||
|
} catch (...) {
|
||||||
|
// A half-built connection must not be left pooled: the next Send()
|
||||||
|
// would treat it as reusable and read from a socket with no
|
||||||
|
// session on it.
|
||||||
|
tcp.reset();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
protocol = std::string(stream->Protocol());
|
||||||
}
|
}
|
||||||
|
|
||||||
void Close() {
|
void Close() {
|
||||||
|
stream.reset();
|
||||||
tcp.reset();
|
tcp.reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -69,14 +66,19 @@ struct ClientHTTP1::Impl {
|
||||||
HTTPResponse Exchange(const std::string& wire, std::string_view method,
|
HTTPResponse Exchange(const std::string& wire, std::string_view method,
|
||||||
const HTTP1::MessageLimits& limits,
|
const HTTP1::MessageLimits& limits,
|
||||||
std::chrono::milliseconds timeout, bool& received) {
|
std::chrono::milliseconds timeout, bool& received) {
|
||||||
tcp->Send(wire.data(), static_cast<std::uint32_t>(wire.size()));
|
stream->Write(wire.data(), wire.size(), timeout);
|
||||||
|
|
||||||
HTTP1::MessageParser parser(HTTP1::MessageKind::Response, limits);
|
HTTP1::MessageParser parser(HTTP1::MessageKind::Response, limits);
|
||||||
parser.SetRequestMethod(method);
|
parser.SetRequestMethod(method);
|
||||||
std::vector<char> chunk(16 * 1024);
|
std::vector<char> chunk(16 * 1024);
|
||||||
while (!parser.Complete()) {
|
while (!parser.Complete()) {
|
||||||
const std::size_t read = ReadSome(tcp->socketid, chunk.data(), chunk.size(), timeout);
|
std::size_t read = 0;
|
||||||
if (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;
|
// Peer closed. Completes a response framed by close;
|
||||||
// anything else throws out of Finish().
|
// anything else throws out of Finish().
|
||||||
parser.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::ClientHTTP1(std::string host, std::uint16_t port)
|
||||||
: ClientHTTP1(host.c_str(), port) {}
|
: ClientHTTP1(host.c_str(), port) {}
|
||||||
|
|
||||||
|
ClientHTTP1::ClientHTTP1(const char* host, std::uint16_t port, TLSClientCredentials credentials)
|
||||||
|
: host(host), port(port), impl(std::make_unique<Impl>(std::string(host), port)) {
|
||||||
|
// Built here rather than on first Send() so bad credentials — an
|
||||||
|
// unreadable certificate, a trust anchor that is not a certificate —
|
||||||
|
// surface at construction, where the caller is still looking.
|
||||||
|
impl->tls = TLSContext::Client(credentials);
|
||||||
|
}
|
||||||
|
|
||||||
|
ClientHTTP1::ClientHTTP1(std::string host, std::uint16_t port, TLSClientCredentials credentials)
|
||||||
|
: ClientHTTP1(host.c_str(), port, std::move(credentials)) {}
|
||||||
|
|
||||||
ClientHTTP1::ClientHTTP1(ClientHTTP1&&) noexcept = default;
|
ClientHTTP1::ClientHTTP1(ClientHTTP1&&) noexcept = default;
|
||||||
ClientHTTP1::~ClientHTTP1() = default;
|
ClientHTTP1::~ClientHTTP1() = default;
|
||||||
|
|
||||||
|
|
@ -107,13 +120,23 @@ bool ClientHTTP1::Connected() const noexcept {
|
||||||
return impl && impl->tcp != nullptr;
|
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() {
|
void ClientHTTP1::Disconnect() {
|
||||||
if (impl) impl->Close();
|
if (impl) impl->Close();
|
||||||
}
|
}
|
||||||
|
|
||||||
HTTPResponse ClientHTTP1::Send(const HTTPRequest& request) {
|
HTTPResponse ClientHTTP1::Send(const HTTPRequest& request) {
|
||||||
HTTPRequest prepared = 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 wire = HTTP1::SerializeRequest(prepared);
|
||||||
const std::string method = prepared.method.empty() ? std::string("GET") : prepared.method;
|
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.
|
// read fails. Nothing is replayed once a response byte has arrived.
|
||||||
for (int attempt = 0;; ++attempt) {
|
for (int attempt = 0;; ++attempt) {
|
||||||
const bool reused = impl->tcp != nullptr;
|
const bool reused = impl->tcp != nullptr;
|
||||||
if (!reused) impl->Connect();
|
if (!reused) impl->Connect(handshakeTimeout);
|
||||||
|
|
||||||
bool received = false;
|
bool received = false;
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||||
|
|
||||||
module;
|
module;
|
||||||
#include <poll.h>
|
|
||||||
#include <sys/socket.h>
|
#include <sys/socket.h>
|
||||||
#include <cerrno>
|
#include <cerrno>
|
||||||
|
|
||||||
|
|
@ -12,39 +11,12 @@ import :ListenerTCP;
|
||||||
import :ClientTCP;
|
import :ClientTCP;
|
||||||
import :HTTP;
|
import :HTTP;
|
||||||
import :HTTP1;
|
import :HTTP1;
|
||||||
|
import :Stream;
|
||||||
|
import :TLS;
|
||||||
import std;
|
import std;
|
||||||
|
|
||||||
using namespace Crafter;
|
using namespace Crafter;
|
||||||
|
|
||||||
namespace {
|
|
||||||
enum class ReadStatus { Data, Closed, TimedOut };
|
|
||||||
|
|
||||||
// Wait for readable data with a deadline, so an idle or stalled peer
|
|
||||||
// releases the connection thread instead of holding it forever.
|
|
||||||
ReadStatus ReadSome(int socketid, char* buffer, std::size_t size,
|
|
||||||
std::chrono::milliseconds timeout, std::size_t& read) {
|
|
||||||
read = 0;
|
|
||||||
for (;;) {
|
|
||||||
pollfd pfd{ .fd = socketid, .events = POLLIN, .revents = 0 };
|
|
||||||
const int ready = poll(&pfd, 1, static_cast<int>(timeout.count()));
|
|
||||||
if (ready < 0) {
|
|
||||||
if (errno == EINTR) continue;
|
|
||||||
return ReadStatus::Closed;
|
|
||||||
}
|
|
||||||
if (ready == 0) return ReadStatus::TimedOut;
|
|
||||||
|
|
||||||
const auto got = recv(socketid, buffer, size, 0);
|
|
||||||
if (got < 0) {
|
|
||||||
if (errno == EINTR) continue;
|
|
||||||
return ReadStatus::Closed;
|
|
||||||
}
|
|
||||||
if (got == 0) return ReadStatus::Closed;
|
|
||||||
read = static_cast<std::size_t>(got);
|
|
||||||
return ReadStatus::Data;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// One accepted connection: the socket, the thread serving it, and a flag
|
// One accepted connection: the socket, the thread serving it, and a flag
|
||||||
// the accept loop uses to join finished threads without blocking.
|
// the accept loop uses to join finished threads without blocking.
|
||||||
struct HTTP1Connection {
|
struct HTTP1Connection {
|
||||||
|
|
@ -56,6 +28,9 @@ struct HTTP1Connection {
|
||||||
struct ListenerHTTP1::Impl {
|
struct ListenerHTTP1::Impl {
|
||||||
ListenerHTTP1* owner = nullptr;
|
ListenerHTTP1* owner = nullptr;
|
||||||
std::unique_ptr<ListenerTCP> listener;
|
std::unique_ptr<ListenerTCP> listener;
|
||||||
|
// Null on a plaintext listener. One context for every connection: the
|
||||||
|
// certificate and trust store are parsed once, at construction.
|
||||||
|
std::shared_ptr<TLSContext> tls;
|
||||||
std::mutex mutex;
|
std::mutex mutex;
|
||||||
// Signalled when a connection thread finishes, so Stop() can wait for
|
// Signalled when a connection thread finishes, so Stop() can wait for
|
||||||
// the last one instead of polling.
|
// the last one instead of polling.
|
||||||
|
|
@ -63,6 +38,7 @@ struct ListenerHTTP1::Impl {
|
||||||
std::vector<std::unique_ptr<HTTP1Connection>> connections;
|
std::vector<std::unique_ptr<HTTP1Connection>> connections;
|
||||||
std::atomic<bool> running{true};
|
std::atomic<bool> running{true};
|
||||||
std::atomic<std::uint64_t> accepted{0};
|
std::atomic<std::uint64_t> accepted{0};
|
||||||
|
std::atomic<std::uint64_t> handshakeFailures{0};
|
||||||
|
|
||||||
// Join threads whose connection has ended. Called from the accept loop
|
// Join threads whose connection has ended. Called from the accept loop
|
||||||
// with `mutex` held, so a connection is never reaped mid-registration.
|
// with `mutex` held, so a connection is never reaped mid-registration.
|
||||||
|
|
@ -88,7 +64,13 @@ struct ListenerHTTP1::Impl {
|
||||||
this->accepted.fetch_add(1);
|
this->accepted.fetch_add(1);
|
||||||
connection->thread = std::thread([this, pointer] {
|
connection->thread = std::thread([this, pointer] {
|
||||||
try {
|
try {
|
||||||
Serve(*pointer->client);
|
// The TLS handshake happens here, on the connection's own
|
||||||
|
// thread, so a peer that stalls halfway through it costs one
|
||||||
|
// thread rather than the whole accept loop. The stream is
|
||||||
|
// scoped so it — and its close_notify — go out before the
|
||||||
|
// socket underneath is released below.
|
||||||
|
std::unique_ptr<ByteStream> stream = Wrap(*pointer->client);
|
||||||
|
if (stream) Serve(*stream);
|
||||||
} catch (...) {
|
} catch (...) {
|
||||||
// A connection dying must never take the server with it.
|
// A connection dying must never take the server with it.
|
||||||
}
|
}
|
||||||
|
|
@ -112,8 +94,22 @@ struct ListenerHTTP1::Impl {
|
||||||
// would abort the process.
|
// would abort the process.
|
||||||
}
|
}
|
||||||
|
|
||||||
void Send(ClientTCP& client, const std::string& wire) {
|
// Put the transport on top of the accepted socket. A TLS handshake that
|
||||||
client.Send(wire.data(), static_cast<std::uint32_t>(wire.size()));
|
// fails is a normal event on a public port — a scanner, a client with no
|
||||||
|
// protocol in common, an untrusted client certificate — so it is counted
|
||||||
|
// and the connection dropped rather than logged or thrown.
|
||||||
|
std::unique_ptr<ByteStream> Wrap(ClientTCP& client) {
|
||||||
|
if (!tls) return std::make_unique<PlainStream>(client.socketid);
|
||||||
|
try {
|
||||||
|
return TLSStream::Accept(client.socketid, tls, owner->handshakeTimeout);
|
||||||
|
} catch (...) {
|
||||||
|
handshakeFailures.fetch_add(1);
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void Send(ByteStream& stream, const std::string& wire) {
|
||||||
|
stream.Write(wire.data(), wire.size(), owner->requestTimeout);
|
||||||
}
|
}
|
||||||
|
|
||||||
HTTPResponse Dispatch(const HTTPRequest& request) {
|
HTTPResponse Dispatch(const HTTPRequest& request) {
|
||||||
|
|
@ -138,8 +134,12 @@ struct ListenerHTTP1::Impl {
|
||||||
|
|
||||||
// Serve one connection until the peer goes away, asks to close, stalls,
|
// Serve one connection until the peer goes away, asks to close, stalls,
|
||||||
// or sends something we refuse to parse.
|
// or sends something we refuse to parse.
|
||||||
void Serve(ClientTCP& client) {
|
void Serve(ByteStream& stream) {
|
||||||
HTTP1::MessageParser parser(HTTP1::MessageKind::Request, owner->limits);
|
HTTP1::MessageParser parser(HTTP1::MessageKind::Request, owner->limits);
|
||||||
|
// Origin-form targets carry no scheme, so the transport supplies it —
|
||||||
|
// handlers shared with ListenerHTTP see the same "https" they would
|
||||||
|
// over HTTP/3.
|
||||||
|
if (stream.Secure()) parser.SetDefaultScheme("https");
|
||||||
std::vector<char> chunk(16 * 1024);
|
std::vector<char> chunk(16 * 1024);
|
||||||
bool keepAlive = true;
|
bool keepAlive = true;
|
||||||
|
|
||||||
|
|
@ -151,21 +151,21 @@ struct ListenerHTTP1::Impl {
|
||||||
const bool idle = parser.AtMessageBoundary();
|
const bool idle = parser.AtMessageBoundary();
|
||||||
const auto timeout = idle ? owner->keepAliveTimeout : owner->requestTimeout;
|
const auto timeout = idle ? owner->keepAliveTimeout : owner->requestTimeout;
|
||||||
std::size_t read = 0;
|
std::size_t read = 0;
|
||||||
const ReadStatus status = ReadSome(client.socketid, chunk.data(), chunk.size(),
|
const StreamStatus status = stream.ReadSome(chunk.data(), chunk.size(),
|
||||||
timeout, read);
|
timeout, read);
|
||||||
if (status == ReadStatus::TimedOut) {
|
if (status == StreamStatus::TimedOut) {
|
||||||
// An idle keep-alive connection is simply dropped;
|
// An idle keep-alive connection is simply dropped;
|
||||||
// a half-sent request earns a 408 first.
|
// a half-sent request earns a 408 first.
|
||||||
if (!idle) {
|
if (!idle) {
|
||||||
try {
|
try {
|
||||||
Send(client, HTTP1::SerializeResponse(
|
Send(stream, HTTP1::SerializeResponse(
|
||||||
CreateResponseHTTP("408", "Request Timeout"),
|
CreateResponseHTTP("408", "Request Timeout"),
|
||||||
{ .keepAlive = false }));
|
{ .keepAlive = false }));
|
||||||
} catch (...) {}
|
} catch (...) {}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (status == ReadStatus::Closed) {
|
if (status == StreamStatus::Closed) {
|
||||||
if (parser.AtMessageBoundary()) return; // clean end of connection
|
if (parser.AtMessageBoundary()) return; // clean end of connection
|
||||||
parser.Finish(); // throws if truncated
|
parser.Finish(); // throws if truncated
|
||||||
closed = true;
|
closed = true;
|
||||||
|
|
@ -174,13 +174,13 @@ struct ListenerHTTP1::Impl {
|
||||||
parser.Feed(chunk.data(), read);
|
parser.Feed(chunk.data(), read);
|
||||||
// The peer is holding its body back until we say go.
|
// The peer is holding its body back until we say go.
|
||||||
if (parser.ExpectsContinue()) {
|
if (parser.ExpectsContinue()) {
|
||||||
Send(client, HTTP1::SerializeContinue());
|
Send(stream, HTTP1::SerializeContinue());
|
||||||
parser.ContinueSent();
|
parser.ContinueSent();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (const HTTP1::HTTP1ProtocolError& error) {
|
} catch (const HTTP1::HTTP1ProtocolError& error) {
|
||||||
try {
|
try {
|
||||||
Send(client, HTTP1::SerializeResponse(
|
Send(stream, HTTP1::SerializeResponse(
|
||||||
CreateResponseHTTP("400", std::string(error.what())),
|
CreateResponseHTTP("400", std::string(error.what())),
|
||||||
{ .keepAlive = false }));
|
{ .keepAlive = false }));
|
||||||
} catch (...) {}
|
} catch (...) {}
|
||||||
|
|
@ -210,7 +210,7 @@ struct ListenerHTTP1::Impl {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Send(client, HTTP1::SerializeResponse(response, {
|
Send(stream, HTTP1::SerializeResponse(response, {
|
||||||
.keepAlive = keepAlive,
|
.keepAlive = keepAlive,
|
||||||
// HTTP/1.0 peers only reuse a connection they were told
|
// HTTP/1.0 peers only reuse a connection they were told
|
||||||
// stays open.
|
// stays open.
|
||||||
|
|
@ -252,11 +252,30 @@ ListenerHTTP1::ListenerHTTP1(std::uint16_t port,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ListenerHTTP1::ListenerHTTP1(std::uint16_t port,
|
||||||
|
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes,
|
||||||
|
TLSServerCredentials credentials)
|
||||||
|
: ListenerHTTP1(port, std::move(routes), {}, std::move(credentials))
|
||||||
|
{}
|
||||||
|
|
||||||
|
ListenerHTTP1::ListenerHTTP1(std::uint16_t port,
|
||||||
|
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes,
|
||||||
|
std::function<HTTPResponse(const HTTPRequest&)> fallback,
|
||||||
|
TLSServerCredentials credentials)
|
||||||
|
: ListenerHTTP1(port, std::move(routes), std::move(fallback))
|
||||||
|
{
|
||||||
|
// After the plaintext constructor: the socket is already bound and
|
||||||
|
// listening, but nothing has been accepted (Listen() has not run), so
|
||||||
|
// there is no window in which a connection could be served unencrypted.
|
||||||
|
impl->tls = TLSContext::Server(credentials);
|
||||||
|
}
|
||||||
|
|
||||||
ListenerHTTP1::ListenerHTTP1(ListenerHTTP1&& other) noexcept
|
ListenerHTTP1::ListenerHTTP1(ListenerHTTP1&& other) noexcept
|
||||||
: routes(std::move(other.routes))
|
: routes(std::move(other.routes))
|
||||||
, fallback(std::move(other.fallback))
|
, fallback(std::move(other.fallback))
|
||||||
, keepAliveTimeout(other.keepAliveTimeout)
|
, keepAliveTimeout(other.keepAliveTimeout)
|
||||||
, requestTimeout(other.requestTimeout)
|
, requestTimeout(other.requestTimeout)
|
||||||
|
, handshakeTimeout(other.handshakeTimeout)
|
||||||
, limits(other.limits)
|
, limits(other.limits)
|
||||||
, impl(std::move(other.impl))
|
, impl(std::move(other.impl))
|
||||||
{
|
{
|
||||||
|
|
@ -313,6 +332,14 @@ std::uint64_t ListenerHTTP1::AcceptedCount() const {
|
||||||
return impl ? impl->accepted.load() : 0;
|
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,
|
ListenerAsyncHTTP1::ListenerAsyncHTTP1(std::uint16_t port,
|
||||||
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes)
|
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes)
|
||||||
: listener(port, std::move(routes))
|
: listener(port, std::move(routes))
|
||||||
|
|
@ -326,6 +353,21 @@ ListenerAsyncHTTP1::ListenerAsyncHTTP1(std::uint16_t port,
|
||||||
, thread(&ListenerHTTP1::Listen, &listener)
|
, thread(&ListenerHTTP1::Listen, &listener)
|
||||||
{}
|
{}
|
||||||
|
|
||||||
|
ListenerAsyncHTTP1::ListenerAsyncHTTP1(std::uint16_t port,
|
||||||
|
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes,
|
||||||
|
TLSServerCredentials credentials)
|
||||||
|
: listener(port, std::move(routes), std::move(credentials))
|
||||||
|
, thread(&ListenerHTTP1::Listen, &listener)
|
||||||
|
{}
|
||||||
|
|
||||||
|
ListenerAsyncHTTP1::ListenerAsyncHTTP1(std::uint16_t port,
|
||||||
|
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes,
|
||||||
|
std::function<HTTPResponse(const HTTPRequest&)> fallback,
|
||||||
|
TLSServerCredentials credentials)
|
||||||
|
: listener(port, std::move(routes), std::move(fallback), std::move(credentials))
|
||||||
|
, thread(&ListenerHTTP1::Listen, &listener)
|
||||||
|
{}
|
||||||
|
|
||||||
ListenerAsyncHTTP1::~ListenerAsyncHTTP1() {
|
ListenerAsyncHTTP1::~ListenerAsyncHTTP1() {
|
||||||
Stop();
|
Stop();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
99
implementations/Crafter.Network-Stream.cpp
Normal file
99
implementations/Crafter.Network-Stream.cpp
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
//SPDX-License-Identifier: LGPL-3.0-only
|
||||||
|
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||||
|
|
||||||
|
module;
|
||||||
|
#include <poll.h>
|
||||||
|
#include <fcntl.h>
|
||||||
|
#include <sys/socket.h>
|
||||||
|
#include <cerrno>
|
||||||
|
|
||||||
|
module Crafter.Network:Stream_impl;
|
||||||
|
import :Stream;
|
||||||
|
import std;
|
||||||
|
|
||||||
|
using namespace Crafter;
|
||||||
|
|
||||||
|
void Crafter::SetNonBlocking(int descriptor) {
|
||||||
|
const int flags = fcntl(descriptor, F_GETFL, 0);
|
||||||
|
if (flags == -1 || fcntl(descriptor, F_SETFL, flags | O_NONBLOCK) == -1) {
|
||||||
|
throw std::runtime_error(std::string("could not make the socket non-blocking: ")
|
||||||
|
+ std::strerror(errno));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Crafter::PollDescriptor(int descriptor, short events,
|
||||||
|
std::chrono::steady_clock::time_point deadline) {
|
||||||
|
for (;;) {
|
||||||
|
const auto left = std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||||
|
deadline - std::chrono::steady_clock::now());
|
||||||
|
// A deadline already in the past still gets one non-blocking look, so
|
||||||
|
// a zero timeout means "is it ready right now" rather than "give up".
|
||||||
|
const int wait = left.count() > 0 ? static_cast<int>(left.count()) : 0;
|
||||||
|
|
||||||
|
pollfd descriptors{ .fd = descriptor, .events = events, .revents = 0 };
|
||||||
|
const int ready = poll(&descriptors, 1, wait);
|
||||||
|
if (ready < 0) {
|
||||||
|
if (errno == EINTR) continue;
|
||||||
|
throw std::runtime_error(std::string("poll failed: ") + std::strerror(errno));
|
||||||
|
}
|
||||||
|
if (ready == 0) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PlainStream::PlainStream(int descriptor) : descriptor(descriptor) {
|
||||||
|
SetNonBlocking(descriptor);
|
||||||
|
}
|
||||||
|
|
||||||
|
StreamStatus PlainStream::ReadSome(char* buffer, std::size_t size,
|
||||||
|
std::chrono::milliseconds timeout,
|
||||||
|
std::size_t& read) {
|
||||||
|
read = 0;
|
||||||
|
const auto deadline = std::chrono::steady_clock::now() + timeout;
|
||||||
|
for (;;) {
|
||||||
|
const auto got = recv(descriptor, buffer, size, 0);
|
||||||
|
if (got > 0) {
|
||||||
|
read = static_cast<std::size_t>(got);
|
||||||
|
return StreamStatus::Data;
|
||||||
|
}
|
||||||
|
if (got == 0) return StreamStatus::Closed;
|
||||||
|
if (errno == EINTR) continue;
|
||||||
|
if (errno == EAGAIN || errno == EWOULDBLOCK) {
|
||||||
|
if (!PollDescriptor(descriptor, POLLIN, deadline)) return StreamStatus::TimedOut;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// A reset is how a peer that stopped caring shows up; it is an end of
|
||||||
|
// connection rather than something worth a diagnostic.
|
||||||
|
if (errno == ECONNRESET) return StreamStatus::Closed;
|
||||||
|
throw std::runtime_error(std::string("recv failed: ") + std::strerror(errno));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void PlainStream::Write(const void* buffer, std::size_t size,
|
||||||
|
std::chrono::milliseconds timeout) {
|
||||||
|
const auto deadline = std::chrono::steady_clock::now() + timeout;
|
||||||
|
const char* data = reinterpret_cast<const char*>(buffer);
|
||||||
|
std::size_t sent = 0;
|
||||||
|
while (sent < size) {
|
||||||
|
// MSG_NOSIGNAL: a peer that closed early must surface as EPIPE here,
|
||||||
|
// not as a SIGPIPE that takes the process down.
|
||||||
|
const auto wrote = send(descriptor, data + sent, size - sent, MSG_NOSIGNAL);
|
||||||
|
if (wrote > 0) {
|
||||||
|
sent += static_cast<std::size_t>(wrote);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (wrote == 0) throw std::runtime_error("the peer closed the connection");
|
||||||
|
if (errno == EINTR) continue;
|
||||||
|
if (errno == EAGAIN || errno == EWOULDBLOCK) {
|
||||||
|
if (!PollDescriptor(descriptor, POLLOUT, deadline)) {
|
||||||
|
throw std::runtime_error("timed out writing to the peer");
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw std::runtime_error(std::string("send failed: ") + std::strerror(errno));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void PlainStream::Shutdown() noexcept {
|
||||||
|
shutdown(descriptor, SHUT_WR);
|
||||||
|
}
|
||||||
669
implementations/Crafter.Network-TLS.cpp
Normal file
669
implementations/Crafter.Network-TLS.cpp
Normal file
|
|
@ -0,0 +1,669 @@
|
||||||
|
//SPDX-License-Identifier: LGPL-3.0-only
|
||||||
|
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||||
|
|
||||||
|
module;
|
||||||
|
#include <poll.h>
|
||||||
|
#include <arpa/inet.h>
|
||||||
|
#include <cerrno>
|
||||||
|
#include <climits>
|
||||||
|
|
||||||
|
#include <openssl/bio.h>
|
||||||
|
#include <openssl/err.h>
|
||||||
|
#include <openssl/evp.h>
|
||||||
|
#include <openssl/pem.h>
|
||||||
|
#include <openssl/ssl.h>
|
||||||
|
#include <openssl/sslerr.h>
|
||||||
|
#include <openssl/x509v3.h>
|
||||||
|
|
||||||
|
module Crafter.Network:TLS_impl;
|
||||||
|
import :TLS;
|
||||||
|
import :Stream;
|
||||||
|
import std;
|
||||||
|
|
||||||
|
using namespace Crafter;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
// ── OpenSSL plumbing ─────────────────────────────────────────────────
|
||||||
|
template <typename T, void (*Release)(T*)>
|
||||||
|
struct Releaser {
|
||||||
|
void operator()(T* pointer) const noexcept { if (pointer) Release(pointer); }
|
||||||
|
};
|
||||||
|
template <typename T, void (*Release)(T*)>
|
||||||
|
using Owned = std::unique_ptr<T, Releaser<T, Release>>;
|
||||||
|
|
||||||
|
using OwnedBio = Owned<BIO, BIO_free_all>;
|
||||||
|
using OwnedKey = Owned<EVP_PKEY, EVP_PKEY_free>;
|
||||||
|
using OwnedCertificate = Owned<X509, X509_free>;
|
||||||
|
|
||||||
|
// Drain OpenSSL's per-thread error queue into a readable message. Without
|
||||||
|
// this every TLS failure reads as "handshake failed" with no hint as to
|
||||||
|
// whether it was the certificate, the version, or the peer hanging up.
|
||||||
|
std::string Describe(std::string_view what) {
|
||||||
|
std::string message(what);
|
||||||
|
bool first = true;
|
||||||
|
while (const unsigned long code = ERR_get_error()) {
|
||||||
|
char buffer[256];
|
||||||
|
ERR_error_string_n(code, buffer, sizeof(buffer));
|
||||||
|
message += first ? ": " : "; ";
|
||||||
|
message += buffer;
|
||||||
|
first = false;
|
||||||
|
}
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
// True once OpenSSL has told us the peer vanished without a close_notify.
|
||||||
|
// For HTTP/1.1 that is an end of connection like any other — the message
|
||||||
|
// parser is the thing that decides whether it arrived too early.
|
||||||
|
bool UnexpectedEof() {
|
||||||
|
const unsigned long code = ERR_peek_error();
|
||||||
|
return ERR_GET_LIB(code) == ERR_LIB_SSL
|
||||||
|
&& ERR_GET_REASON(code) == SSL_R_UNEXPECTED_EOF_WHILE_READING;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string BioToString(BIO* bio) {
|
||||||
|
char* data = nullptr;
|
||||||
|
const long length = BIO_get_mem_data(bio, &data);
|
||||||
|
if (length <= 0 || data == nullptr) return {};
|
||||||
|
return std::string(data, static_cast<std::size_t>(length));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool IsIpLiteral(const std::string& name) {
|
||||||
|
in_addr v4{};
|
||||||
|
in6_addr v6{};
|
||||||
|
return inet_pton(AF_INET, name.c_str(), &v4) == 1
|
||||||
|
|| inet_pton(AF_INET6, name.c_str(), &v6) == 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ALPN ─────────────────────────────────────────────────────────────
|
||||||
|
// Wire form is a sequence of length-prefixed protocol names.
|
||||||
|
std::vector<unsigned char> EncodeAlpn(const std::vector<std::string>& protocols) {
|
||||||
|
std::vector<unsigned char> wire;
|
||||||
|
for (const std::string& protocol : protocols) {
|
||||||
|
if (protocol.empty() || protocol.size() > 255) {
|
||||||
|
throw TLSException("ALPN protocol names must be 1..255 bytes: '" + protocol + "'");
|
||||||
|
}
|
||||||
|
wire.push_back(static_cast<unsigned char>(protocol.size()));
|
||||||
|
wire.insert(wire.end(), protocol.begin(), protocol.end());
|
||||||
|
}
|
||||||
|
return wire;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server-side selection, in *our* preference order rather than the
|
||||||
|
// client's: the server is the side that knows what it can actually parse.
|
||||||
|
// No overlap is a fatal no_application_protocol alert (RFC 7301 §3.2) —
|
||||||
|
// letting the connection through would mean answering HTTP/2 with an
|
||||||
|
// HTTP/1.1 response and confusing both ends.
|
||||||
|
int SelectAlpn(SSL*, const unsigned char** out, unsigned char* outLength,
|
||||||
|
const unsigned char* in, unsigned int inLength, void* argument) {
|
||||||
|
const auto& preferred = *static_cast<const std::vector<std::string>*>(argument);
|
||||||
|
for (const std::string& candidate : preferred) {
|
||||||
|
for (unsigned int offset = 0; offset < inLength;) {
|
||||||
|
const unsigned int length = in[offset];
|
||||||
|
if (offset + 1 + length > inLength) break; // malformed list
|
||||||
|
if (length == candidate.size()
|
||||||
|
&& std::memcmp(in + offset + 1, candidate.data(), length) == 0) {
|
||||||
|
*out = in + offset + 1;
|
||||||
|
*outLength = static_cast<unsigned char>(length);
|
||||||
|
return SSL_TLSEXT_ERR_OK;
|
||||||
|
}
|
||||||
|
offset += 1 + length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return SSL_TLSEXT_ERR_ALERT_FATAL;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Self-signed certificate ──────────────────────────────────────────
|
||||||
|
void AddExtension(X509* certificate, X509V3_CTX* context, int nid, const char* value) {
|
||||||
|
X509_EXTENSION* extension = X509V3_EXT_conf_nid(nullptr, context, nid, value);
|
||||||
|
if (extension == nullptr) {
|
||||||
|
throw TLSException(Describe("could not build certificate extension"));
|
||||||
|
}
|
||||||
|
const int added = X509_add_ext(certificate, extension, -1);
|
||||||
|
X509_EXTENSION_free(extension);
|
||||||
|
if (added != 1) throw TLSException(Describe("could not add certificate extension"));
|
||||||
|
}
|
||||||
|
|
||||||
|
TLSCertificatePem MakeSelfSignedCertificate() {
|
||||||
|
OwnedKey key(EVP_EC_gen("P-256"));
|
||||||
|
if (!key) throw TLSException(Describe("could not generate a P-256 key"));
|
||||||
|
|
||||||
|
OwnedCertificate certificate(X509_new());
|
||||||
|
if (!certificate) throw TLSException(Describe("could not allocate a certificate"));
|
||||||
|
|
||||||
|
// X509_set_version takes the zero-based version, so 2 is v3 — which is
|
||||||
|
// what the extensions below require.
|
||||||
|
X509_set_version(certificate.get(), 2);
|
||||||
|
ASN1_INTEGER_set(X509_get_serialNumber(certificate.get()), 1);
|
||||||
|
// Backdated an hour so a peer whose clock runs slightly behind ours
|
||||||
|
// does not reject a certificate we just minted.
|
||||||
|
X509_gmtime_adj(X509_getm_notBefore(certificate.get()), -3600);
|
||||||
|
X509_gmtime_adj(X509_getm_notAfter(certificate.get()), 10 * 24 * 60 * 60);
|
||||||
|
if (X509_set_pubkey(certificate.get(), key.get()) != 1) {
|
||||||
|
throw TLSException(Describe("could not set the certificate public key"));
|
||||||
|
}
|
||||||
|
|
||||||
|
X509_NAME* subject = X509_get_subject_name(certificate.get());
|
||||||
|
X509_NAME_add_entry_by_txt(subject, "CN", MBSTRING_ASC,
|
||||||
|
reinterpret_cast<const unsigned char*>("localhost"), -1, -1, 0);
|
||||||
|
// Self-signed: issuer is the subject.
|
||||||
|
X509_set_issuer_name(certificate.get(), subject);
|
||||||
|
|
||||||
|
X509V3_CTX extensionContext;
|
||||||
|
X509V3_set_ctx_nodb(&extensionContext);
|
||||||
|
X509V3_set_ctx(&extensionContext, certificate.get(), certificate.get(),
|
||||||
|
nullptr, nullptr, 0);
|
||||||
|
AddExtension(certificate.get(), &extensionContext, NID_basic_constraints,
|
||||||
|
"critical,CA:FALSE");
|
||||||
|
AddExtension(certificate.get(), &extensionContext, NID_key_usage,
|
||||||
|
"critical,digitalSignature,keyEncipherment");
|
||||||
|
AddExtension(certificate.get(), &extensionContext, NID_ext_key_usage, "serverAuth");
|
||||||
|
// The SANs are what a verifying client actually matches on; a bare CN
|
||||||
|
// has not been accepted by anything for years.
|
||||||
|
AddExtension(certificate.get(), &extensionContext, NID_subject_alt_name,
|
||||||
|
"DNS:localhost,IP:127.0.0.1,IP:::1");
|
||||||
|
|
||||||
|
if (X509_sign(certificate.get(), key.get(), EVP_sha256()) == 0) {
|
||||||
|
throw TLSException(Describe("could not sign the certificate"));
|
||||||
|
}
|
||||||
|
|
||||||
|
TLSCertificatePem pem;
|
||||||
|
{
|
||||||
|
OwnedBio bio(BIO_new(BIO_s_mem()));
|
||||||
|
if (!bio || PEM_write_bio_X509(bio.get(), certificate.get()) != 1) {
|
||||||
|
throw TLSException(Describe("could not encode the certificate as PEM"));
|
||||||
|
}
|
||||||
|
pem.certificate = BioToString(bio.get());
|
||||||
|
}
|
||||||
|
{
|
||||||
|
OwnedBio bio(BIO_new(BIO_s_mem()));
|
||||||
|
if (!bio || PEM_write_bio_PrivateKey(bio.get(), key.get(), nullptr, nullptr, 0,
|
||||||
|
nullptr, nullptr) != 1) {
|
||||||
|
throw TLSException(Describe("could not encode the private key as PEM"));
|
||||||
|
}
|
||||||
|
pem.privateKey = BioToString(bio.get());
|
||||||
|
}
|
||||||
|
return pem;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Credential loading ───────────────────────────────────────────────
|
||||||
|
OwnedBio MemoryBio(const std::string& contents) {
|
||||||
|
if (contents.size() > static_cast<std::size_t>(INT_MAX)) {
|
||||||
|
throw TLSException("PEM blob is implausibly large");
|
||||||
|
}
|
||||||
|
OwnedBio bio(BIO_new_mem_buf(contents.data(), static_cast<int>(contents.size())));
|
||||||
|
if (!bio) throw TLSException(Describe("could not wrap the PEM blob"));
|
||||||
|
return bio;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leaf first, then any intermediates, exactly as OpenSSL's own
|
||||||
|
// *_chain_file loader treats a PEM bundle.
|
||||||
|
void UseCertificateChainPem(SSL_CTX* context, const std::string& pem) {
|
||||||
|
OwnedBio bio = MemoryBio(pem);
|
||||||
|
OwnedCertificate leaf(PEM_read_bio_X509(bio.get(), nullptr, nullptr, nullptr));
|
||||||
|
if (!leaf) throw TLSException(Describe("could not read the certificate PEM"));
|
||||||
|
if (SSL_CTX_use_certificate(context, leaf.get()) != 1) {
|
||||||
|
throw TLSException(Describe("could not install the certificate"));
|
||||||
|
}
|
||||||
|
SSL_CTX_clear_chain_certs(context);
|
||||||
|
for (;;) {
|
||||||
|
OwnedCertificate extra(PEM_read_bio_X509(bio.get(), nullptr, nullptr, nullptr));
|
||||||
|
if (!extra) break;
|
||||||
|
// add0 takes ownership on success, so the pointer is released.
|
||||||
|
if (SSL_CTX_add0_chain_cert(context, extra.get()) != 1) {
|
||||||
|
throw TLSException(Describe("could not install a chain certificate"));
|
||||||
|
}
|
||||||
|
(void)extra.release();
|
||||||
|
}
|
||||||
|
// PEM_read_bio_X509 leaves a "no start line" error behind when it runs
|
||||||
|
// out of certificates; that is the loop's exit condition, not a fault.
|
||||||
|
ERR_clear_error();
|
||||||
|
}
|
||||||
|
|
||||||
|
void UsePrivateKeyPem(SSL_CTX* context, const std::string& pem) {
|
||||||
|
OwnedBio bio = MemoryBio(pem);
|
||||||
|
OwnedKey key(PEM_read_bio_PrivateKey(bio.get(), nullptr, nullptr, nullptr));
|
||||||
|
if (!key) throw TLSException(Describe("could not read the private key PEM"));
|
||||||
|
if (SSL_CTX_use_PrivateKey(context, key.get()) != 1) {
|
||||||
|
throw TLSException(Describe("could not install the private key"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A trust anchor path is either a PEM bundle or a hashed directory of
|
||||||
|
// them; OpenSSL wants to be told which, so look.
|
||||||
|
void LoadTrustAnchorPath(SSL_CTX* context, const std::string& path) {
|
||||||
|
std::error_code error;
|
||||||
|
const bool directory = std::filesystem::is_directory(path, error);
|
||||||
|
const int loaded = directory
|
||||||
|
? SSL_CTX_load_verify_locations(context, nullptr, path.c_str())
|
||||||
|
: SSL_CTX_load_verify_locations(context, path.c_str(), nullptr);
|
||||||
|
if (loaded != 1) {
|
||||||
|
throw TLSException(Describe("could not load trust anchors from '" + path + "'"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void LoadTrustAnchorPem(SSL_CTX* context, const std::string& pem) {
|
||||||
|
X509_STORE* store = SSL_CTX_get_cert_store(context);
|
||||||
|
OwnedBio bio = MemoryBio(pem);
|
||||||
|
std::size_t added = 0;
|
||||||
|
for (;;) {
|
||||||
|
OwnedCertificate anchor(PEM_read_bio_X509(bio.get(), nullptr, nullptr, nullptr));
|
||||||
|
if (!anchor) break;
|
||||||
|
if (X509_STORE_add_cert(store, anchor.get()) != 1) {
|
||||||
|
throw TLSException(Describe("could not add a trust anchor"));
|
||||||
|
}
|
||||||
|
++added;
|
||||||
|
}
|
||||||
|
ERR_clear_error();
|
||||||
|
if (added == 0) throw TLSException("caPem contained no certificate");
|
||||||
|
}
|
||||||
|
|
||||||
|
void ApplyCommonOptions(SSL_CTX* context) {
|
||||||
|
// TLS 1.2 floor: 1.0/1.1 are deprecated (RFC 8996) and nothing we want
|
||||||
|
// to talk to needs them.
|
||||||
|
if (SSL_CTX_set_min_proto_version(context, TLS1_2_VERSION) != 1) {
|
||||||
|
throw TLSException(Describe("could not require TLS 1.2 or newer"));
|
||||||
|
}
|
||||||
|
// Partial writes plus a moving write buffer: our Write() loops over
|
||||||
|
// its own offset, so it must be allowed to make progress a record at a
|
||||||
|
// time instead of being forced to re-present a byte-identical buffer.
|
||||||
|
SSL_CTX_set_mode(context, SSL_MODE_ENABLE_PARTIAL_WRITE
|
||||||
|
| SSL_MODE_ACCEPT_MOVING_WRITE_BUFFER);
|
||||||
|
// Renegotiation buys nothing here and is a cheap way for a peer to
|
||||||
|
// make us do asymmetric crypto on demand.
|
||||||
|
SSL_CTX_set_options(context, SSL_OP_NO_RENEGOTIATION);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TLSContext ───────────────────────────────────────────────────────────
|
||||||
|
struct TLSContext::Impl {
|
||||||
|
SSL_CTX* context = nullptr;
|
||||||
|
bool verifyPeer = true;
|
||||||
|
std::string serverName;
|
||||||
|
// Server preference list; SelectAlpn holds a pointer to it, so it must
|
||||||
|
// outlive every SSL made from this context — which it does, being owned
|
||||||
|
// by the shared_ptr'd TLSContext.
|
||||||
|
std::vector<std::string> alpn;
|
||||||
|
|
||||||
|
~Impl() { if (context) SSL_CTX_free(context); }
|
||||||
|
};
|
||||||
|
|
||||||
|
TLSContext::TLSContext() : impl(std::make_unique<Impl>()) {}
|
||||||
|
TLSContext::~TLSContext() = default;
|
||||||
|
|
||||||
|
std::shared_ptr<TLSContext> TLSContext::Server(const TLSServerCredentials& credentials) {
|
||||||
|
std::shared_ptr<TLSContext> wrapper(new TLSContext());
|
||||||
|
Impl& state = *wrapper->impl;
|
||||||
|
|
||||||
|
state.context = SSL_CTX_new(TLS_server_method());
|
||||||
|
if (state.context == nullptr) throw TLSException(Describe("could not create a TLS context"));
|
||||||
|
ApplyCommonOptions(state.context);
|
||||||
|
|
||||||
|
if (!credentials.certPath.empty()) {
|
||||||
|
if (credentials.keyPath.empty()) {
|
||||||
|
throw TLSException("certPath was given without a matching keyPath");
|
||||||
|
}
|
||||||
|
if (SSL_CTX_use_certificate_chain_file(state.context, credentials.certPath.c_str()) != 1) {
|
||||||
|
throw TLSException(Describe("could not load the certificate '"
|
||||||
|
+ credentials.certPath + "'"));
|
||||||
|
}
|
||||||
|
if (SSL_CTX_use_PrivateKey_file(state.context, credentials.keyPath.c_str(),
|
||||||
|
SSL_FILETYPE_PEM) != 1) {
|
||||||
|
throw TLSException(Describe("could not load the private key '"
|
||||||
|
+ credentials.keyPath + "'"));
|
||||||
|
}
|
||||||
|
} else if (!credentials.certPem.empty()) {
|
||||||
|
if (credentials.keyPem.empty()) {
|
||||||
|
throw TLSException("certPem was given without a matching keyPem");
|
||||||
|
}
|
||||||
|
UseCertificateChainPem(state.context, credentials.certPem);
|
||||||
|
UsePrivateKeyPem(state.context, credentials.keyPem);
|
||||||
|
} else if (credentials.selfSigned) {
|
||||||
|
const TLSCertificatePem& pem = GetSelfSignedCertificatePem();
|
||||||
|
UseCertificateChainPem(state.context, pem.certificate);
|
||||||
|
UsePrivateKeyPem(state.context, pem.privateKey);
|
||||||
|
} else {
|
||||||
|
throw TLSException("no server certificate: set certPath/keyPath, certPem/keyPem, "
|
||||||
|
"or selfSigned for a development certificate");
|
||||||
|
}
|
||||||
|
if (SSL_CTX_check_private_key(state.context) != 1) {
|
||||||
|
throw TLSException(Describe("the private key does not match the certificate"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (credentials.requireClientCertificate) {
|
||||||
|
if (!credentials.clientCaPath.empty()) {
|
||||||
|
LoadTrustAnchorPath(state.context, credentials.clientCaPath);
|
||||||
|
// Advertise the acceptable issuers so the client can choose a
|
||||||
|
// certificate instead of guessing.
|
||||||
|
std::error_code error;
|
||||||
|
if (!std::filesystem::is_directory(credentials.clientCaPath, error)) {
|
||||||
|
if (STACK_OF(X509_NAME)* names =
|
||||||
|
SSL_load_client_CA_file(credentials.clientCaPath.c_str())) {
|
||||||
|
SSL_CTX_set_client_CA_list(state.context, names);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (SSL_CTX_set_default_verify_paths(state.context) != 1) {
|
||||||
|
throw TLSException(Describe("could not load the system trust store"));
|
||||||
|
}
|
||||||
|
SSL_CTX_set_verify(state.context,
|
||||||
|
SSL_VERIFY_PEER | SSL_VERIFY_FAIL_IF_NO_PEER_CERT, nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
state.alpn = credentials.alpnProtocols;
|
||||||
|
if (!state.alpn.empty()) {
|
||||||
|
// Validate the names now rather than inside the handshake callback,
|
||||||
|
// where there is nowhere useful to report a bad configuration.
|
||||||
|
(void)EncodeAlpn(state.alpn);
|
||||||
|
SSL_CTX_set_alpn_select_cb(state.context, SelectAlpn, &state.alpn);
|
||||||
|
}
|
||||||
|
return wrapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::shared_ptr<TLSContext> TLSContext::Client(const TLSClientCredentials& credentials) {
|
||||||
|
std::shared_ptr<TLSContext> wrapper(new TLSContext());
|
||||||
|
Impl& state = *wrapper->impl;
|
||||||
|
|
||||||
|
state.context = SSL_CTX_new(TLS_client_method());
|
||||||
|
if (state.context == nullptr) throw TLSException(Describe("could not create a TLS context"));
|
||||||
|
ApplyCommonOptions(state.context);
|
||||||
|
|
||||||
|
state.verifyPeer = !credentials.insecureNoServerValidation;
|
||||||
|
state.serverName = credentials.serverName;
|
||||||
|
if (state.verifyPeer) {
|
||||||
|
if (SSL_CTX_set_default_verify_paths(state.context) != 1) {
|
||||||
|
throw TLSException(Describe("could not load the system trust store"));
|
||||||
|
}
|
||||||
|
if (!credentials.caPath.empty()) LoadTrustAnchorPath(state.context, credentials.caPath);
|
||||||
|
if (!credentials.caPem.empty()) LoadTrustAnchorPem(state.context, credentials.caPem);
|
||||||
|
SSL_CTX_set_verify(state.context, SSL_VERIFY_PEER, nullptr);
|
||||||
|
} else {
|
||||||
|
// The handshake still completes and SSL_get_verify_result still
|
||||||
|
// reports what it found; nothing acts on it.
|
||||||
|
SSL_CTX_set_verify(state.context, SSL_VERIFY_NONE, nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!credentials.certPath.empty()) {
|
||||||
|
if (credentials.keyPath.empty()) {
|
||||||
|
throw TLSException("certPath was given without a matching keyPath");
|
||||||
|
}
|
||||||
|
if (SSL_CTX_use_certificate_chain_file(state.context, credentials.certPath.c_str()) != 1) {
|
||||||
|
throw TLSException(Describe("could not load the client certificate '"
|
||||||
|
+ credentials.certPath + "'"));
|
||||||
|
}
|
||||||
|
if (SSL_CTX_use_PrivateKey_file(state.context, credentials.keyPath.c_str(),
|
||||||
|
SSL_FILETYPE_PEM) != 1) {
|
||||||
|
throw TLSException(Describe("could not load the client private key '"
|
||||||
|
+ credentials.keyPath + "'"));
|
||||||
|
}
|
||||||
|
if (SSL_CTX_check_private_key(state.context) != 1) {
|
||||||
|
throw TLSException(Describe("the client key does not match the client certificate"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!credentials.alpnProtocols.empty()) {
|
||||||
|
const std::vector<unsigned char> wire = EncodeAlpn(credentials.alpnProtocols);
|
||||||
|
if (SSL_CTX_set_alpn_protos(state.context, wire.data(),
|
||||||
|
static_cast<unsigned int>(wire.size())) != 0) {
|
||||||
|
throw TLSException(Describe("could not set the ALPN protocol list"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return wrapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TLSStream ────────────────────────────────────────────────────────────
|
||||||
|
struct TLSStream::Impl {
|
||||||
|
std::shared_ptr<TLSContext> context;
|
||||||
|
SSL* ssl = nullptr;
|
||||||
|
int descriptor = -1;
|
||||||
|
std::string protocol;
|
||||||
|
bool shutdownSent = false;
|
||||||
|
|
||||||
|
~Impl() { if (ssl) SSL_free(ssl); }
|
||||||
|
|
||||||
|
// Drive SSL_connect/SSL_accept to completion, polling for whichever
|
||||||
|
// direction OpenSSL is waiting on. The deadline covers the whole
|
||||||
|
// handshake, not each poll, so a peer that dribbles records cannot
|
||||||
|
// stretch it indefinitely.
|
||||||
|
void Handshake(bool client, std::chrono::milliseconds timeout) {
|
||||||
|
const auto deadline = std::chrono::steady_clock::now() + timeout;
|
||||||
|
for (;;) {
|
||||||
|
ERR_clear_error();
|
||||||
|
const int result = client ? SSL_connect(ssl) : SSL_accept(ssl);
|
||||||
|
if (result == 1) break;
|
||||||
|
|
||||||
|
const int error = SSL_get_error(ssl, result);
|
||||||
|
if (error == SSL_ERROR_WANT_READ || error == SSL_ERROR_WANT_WRITE) {
|
||||||
|
const short events = error == SSL_ERROR_WANT_READ ? POLLIN : POLLOUT;
|
||||||
|
if (!PollDescriptor(descriptor, events, deadline)) {
|
||||||
|
throw TLSException("TLS handshake timed out");
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Certificate problems are the failure people actually hit, and
|
||||||
|
// OpenSSL's generic queue message for them ("certificate verify
|
||||||
|
// failed") does not say which check tripped.
|
||||||
|
const long verified = SSL_get_verify_result(ssl);
|
||||||
|
if (verified != X509_V_OK) {
|
||||||
|
throw TLSException(std::string("TLS certificate rejected: ")
|
||||||
|
+ X509_verify_cert_error_string(verified));
|
||||||
|
}
|
||||||
|
if (error == SSL_ERROR_ZERO_RETURN
|
||||||
|
|| (error == SSL_ERROR_SYSCALL && result == 0)
|
||||||
|
|| (error == SSL_ERROR_SSL && UnexpectedEof())) {
|
||||||
|
throw TLSException("the peer closed the connection during the TLS handshake");
|
||||||
|
}
|
||||||
|
if (error == SSL_ERROR_SYSCALL) {
|
||||||
|
throw TLSException(std::string("TLS handshake failed: ") + std::strerror(errno));
|
||||||
|
}
|
||||||
|
throw TLSException(Describe("TLS handshake failed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsigned char* selected = nullptr;
|
||||||
|
unsigned int length = 0;
|
||||||
|
SSL_get0_alpn_selected(ssl, &selected, &length);
|
||||||
|
if (selected != nullptr && length != 0) {
|
||||||
|
protocol.assign(reinterpret_cast<const char*>(selected), length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
TLSStream::TLSStream() : impl(std::make_unique<Impl>()) {}
|
||||||
|
|
||||||
|
TLSStream::~TLSStream() {
|
||||||
|
Shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::unique_ptr<TLSStream> TLSStream::Connect(int descriptor,
|
||||||
|
std::shared_ptr<TLSContext> context,
|
||||||
|
const std::string& hostName,
|
||||||
|
std::chrono::milliseconds timeout) {
|
||||||
|
if (!context) throw TLSException("no TLS context");
|
||||||
|
SetNonBlocking(descriptor);
|
||||||
|
|
||||||
|
std::unique_ptr<TLSStream> stream(new TLSStream());
|
||||||
|
TLSContext::Impl& configuration = *context->impl;
|
||||||
|
stream->impl->context = std::move(context);
|
||||||
|
stream->impl->descriptor = descriptor;
|
||||||
|
|
||||||
|
SSL* ssl = SSL_new(configuration.context);
|
||||||
|
if (ssl == nullptr) throw TLSException(Describe("could not create a TLS session"));
|
||||||
|
stream->impl->ssl = ssl;
|
||||||
|
if (SSL_set_fd(ssl, descriptor) != 1) {
|
||||||
|
throw TLSException(Describe("could not attach the socket to the TLS session"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::string& name = configuration.serverName.empty()
|
||||||
|
? hostName : configuration.serverName;
|
||||||
|
const bool literal = !name.empty() && IsIpLiteral(name);
|
||||||
|
// SNI carries host names only — an IP literal there is a protocol
|
||||||
|
// violation and some servers reject the handshake outright (RFC 6066 §3).
|
||||||
|
if (!name.empty() && !literal && SSL_set_tlsext_host_name(ssl, name.c_str()) != 1) {
|
||||||
|
throw TLSException(Describe("could not set the SNI host name"));
|
||||||
|
}
|
||||||
|
if (configuration.verifyPeer) {
|
||||||
|
if (name.empty()) {
|
||||||
|
throw TLSException("certificate verification needs a name to check against; "
|
||||||
|
"set serverName or use insecureNoServerValidation");
|
||||||
|
}
|
||||||
|
SSL_set_hostflags(ssl, X509_CHECK_FLAG_NO_PARTIAL_WILDCARDS);
|
||||||
|
const int named = literal
|
||||||
|
? X509_VERIFY_PARAM_set1_ip_asc(SSL_get0_param(ssl), name.c_str())
|
||||||
|
: SSL_set1_host(ssl, name.c_str());
|
||||||
|
if (named != 1) {
|
||||||
|
throw TLSException("could not use '" + name + "' as the name to verify");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SSL_set_connect_state(ssl);
|
||||||
|
stream->impl->Handshake(true, timeout);
|
||||||
|
return stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::unique_ptr<TLSStream> TLSStream::Accept(int descriptor,
|
||||||
|
std::shared_ptr<TLSContext> context,
|
||||||
|
std::chrono::milliseconds timeout) {
|
||||||
|
if (!context) throw TLSException("no TLS context");
|
||||||
|
SetNonBlocking(descriptor);
|
||||||
|
|
||||||
|
std::unique_ptr<TLSStream> stream(new TLSStream());
|
||||||
|
TLSContext::Impl& configuration = *context->impl;
|
||||||
|
stream->impl->context = std::move(context);
|
||||||
|
stream->impl->descriptor = descriptor;
|
||||||
|
|
||||||
|
SSL* ssl = SSL_new(configuration.context);
|
||||||
|
if (ssl == nullptr) throw TLSException(Describe("could not create a TLS session"));
|
||||||
|
stream->impl->ssl = ssl;
|
||||||
|
if (SSL_set_fd(ssl, descriptor) != 1) {
|
||||||
|
throw TLSException(Describe("could not attach the socket to the TLS session"));
|
||||||
|
}
|
||||||
|
|
||||||
|
SSL_set_accept_state(ssl);
|
||||||
|
stream->impl->Handshake(false, timeout);
|
||||||
|
return stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
StreamStatus TLSStream::ReadSome(char* buffer, std::size_t size,
|
||||||
|
std::chrono::milliseconds timeout,
|
||||||
|
std::size_t& read) {
|
||||||
|
read = 0;
|
||||||
|
if (size == 0) return StreamStatus::Data;
|
||||||
|
const auto deadline = std::chrono::steady_clock::now() + timeout;
|
||||||
|
const int wanted = static_cast<int>(std::min<std::size_t>(size, INT_MAX));
|
||||||
|
for (;;) {
|
||||||
|
ERR_clear_error();
|
||||||
|
const int got = SSL_read(impl->ssl, buffer, wanted);
|
||||||
|
if (got > 0) {
|
||||||
|
read = static_cast<std::size_t>(got);
|
||||||
|
return StreamStatus::Data;
|
||||||
|
}
|
||||||
|
const int error = SSL_get_error(impl->ssl, got);
|
||||||
|
switch (error) {
|
||||||
|
case SSL_ERROR_WANT_READ:
|
||||||
|
if (!PollDescriptor(impl->descriptor, POLLIN, deadline)) {
|
||||||
|
return StreamStatus::TimedOut;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
// A read can need the socket writable: TLS 1.3 key updates and
|
||||||
|
// (where allowed) renegotiation both send records mid-read.
|
||||||
|
case SSL_ERROR_WANT_WRITE:
|
||||||
|
if (!PollDescriptor(impl->descriptor, POLLOUT, deadline)) {
|
||||||
|
return StreamStatus::TimedOut;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
case SSL_ERROR_ZERO_RETURN:
|
||||||
|
return StreamStatus::Closed; // close_notify: an orderly end
|
||||||
|
case SSL_ERROR_SYSCALL:
|
||||||
|
if (errno == EINTR) continue;
|
||||||
|
if (got == 0 || errno == 0 || errno == ECONNRESET) return StreamStatus::Closed;
|
||||||
|
throw TLSException(std::string("TLS read failed: ") + std::strerror(errno));
|
||||||
|
case SSL_ERROR_SSL:
|
||||||
|
if (UnexpectedEof()) return StreamStatus::Closed;
|
||||||
|
throw TLSException(Describe("TLS read failed"));
|
||||||
|
default:
|
||||||
|
throw TLSException(Describe("TLS read failed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void TLSStream::Write(const void* buffer, std::size_t size,
|
||||||
|
std::chrono::milliseconds timeout) {
|
||||||
|
const auto deadline = std::chrono::steady_clock::now() + timeout;
|
||||||
|
const char* data = reinterpret_cast<const char*>(buffer);
|
||||||
|
std::size_t sent = 0;
|
||||||
|
while (sent < size) {
|
||||||
|
const int wanted = static_cast<int>(std::min<std::size_t>(size - sent, INT_MAX));
|
||||||
|
ERR_clear_error();
|
||||||
|
const int wrote = SSL_write(impl->ssl, data + sent, wanted);
|
||||||
|
if (wrote > 0) {
|
||||||
|
sent += static_cast<std::size_t>(wrote);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const int error = SSL_get_error(impl->ssl, wrote);
|
||||||
|
switch (error) {
|
||||||
|
case SSL_ERROR_WANT_READ:
|
||||||
|
if (!PollDescriptor(impl->descriptor, POLLIN, deadline)) {
|
||||||
|
throw TLSException("timed out writing to the TLS peer");
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
case SSL_ERROR_WANT_WRITE:
|
||||||
|
if (!PollDescriptor(impl->descriptor, POLLOUT, deadline)) {
|
||||||
|
throw TLSException("timed out writing to the TLS peer");
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
case SSL_ERROR_ZERO_RETURN:
|
||||||
|
throw TLSException("the TLS peer closed the connection");
|
||||||
|
case SSL_ERROR_SYSCALL:
|
||||||
|
if (errno == EINTR) continue;
|
||||||
|
throw TLSException(std::string("TLS write failed: ")
|
||||||
|
+ (errno == 0 ? "the peer closed the connection"
|
||||||
|
: std::strerror(errno)));
|
||||||
|
default:
|
||||||
|
throw TLSException(Describe("TLS write failed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void TLSStream::Shutdown() noexcept {
|
||||||
|
if (!impl || impl->ssl == nullptr || impl->shutdownSent) return;
|
||||||
|
impl->shutdownSent = true;
|
||||||
|
// One attempt only: close_notify goes out, and we deliberately do not
|
||||||
|
// wait for the peer's. Waiting means blocking a teardown path on a peer
|
||||||
|
// that may never answer, and every framing decision has already been made
|
||||||
|
// by the time we get here.
|
||||||
|
ERR_clear_error();
|
||||||
|
SSL_shutdown(impl->ssl);
|
||||||
|
ERR_clear_error();
|
||||||
|
}
|
||||||
|
|
||||||
|
int TLSStream::Descriptor() const noexcept {
|
||||||
|
return impl ? impl->descriptor : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string_view TLSStream::Protocol() const noexcept {
|
||||||
|
return impl ? std::string_view(impl->protocol) : std::string_view();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string TLSStream::Version() const {
|
||||||
|
if (!impl || impl->ssl == nullptr) return {};
|
||||||
|
const char* version = SSL_get_version(impl->ssl);
|
||||||
|
return version == nullptr ? std::string() : std::string(version);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string TLSStream::PeerCertificateSubject() const {
|
||||||
|
if (!impl || impl->ssl == nullptr) return {};
|
||||||
|
OwnedCertificate peer(SSL_get1_peer_certificate(impl->ssl));
|
||||||
|
if (!peer) return {};
|
||||||
|
char buffer[512] = {};
|
||||||
|
X509_NAME_oneline(X509_get_subject_name(peer.get()), buffer, sizeof(buffer));
|
||||||
|
return std::string(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
const TLSCertificatePem& Crafter::GetSelfSignedCertificatePem() {
|
||||||
|
// Generated once per process so every listener presents the same
|
||||||
|
// certificate: a client that was handed it as a trust anchor keeps
|
||||||
|
// working across reconnects.
|
||||||
|
static std::mutex mutex;
|
||||||
|
static std::optional<TLSCertificatePem> cached;
|
||||||
|
std::lock_guard lock(mutex);
|
||||||
|
if (!cached) cached = MakeSelfSignedCertificate();
|
||||||
|
return *cached;
|
||||||
|
}
|
||||||
|
|
@ -5,13 +5,14 @@ export module Crafter.Network:ClientHTTP1;
|
||||||
import std;
|
import std;
|
||||||
import :HTTP;
|
import :HTTP;
|
||||||
import :HTTP1;
|
import :HTTP1;
|
||||||
|
import :TLS;
|
||||||
|
|
||||||
#ifndef CRAFTER_NETWORK_BROWSER
|
#ifndef CRAFTER_NETWORK_BROWSER
|
||||||
namespace Crafter {
|
namespace Crafter {
|
||||||
// HTTP/1.1 client over plain TCP, for peers that cannot speak HTTP/3.
|
// HTTP/1.1 client over TCP, with or without TLS, for peers that cannot
|
||||||
// The request/response types are the ones the HTTP/3 client uses, so
|
// speak HTTP/3. The request/response types are the ones the HTTP/3 client
|
||||||
// swapping ClientHTTP for ClientHTTP1 is a one-line change at the call
|
// uses, so swapping ClientHTTP for ClientHTTP1 is a one-line change at the
|
||||||
// site.
|
// call site.
|
||||||
//
|
//
|
||||||
// The connection is persistent: the first Send() dials, and later calls
|
// The connection is persistent: the first Send() dials, and later calls
|
||||||
// reuse the socket unless the peer asked for it to be closed
|
// 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
|
// Thread-affinity matches ClientHTTP: one ClientHTTP1 serves one caller
|
||||||
// at a time; distinct instances are independent.
|
// at a time; distinct instances are independent.
|
||||||
//
|
//
|
||||||
// No TLS. This talks `http://`; for an encrypted transport use
|
// `http://` or `https://` is chosen by the constructor: pass
|
||||||
// ClientHTTP (HTTP/3 over QUIC), or put a TLS-terminating proxy in
|
// TLSClientCredentials and every byte goes through libssl (see :TLS),
|
||||||
// front of the HTTP/1.1 endpoint.
|
// 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 {
|
export class ClientHTTP1 {
|
||||||
public:
|
public:
|
||||||
std::string host;
|
std::string host;
|
||||||
|
|
@ -36,13 +40,21 @@ namespace Crafter {
|
||||||
ClientHTTP1(const char* host, std::uint16_t port);
|
ClientHTTP1(const char* host, std::uint16_t port);
|
||||||
ClientHTTP1(std::string 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();
|
||||||
ClientHTTP1(const ClientHTTP1&) = delete;
|
ClientHTTP1(const ClientHTTP1&) = delete;
|
||||||
ClientHTTP1(ClientHTTP1&&) noexcept;
|
ClientHTTP1(ClientHTTP1&&) noexcept;
|
||||||
|
|
||||||
// Send a request and read the full response. `authority` defaults to
|
// Send a request and read the full response. `authority` defaults to
|
||||||
// the host:port this client was constructed with; `scheme` is
|
// the host:port this client was constructed with; `scheme` is ignored
|
||||||
// ignored (the transport is plaintext).
|
// — HTTP/1.1 request targets are origin-form and the transport was
|
||||||
|
// already decided by the constructor.
|
||||||
HTTPResponse Send(const HTTPRequest& request);
|
HTTPResponse Send(const HTTPRequest& request);
|
||||||
|
|
||||||
// Send a request and deliver the response (or the error text) via
|
// 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.
|
// tests asserting that keep-alive actually kept the socket.
|
||||||
bool Connected() const noexcept;
|
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.
|
// Drop the pooled connection; the next Send() dials again.
|
||||||
void Disconnect();
|
void Disconnect();
|
||||||
|
|
||||||
|
|
@ -63,6 +83,10 @@ namespace Crafter {
|
||||||
// How long to wait for the next piece of a response before giving
|
// 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.
|
// up on a server that accepted the connection and then went quiet.
|
||||||
std::chrono::milliseconds timeout{30000};
|
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:
|
private:
|
||||||
struct Impl;
|
struct Impl;
|
||||||
|
|
|
||||||
|
|
@ -299,6 +299,14 @@ namespace Crafter::HTTP1 {
|
||||||
headRequest = EqualsIgnoreCase(requestMethod, "HEAD");
|
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) {
|
void Feed(const char* data, std::size_t size) {
|
||||||
if (size != 0) buffer.append(data, size);
|
if (size != 0) buffer.append(data, size);
|
||||||
Advance();
|
Advance();
|
||||||
|
|
@ -340,7 +348,7 @@ namespace Crafter::HTTP1 {
|
||||||
HTTPRequest request;
|
HTTPRequest request;
|
||||||
request.method = std::move(method);
|
request.method = std::move(method);
|
||||||
request.path = std::move(path);
|
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
|
// Absolute-form targets carry their own authority and win over
|
||||||
// Host (RFC 9112 §3.2.2); otherwise Host supplies it. Either way
|
// 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
|
// 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 method;
|
||||||
std::string path;
|
std::string path;
|
||||||
std::string scheme;
|
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 authority;
|
||||||
std::string version;
|
std::string version;
|
||||||
std::string status;
|
std::string status;
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,13 @@ export module Crafter.Network:ListenerHTTP1;
|
||||||
import std;
|
import std;
|
||||||
import :HTTP;
|
import :HTTP;
|
||||||
import :HTTP1;
|
import :HTTP1;
|
||||||
|
import :TLS;
|
||||||
|
|
||||||
#ifndef CRAFTER_NETWORK_BROWSER
|
#ifndef CRAFTER_NETWORK_BROWSER
|
||||||
namespace Crafter {
|
namespace Crafter {
|
||||||
// HTTP/1.1 server over plain TCP. Same route map and `fallback` shape as
|
// HTTP/1.1 server over TCP, plaintext or TLS. Same route map and
|
||||||
// ListenerHTTP, so a handler can be registered with both and served over
|
// `fallback` shape as ListenerHTTP, so a handler can be registered with
|
||||||
// either protocol.
|
// both and served over either protocol.
|
||||||
//
|
//
|
||||||
// Each accepted connection gets its own thread and is served
|
// Each accepted connection gets its own thread and is served
|
||||||
// sequentially until the peer closes it, `Connection: close` is seen, or
|
// 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
|
// across connections but never for two requests on the same one, which
|
||||||
// is what HTTP/1.1 response ordering requires. A dedicated thread rather
|
// is what HTTP/1.1 response ordering requires. A dedicated thread rather
|
||||||
// than a ThreadPool task is deliberate: keep-alive connections are idle
|
// 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
|
// Implemented: TLS via libssl (pass TLSServerCredentials), keep-alive and
|
||||||
// request bodies, `Expect: 100-continue`, HEAD (headers only), automatic
|
// pipelining, content-length and chunked request bodies, `Expect:
|
||||||
// `Date`, and 400/404/500 error responses. Not implemented: TLS,
|
// 100-continue`, HEAD (headers only), automatic `Date`, and 400/404/500
|
||||||
// CONNECT tunnels, Upgrade, and chunked *responses* (handlers return a
|
// error responses. Not implemented: CONNECT tunnels, Upgrade, and chunked
|
||||||
// complete body, so responses are always content-length framed).
|
// *responses* (handlers return a complete body, so responses are always
|
||||||
|
// content-length framed).
|
||||||
export class ListenerHTTP1 {
|
export class ListenerHTTP1 {
|
||||||
public:
|
public:
|
||||||
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes;
|
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes;
|
||||||
|
|
@ -45,6 +49,9 @@ namespace Crafter {
|
||||||
// against a peer holding a thread forever.
|
// against a peer holding a thread forever.
|
||||||
std::chrono::milliseconds keepAliveTimeout{15000};
|
std::chrono::milliseconds keepAliveTimeout{15000};
|
||||||
std::chrono::milliseconds requestTimeout{30000};
|
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.
|
// Limits applied to incoming requests.
|
||||||
HTTP1::MessageLimits limits;
|
HTTP1::MessageLimits limits;
|
||||||
|
|
||||||
|
|
@ -55,6 +62,21 @@ namespace Crafter {
|
||||||
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes,
|
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes,
|
||||||
std::function<HTTPResponse(const HTTPRequest&)> fallback);
|
std::function<HTTPResponse(const HTTPRequest&)> fallback);
|
||||||
|
|
||||||
|
// https://. Every accepted connection is wrapped in TLS before a byte
|
||||||
|
// of HTTP is read; requests reach handlers with `scheme` set to
|
||||||
|
// "https". Throws TLSException from the constructor if the credentials
|
||||||
|
// do not yield a usable certificate, so a misconfigured server never
|
||||||
|
// reaches the point of listening.
|
||||||
|
ListenerHTTP1(std::uint16_t port,
|
||||||
|
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes,
|
||||||
|
TLSServerCredentials credentials);
|
||||||
|
|
||||||
|
// TLS plus a fallback handler.
|
||||||
|
ListenerHTTP1(std::uint16_t port,
|
||||||
|
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes,
|
||||||
|
std::function<HTTPResponse(const HTTPRequest&)> fallback,
|
||||||
|
TLSServerCredentials credentials);
|
||||||
|
|
||||||
~ListenerHTTP1();
|
~ListenerHTTP1();
|
||||||
ListenerHTTP1(const ListenerHTTP1&) = delete;
|
ListenerHTTP1(const ListenerHTTP1&) = delete;
|
||||||
ListenerHTTP1(ListenerHTTP1&&) noexcept;
|
ListenerHTTP1(ListenerHTTP1&&) noexcept;
|
||||||
|
|
@ -69,6 +91,13 @@ namespace Crafter {
|
||||||
std::size_t ConnectionCount() const;
|
std::size_t ConnectionCount() const;
|
||||||
// Connections accepted since construction.
|
// Connections accepted since construction.
|
||||||
std::uint64_t AcceptedCount() const;
|
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:
|
private:
|
||||||
struct Impl;
|
struct Impl;
|
||||||
|
|
@ -92,6 +121,15 @@ namespace Crafter {
|
||||||
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes,
|
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes,
|
||||||
std::function<HTTPResponse(const HTTPRequest&)> fallback);
|
std::function<HTTPResponse(const HTTPRequest&)> fallback);
|
||||||
|
|
||||||
|
// TLS, with and without a fallback handler.
|
||||||
|
ListenerAsyncHTTP1(std::uint16_t port,
|
||||||
|
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes,
|
||||||
|
TLSServerCredentials credentials);
|
||||||
|
ListenerAsyncHTTP1(std::uint16_t port,
|
||||||
|
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes,
|
||||||
|
std::function<HTTPResponse(const HTTPRequest&)> fallback,
|
||||||
|
TLSServerCredentials credentials);
|
||||||
|
|
||||||
~ListenerAsyncHTTP1();
|
~ListenerAsyncHTTP1();
|
||||||
void Stop();
|
void Stop();
|
||||||
};
|
};
|
||||||
|
|
|
||||||
95
interfaces/Crafter.Network-Stream.cppm
Normal file
95
interfaces/Crafter.Network-Stream.cppm
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
//SPDX-License-Identifier: LGPL-3.0-only
|
||||||
|
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||||
|
|
||||||
|
export module Crafter.Network:Stream;
|
||||||
|
import std;
|
||||||
|
|
||||||
|
#ifndef CRAFTER_NETWORK_BROWSER
|
||||||
|
namespace Crafter {
|
||||||
|
// A reliable, ordered byte stream with deadlines on both directions.
|
||||||
|
//
|
||||||
|
// This exists so the HTTP/1.1 client and listener can be written once and
|
||||||
|
// run over either a bare socket or a TLS session: `PlainStream` below is
|
||||||
|
// the `http://` transport, `TLSStream` (in :TLS) the `https://` one. The
|
||||||
|
// HTTP/1.1 code holds a `ByteStream&` and never learns which it has.
|
||||||
|
//
|
||||||
|
// Every method takes its own timeout rather than the stream carrying one,
|
||||||
|
// because HTTP/1.1 uses different budgets for different states — a long
|
||||||
|
// idle keep-alive wait, a shorter one once a request has started.
|
||||||
|
export enum class StreamStatus {
|
||||||
|
Data, // `read` bytes are available in the buffer
|
||||||
|
Closed, // the peer closed its send side, cleanly
|
||||||
|
TimedOut, // nothing arrived before the deadline
|
||||||
|
};
|
||||||
|
|
||||||
|
export class ByteStream {
|
||||||
|
public:
|
||||||
|
virtual ~ByteStream() = default;
|
||||||
|
ByteStream() = default;
|
||||||
|
ByteStream(const ByteStream&) = delete;
|
||||||
|
ByteStream& operator=(const ByteStream&) = delete;
|
||||||
|
|
||||||
|
// Read whatever is already available, waiting at most `timeout` for
|
||||||
|
// the first byte. Sets `read` and returns Data, or reports a clean
|
||||||
|
// close / a timeout. Throws on a transport error.
|
||||||
|
virtual StreamStatus ReadSome(char* buffer, std::size_t size,
|
||||||
|
std::chrono::milliseconds timeout,
|
||||||
|
std::size_t& read) = 0;
|
||||||
|
|
||||||
|
// Write the whole buffer. Throws if it could not all be handed over
|
||||||
|
// within `timeout`.
|
||||||
|
virtual void Write(const void* buffer, std::size_t size,
|
||||||
|
std::chrono::milliseconds timeout) = 0;
|
||||||
|
|
||||||
|
// Best-effort orderly close of our send side. Never throws — it runs
|
||||||
|
// on teardown paths where there is nothing useful to do with a
|
||||||
|
// failure.
|
||||||
|
virtual void Shutdown() noexcept = 0;
|
||||||
|
|
||||||
|
// The underlying descriptor, so a listener can shutdown(2) it to wake
|
||||||
|
// a thread parked in poll().
|
||||||
|
virtual int Descriptor() const noexcept = 0;
|
||||||
|
|
||||||
|
// Negotiated ALPN protocol, empty when the transport has no notion of
|
||||||
|
// one (plaintext) or nothing was agreed.
|
||||||
|
virtual std::string_view Protocol() const noexcept { return {}; }
|
||||||
|
|
||||||
|
// Whether the bytes are encrypted on the wire.
|
||||||
|
virtual bool Secure() const noexcept { return false; }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Plaintext TCP. Non-owning: the descriptor stays owned by the ClientTCP
|
||||||
|
// (or whatever else) that opened it.
|
||||||
|
//
|
||||||
|
// The descriptor is switched to non-blocking on construction — both
|
||||||
|
// directions are driven by poll() against a deadline, which a blocking
|
||||||
|
// descriptor cannot express. That is also what lets a write time out
|
||||||
|
// instead of parking forever against a peer that has stopped reading.
|
||||||
|
export class PlainStream final : public ByteStream {
|
||||||
|
public:
|
||||||
|
explicit PlainStream(int descriptor);
|
||||||
|
|
||||||
|
StreamStatus ReadSome(char* buffer, std::size_t size,
|
||||||
|
std::chrono::milliseconds timeout,
|
||||||
|
std::size_t& read) override;
|
||||||
|
void Write(const void* buffer, std::size_t size,
|
||||||
|
std::chrono::milliseconds timeout) override;
|
||||||
|
void Shutdown() noexcept override;
|
||||||
|
int Descriptor() const noexcept override { return descriptor; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
int descriptor;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Put a descriptor into non-blocking mode. Exposed because :TLS needs the
|
||||||
|
// same thing for the descriptor it wraps.
|
||||||
|
export void SetNonBlocking(int descriptor);
|
||||||
|
|
||||||
|
// Wait until `descriptor` is ready for `events` (a poll(2) event mask) or
|
||||||
|
// `deadline` passes; false means the deadline won. Retries across EINTR
|
||||||
|
// and throws on a real poll failure. Shared with :TLS, which has to poll
|
||||||
|
// for whichever direction OpenSSL asks for next.
|
||||||
|
export bool PollDescriptor(int descriptor, short events,
|
||||||
|
std::chrono::steady_clock::time_point deadline);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
179
interfaces/Crafter.Network-TLS.cppm
Normal file
179
interfaces/Crafter.Network-TLS.cppm
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
//SPDX-License-Identifier: LGPL-3.0-only
|
||||||
|
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||||
|
|
||||||
|
export module Crafter.Network:TLS;
|
||||||
|
import std;
|
||||||
|
import :Stream;
|
||||||
|
|
||||||
|
#ifndef CRAFTER_NETWORK_BROWSER
|
||||||
|
namespace Crafter {
|
||||||
|
// TLS over a TCP socket, via libssl (OpenSSL 3). This is the transport
|
||||||
|
// that turns `ClientHTTP1`/`ListenerHTTP1` into `https://` endpoints; it
|
||||||
|
// is deliberately protocol-agnostic, so anything else that owns a
|
||||||
|
// connected descriptor can wrap it the same way.
|
||||||
|
//
|
||||||
|
// No OpenSSL type appears below: the SSL_CTX and SSL live behind the Impl
|
||||||
|
// pointers, so importing this partition does not drag <openssl/*.h> into
|
||||||
|
// the consumer. TLS 1.2 is the floor, the platform's cipher defaults are
|
||||||
|
// used unchanged, and renegotiation is left to OpenSSL's own policy.
|
||||||
|
export class TLSException : public std::runtime_error {
|
||||||
|
public:
|
||||||
|
using std::runtime_error::runtime_error;
|
||||||
|
};
|
||||||
|
|
||||||
|
// A certificate and its private key, PEM-encoded.
|
||||||
|
export struct TLSCertificatePem {
|
||||||
|
std::string certificate;
|
||||||
|
std::string privateKey;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The certificate the server presents. Exactly one source is used, in
|
||||||
|
// this order: certPath/keyPath, then certPem/keyPem, then selfSigned.
|
||||||
|
//
|
||||||
|
// selfSigned generates an ephemeral in-memory certificate (see
|
||||||
|
// GetSelfSignedCertificatePem) — for development, tests and LAN use. A
|
||||||
|
// client talking to it needs either insecureNoServerValidation or the
|
||||||
|
// certificate itself as a trust anchor.
|
||||||
|
export struct TLSServerCredentials {
|
||||||
|
// PEM files on disk. certPath may hold a chain (leaf first).
|
||||||
|
std::string certPath;
|
||||||
|
std::string keyPath;
|
||||||
|
// The same material inline, for callers that hold it in memory
|
||||||
|
// already (a secret store, a test) and would rather not touch disk.
|
||||||
|
std::string certPem;
|
||||||
|
std::string keyPem;
|
||||||
|
bool selfSigned = false;
|
||||||
|
|
||||||
|
// Mutual TLS. With requireClientCertificate set, a peer that presents
|
||||||
|
// no certificate — or one that does not chain to clientCaPath — is
|
||||||
|
// rejected during the handshake. clientCaPath is a PEM file or a
|
||||||
|
// directory of them; when it is empty the system trust store is used.
|
||||||
|
std::string clientCaPath;
|
||||||
|
bool requireClientCertificate = false;
|
||||||
|
|
||||||
|
// Protocols we are willing to speak, in server preference order. A
|
||||||
|
// client that offers ALPN and none of these is rejected with
|
||||||
|
// no_application_protocol (RFC 7301 §3.2) rather than being let
|
||||||
|
// through to speak something we cannot parse. A client that offers no
|
||||||
|
// ALPN at all is accepted — plenty of tooling still does not send it.
|
||||||
|
std::vector<std::string> alpnProtocols = { "http/1.1" };
|
||||||
|
};
|
||||||
|
|
||||||
|
// How the client checks the server, and what it presents itself.
|
||||||
|
//
|
||||||
|
// The default verifies the chain against the system trust store *and* the
|
||||||
|
// hostname, which is the only combination that is actually safe; a chain
|
||||||
|
// check without a name check accepts any valid certificate for any name.
|
||||||
|
export struct TLSClientCredentials {
|
||||||
|
// Skip both checks. Development only — it accepts any certificate,
|
||||||
|
// including an attacker's.
|
||||||
|
bool insecureNoServerValidation = false;
|
||||||
|
|
||||||
|
// Extra trust anchor: a PEM file or a directory of them. Added to the
|
||||||
|
// system store rather than replacing it. This is how you talk to a
|
||||||
|
// self-signed listener without giving up verification — hand the
|
||||||
|
// client the server's certificate.
|
||||||
|
std::string caPath;
|
||||||
|
// The same, inline.
|
||||||
|
std::string caPem;
|
||||||
|
|
||||||
|
// Overrides the name used for SNI and hostname verification. Empty
|
||||||
|
// means the host being connected to, which is what you want unless
|
||||||
|
// you are dialling an address that differs from the certificate name
|
||||||
|
// (a tunnel, a pinned IP).
|
||||||
|
std::string serverName;
|
||||||
|
|
||||||
|
// Client certificate for mutual TLS. Ignored when the server does not
|
||||||
|
// ask for one.
|
||||||
|
std::string certPath;
|
||||||
|
std::string keyPath;
|
||||||
|
|
||||||
|
// Protocols to offer, in client preference order. Empty sends no ALPN
|
||||||
|
// extension at all.
|
||||||
|
std::vector<std::string> alpnProtocols = { "http/1.1" };
|
||||||
|
};
|
||||||
|
|
||||||
|
// A configured SSL_CTX. Shared by every connection it produces — one per
|
||||||
|
// listener, one per client — because the expensive parts (parsing the
|
||||||
|
// certificate, loading the trust store) are per-context, and OpenSSL 3
|
||||||
|
// lets an SSL_CTX be used concurrently from many threads.
|
||||||
|
//
|
||||||
|
// Held by shared_ptr: a TLSStream keeps its context alive, so a listener
|
||||||
|
// that goes away mid-connection does not pull the configuration out from
|
||||||
|
// under a session still using it.
|
||||||
|
export class TLSContext {
|
||||||
|
public:
|
||||||
|
static std::shared_ptr<TLSContext> Server(const TLSServerCredentials& credentials);
|
||||||
|
static std::shared_ptr<TLSContext> Client(const TLSClientCredentials& credentials);
|
||||||
|
|
||||||
|
~TLSContext();
|
||||||
|
TLSContext(const TLSContext&) = delete;
|
||||||
|
TLSContext& operator=(const TLSContext&) = delete;
|
||||||
|
|
||||||
|
private:
|
||||||
|
TLSContext();
|
||||||
|
struct Impl;
|
||||||
|
std::unique_ptr<Impl> impl;
|
||||||
|
friend class TLSStream;
|
||||||
|
};
|
||||||
|
|
||||||
|
// A TLS session over an already-connected descriptor. Non-owning, like
|
||||||
|
// PlainStream: the descriptor stays owned by its ClientTCP, and this only
|
||||||
|
// adds the record layer on top.
|
||||||
|
//
|
||||||
|
// Both factories complete the handshake before returning, so a stream you
|
||||||
|
// hold is a stream you can write to. They throw TLSException on
|
||||||
|
// certificate rejection, on a protocol mismatch, and on a peer that stops
|
||||||
|
// answering mid-handshake.
|
||||||
|
export class TLSStream final : public ByteStream {
|
||||||
|
public:
|
||||||
|
// Client side. `hostName` drives SNI and hostname verification unless
|
||||||
|
// the credentials overrode it with serverName; an IP literal sets no
|
||||||
|
// SNI (RFC 6066 forbids it) and is checked against the certificate's
|
||||||
|
// iPAddress SANs instead.
|
||||||
|
static std::unique_ptr<TLSStream> Connect(int descriptor,
|
||||||
|
std::shared_ptr<TLSContext> context,
|
||||||
|
const std::string& hostName,
|
||||||
|
std::chrono::milliseconds timeout);
|
||||||
|
|
||||||
|
// Server side, on a descriptor accept(2) just handed us.
|
||||||
|
static std::unique_ptr<TLSStream> Accept(int descriptor,
|
||||||
|
std::shared_ptr<TLSContext> context,
|
||||||
|
std::chrono::milliseconds timeout);
|
||||||
|
|
||||||
|
~TLSStream() override;
|
||||||
|
|
||||||
|
StreamStatus ReadSome(char* buffer, std::size_t size,
|
||||||
|
std::chrono::milliseconds timeout,
|
||||||
|
std::size_t& read) override;
|
||||||
|
void Write(const void* buffer, std::size_t size,
|
||||||
|
std::chrono::milliseconds timeout) override;
|
||||||
|
void Shutdown() noexcept override;
|
||||||
|
int Descriptor() const noexcept override;
|
||||||
|
std::string_view Protocol() const noexcept override;
|
||||||
|
bool Secure() const noexcept override { return true; }
|
||||||
|
|
||||||
|
// The negotiated protocol version, e.g. "TLSv1.3". For logging.
|
||||||
|
std::string Version() const;
|
||||||
|
// One-line subject of the peer's certificate, empty when it presented
|
||||||
|
// none. With requireClientCertificate a non-empty value is the
|
||||||
|
// authenticated client identity.
|
||||||
|
std::string PeerCertificateSubject() const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
TLSStream();
|
||||||
|
struct Impl;
|
||||||
|
std::unique_ptr<Impl> impl;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The process-wide ephemeral self-signed certificate used by
|
||||||
|
// TLSServerCredentials{selfSigned=true}, in PEM form. Generated on first
|
||||||
|
// call and then cached, so every listener in a process presents the same
|
||||||
|
// certificate and a client can be handed it as a trust anchor.
|
||||||
|
//
|
||||||
|
// ECDSA P-256, CN=localhost, SAN {DNS:localhost, IP:127.0.0.1, IP:::1},
|
||||||
|
// valid for 10 days. Development and tests only — it is regenerated on
|
||||||
|
// every process start and no peer has any reason to trust it.
|
||||||
|
export const TLSCertificatePem& GetSelfSignedCertificatePem();
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
@ -21,6 +21,12 @@ export import :HTTP3;
|
||||||
// in the browser this job is already done by fetch() behind :ClientHTTP,
|
// in the browser this job is already done by fetch() behind :ClientHTTP,
|
||||||
// and these partitions use exceptions and POSIX sockets.
|
// and these partitions use exceptions and POSIX sockets.
|
||||||
export import :HTTP1;
|
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 :ClientHTTP1;
|
||||||
export import :ListenerHTTP1;
|
export import :ListenerHTTP1;
|
||||||
#endif
|
#endif
|
||||||
21
project.cpp
21
project.cpp
|
|
@ -7,7 +7,7 @@ namespace fs = std::filesystem;
|
||||||
using namespace Crafter;
|
using namespace Crafter;
|
||||||
|
|
||||||
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> args) {
|
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> args) {
|
||||||
constexpr std::array<std::string_view, 13> networkInterfaces = {
|
constexpr std::array<std::string_view, 15> networkInterfaces = {
|
||||||
"interfaces/Crafter.Network",
|
"interfaces/Crafter.Network",
|
||||||
"interfaces/Crafter.Network-ClientTCP",
|
"interfaces/Crafter.Network-ClientTCP",
|
||||||
"interfaces/Crafter.Network-ListenerTCP",
|
"interfaces/Crafter.Network-ListenerTCP",
|
||||||
|
|
@ -15,6 +15,8 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
|
||||||
"interfaces/Crafter.Network-ListenerHTTP",
|
"interfaces/Crafter.Network-ListenerHTTP",
|
||||||
"interfaces/Crafter.Network-HTTP",
|
"interfaces/Crafter.Network-HTTP",
|
||||||
"interfaces/Crafter.Network-HTTP1",
|
"interfaces/Crafter.Network-HTTP1",
|
||||||
|
"interfaces/Crafter.Network-Stream",
|
||||||
|
"interfaces/Crafter.Network-TLS",
|
||||||
"interfaces/Crafter.Network-ClientHTTP1",
|
"interfaces/Crafter.Network-ClientHTTP1",
|
||||||
"interfaces/Crafter.Network-ListenerHTTP1",
|
"interfaces/Crafter.Network-ListenerHTTP1",
|
||||||
"interfaces/Crafter.Network-HTTP3",
|
"interfaces/Crafter.Network-HTTP3",
|
||||||
|
|
@ -72,11 +74,13 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
|
||||||
return cfg;
|
return cfg;
|
||||||
}
|
}
|
||||||
|
|
||||||
constexpr std::array<std::string_view, 9> networkImplementations = {
|
constexpr std::array<std::string_view, 11> networkImplementations = {
|
||||||
"implementations/Crafter.Network-ClientTCP",
|
"implementations/Crafter.Network-ClientTCP",
|
||||||
"implementations/Crafter.Network-ListenerTCP",
|
"implementations/Crafter.Network-ListenerTCP",
|
||||||
"implementations/Crafter.Network-ClientHTTP",
|
"implementations/Crafter.Network-ClientHTTP",
|
||||||
"implementations/Crafter.Network-ListenerHTTP",
|
"implementations/Crafter.Network-ListenerHTTP",
|
||||||
|
"implementations/Crafter.Network-Stream",
|
||||||
|
"implementations/Crafter.Network-TLS",
|
||||||
"implementations/Crafter.Network-ClientHTTP1",
|
"implementations/Crafter.Network-ClientHTTP1",
|
||||||
"implementations/Crafter.Network-ListenerHTTP1",
|
"implementations/Crafter.Network-ListenerHTTP1",
|
||||||
"implementations/Crafter.Network-ClientQUIC",
|
"implementations/Crafter.Network-ClientQUIC",
|
||||||
|
|
@ -108,9 +112,18 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
|
||||||
// linker at the actual output location.
|
// linker at the actual output location.
|
||||||
msquic.libDirs = { "bin/Release" };
|
msquic.libDirs = { "bin/Release" };
|
||||||
msquic.libs = { "msquic" };
|
msquic.libs = { "msquic" };
|
||||||
std::array<fs::path, 13> ifaces;
|
|
||||||
|
// libssl/libcrypto — the TLS transport behind :TLS, i.e. https:// on the
|
||||||
|
// HTTP/1.1 client and listener. A system package rather than a built
|
||||||
|
// external: OpenSSL 3 is on every platform we target, and building it here
|
||||||
|
// would mean shipping a second TLS stack alongside the one msquic already
|
||||||
|
// links (quictls, which keeps its symbols to itself inside libmsquic.so).
|
||||||
|
cfg.linkFlags.push_back("-lssl");
|
||||||
|
cfg.linkFlags.push_back("-lcrypto");
|
||||||
|
|
||||||
|
std::array<fs::path, 15> ifaces;
|
||||||
std::ranges::copy(networkInterfaces, ifaces.begin());
|
std::ranges::copy(networkInterfaces, ifaces.begin());
|
||||||
std::array<fs::path, 9> impls;
|
std::array<fs::path, 11> impls;
|
||||||
std::ranges::copy(networkImplementations, impls.begin());
|
std::ranges::copy(networkImplementations, impls.begin());
|
||||||
cfg.GetInterfacesAndImplementations(ifaces, impls);
|
cfg.GetInterfacesAndImplementations(ifaces, impls);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue