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>
This commit is contained in:
parent
e1bb116b2d
commit
9c22cbe09e
11 changed files with 1299 additions and 99 deletions
|
|
@ -2,8 +2,6 @@
|
|||
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
module;
|
||||
#include <poll.h>
|
||||
#include <sys/socket.h>
|
||||
#include <cerrno>
|
||||
|
||||
module Crafter.Network:ClientHTTP1_impl;
|
||||
|
|
@ -11,41 +9,19 @@ import :ClientHTTP1;
|
|||
import :ClientTCP;
|
||||
import :HTTP;
|
||||
import :HTTP1;
|
||||
import :Stream;
|
||||
import :TLS;
|
||||
import Crafter.Thread;
|
||||
import std;
|
||||
|
||||
using namespace Crafter;
|
||||
|
||||
namespace {
|
||||
// Read whatever is available, waiting at most `timeout`. Returns 0 on a
|
||||
// clean close by the peer. ClientTCP::RecieveSync() can't express a
|
||||
// timeout — and a server that accepts the connection and then says
|
||||
// nothing would hang Send() forever — so the read is done here.
|
||||
std::size_t ReadSome(int socketid, char* buffer, std::size_t size,
|
||||
std::chrono::milliseconds timeout) {
|
||||
for (;;) {
|
||||
pollfd pfd{ .fd = socketid, .events = POLLIN, .revents = 0 };
|
||||
const int ready = poll(&pfd, 1, static_cast<int>(timeout.count()));
|
||||
if (ready < 0) {
|
||||
if (errno == EINTR) continue;
|
||||
throw std::runtime_error(std::string("poll failed: ") + std::strerror(errno));
|
||||
}
|
||||
if (ready == 0) throw std::runtime_error("timed out waiting for the response");
|
||||
|
||||
const auto read = recv(socketid, buffer, size, 0);
|
||||
if (read < 0) {
|
||||
if (errno == EINTR) continue;
|
||||
throw std::runtime_error(std::string("recv failed: ") + std::strerror(errno));
|
||||
}
|
||||
return static_cast<std::size_t>(read);
|
||||
}
|
||||
}
|
||||
|
||||
// Host header value. The port is elided when it is the scheme default,
|
||||
// which is what every other HTTP/1.1 client on the wire does and what
|
||||
// virtual-host matching on the far side tends to expect.
|
||||
std::string DefaultAuthority(const std::string& host, std::uint16_t port) {
|
||||
if (port == 80) return host;
|
||||
std::string DefaultAuthority(const std::string& host, std::uint16_t port, bool secure) {
|
||||
if (port == (secure ? 443 : 80)) return host;
|
||||
return host + ":" + std::to_string(port);
|
||||
}
|
||||
}
|
||||
|
|
@ -53,13 +29,34 @@ namespace {
|
|||
struct ClientHTTP1::Impl {
|
||||
std::string host;
|
||||
std::uint16_t port;
|
||||
// Null for plaintext; shared by every connection this client dials, so the
|
||||
// trust store is parsed once rather than per redial.
|
||||
std::shared_ptr<TLSContext> tls;
|
||||
std::unique_ptr<ClientTCP> tcp;
|
||||
// Sits on top of `tcp` and must therefore be destroyed before it.
|
||||
std::unique_ptr<ByteStream> stream;
|
||||
std::string protocol;
|
||||
|
||||
void Connect() {
|
||||
void Connect(std::chrono::milliseconds handshakeTimeout) {
|
||||
tcp = std::make_unique<ClientTCP>(host, port);
|
||||
try {
|
||||
if (tls) {
|
||||
stream = TLSStream::Connect(tcp->socketid, tls, host, handshakeTimeout);
|
||||
} else {
|
||||
stream = std::make_unique<PlainStream>(tcp->socketid);
|
||||
}
|
||||
} catch (...) {
|
||||
// A half-built connection must not be left pooled: the next Send()
|
||||
// would treat it as reusable and read from a socket with no
|
||||
// session on it.
|
||||
tcp.reset();
|
||||
throw;
|
||||
}
|
||||
protocol = std::string(stream->Protocol());
|
||||
}
|
||||
|
||||
void Close() {
|
||||
stream.reset();
|
||||
tcp.reset();
|
||||
}
|
||||
|
||||
|
|
@ -69,14 +66,19 @@ struct ClientHTTP1::Impl {
|
|||
HTTPResponse Exchange(const std::string& wire, std::string_view method,
|
||||
const HTTP1::MessageLimits& limits,
|
||||
std::chrono::milliseconds timeout, bool& received) {
|
||||
tcp->Send(wire.data(), static_cast<std::uint32_t>(wire.size()));
|
||||
stream->Write(wire.data(), wire.size(), timeout);
|
||||
|
||||
HTTP1::MessageParser parser(HTTP1::MessageKind::Response, limits);
|
||||
parser.SetRequestMethod(method);
|
||||
std::vector<char> chunk(16 * 1024);
|
||||
while (!parser.Complete()) {
|
||||
const std::size_t read = ReadSome(tcp->socketid, chunk.data(), chunk.size(), timeout);
|
||||
if (read == 0) {
|
||||
std::size_t read = 0;
|
||||
const StreamStatus status = stream->ReadSome(chunk.data(), chunk.size(),
|
||||
timeout, read);
|
||||
if (status == StreamStatus::TimedOut) {
|
||||
throw std::runtime_error("timed out waiting for the response");
|
||||
}
|
||||
if (status == StreamStatus::Closed) {
|
||||
// Peer closed. Completes a response framed by close;
|
||||
// anything else throws out of Finish().
|
||||
parser.Finish();
|
||||
|
|
@ -100,6 +102,17 @@ ClientHTTP1::ClientHTTP1(const char* host, std::uint16_t port)
|
|||
ClientHTTP1::ClientHTTP1(std::string host, std::uint16_t port)
|
||||
: ClientHTTP1(host.c_str(), port) {}
|
||||
|
||||
ClientHTTP1::ClientHTTP1(const char* host, std::uint16_t port, TLSClientCredentials credentials)
|
||||
: host(host), port(port), impl(std::make_unique<Impl>(std::string(host), port)) {
|
||||
// Built here rather than on first Send() so bad credentials — an
|
||||
// unreadable certificate, a trust anchor that is not a certificate —
|
||||
// surface at construction, where the caller is still looking.
|
||||
impl->tls = TLSContext::Client(credentials);
|
||||
}
|
||||
|
||||
ClientHTTP1::ClientHTTP1(std::string host, std::uint16_t port, TLSClientCredentials credentials)
|
||||
: ClientHTTP1(host.c_str(), port, std::move(credentials)) {}
|
||||
|
||||
ClientHTTP1::ClientHTTP1(ClientHTTP1&&) noexcept = default;
|
||||
ClientHTTP1::~ClientHTTP1() = default;
|
||||
|
||||
|
|
@ -107,13 +120,23 @@ bool ClientHTTP1::Connected() const noexcept {
|
|||
return impl && impl->tcp != nullptr;
|
||||
}
|
||||
|
||||
bool ClientHTTP1::Secure() const noexcept {
|
||||
return impl && impl->tls != nullptr;
|
||||
}
|
||||
|
||||
std::string_view ClientHTTP1::Protocol() const noexcept {
|
||||
return impl ? std::string_view(impl->protocol) : std::string_view();
|
||||
}
|
||||
|
||||
void ClientHTTP1::Disconnect() {
|
||||
if (impl) impl->Close();
|
||||
}
|
||||
|
||||
HTTPResponse ClientHTTP1::Send(const HTTPRequest& request) {
|
||||
HTTPRequest prepared = request;
|
||||
if (prepared.authority.empty()) prepared.authority = DefaultAuthority(host, port);
|
||||
if (prepared.authority.empty()) {
|
||||
prepared.authority = DefaultAuthority(host, port, Secure());
|
||||
}
|
||||
const std::string wire = HTTP1::SerializeRequest(prepared);
|
||||
const std::string method = prepared.method.empty() ? std::string("GET") : prepared.method;
|
||||
|
||||
|
|
@ -123,7 +146,7 @@ HTTPResponse ClientHTTP1::Send(const HTTPRequest& request) {
|
|||
// read fails. Nothing is replayed once a response byte has arrived.
|
||||
for (int attempt = 0;; ++attempt) {
|
||||
const bool reused = impl->tcp != nullptr;
|
||||
if (!reused) impl->Connect();
|
||||
if (!reused) impl->Connect(handshakeTimeout);
|
||||
|
||||
bool received = false;
|
||||
try {
|
||||
|
|
|
|||
Loading…
Reference in a new issue