Crafter.Network/implementations/Crafter.Network-ClientHTTP1.cpp
catbot 9c22cbe09e 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>
2026-07-28 20:13:30 +00:00

178 lines
6.6 KiB
C++

//SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
module;
#include <cerrno>
module Crafter.Network:ClientHTTP1_impl;
import :ClientHTTP1;
import :ClientTCP;
import :HTTP;
import :HTTP1;
import :Stream;
import :TLS;
import Crafter.Thread;
import std;
using namespace Crafter;
namespace {
// Host header value. The port is elided when it is the scheme default,
// which is what every other HTTP/1.1 client on the wire does and what
// virtual-host matching on the far side tends to expect.
std::string DefaultAuthority(const std::string& host, std::uint16_t port, bool secure) {
if (port == (secure ? 443 : 80)) return host;
return host + ":" + std::to_string(port);
}
}
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(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();
}
// One request/response exchange on the current connection. `received`
// is set as soon as any response byte arrives, which is what decides
// whether a failure is safe to replay on a new connection.
HTTPResponse Exchange(const std::string& wire, std::string_view method,
const HTTP1::MessageLimits& limits,
std::chrono::milliseconds timeout, bool& received) {
stream->Write(wire.data(), wire.size(), timeout);
HTTP1::MessageParser parser(HTTP1::MessageKind::Response, limits);
parser.SetRequestMethod(method);
std::vector<char> chunk(16 * 1024);
while (!parser.Complete()) {
std::size_t read = 0;
const StreamStatus status = stream->ReadSome(chunk.data(), chunk.size(),
timeout, read);
if (status == StreamStatus::TimedOut) {
throw std::runtime_error("timed out waiting for the response");
}
if (status == StreamStatus::Closed) {
// Peer closed. Completes a response framed by close;
// anything else throws out of Finish().
parser.Finish();
break;
}
received = true;
parser.Feed(chunk.data(), read);
}
if (!parser.Complete()) {
throw std::runtime_error("connection closed before a complete response arrived");
}
HTTPResponse response = parser.TakeResponse();
if (!parser.KeepAlive()) Close();
return response;
}
};
ClientHTTP1::ClientHTTP1(const char* host, std::uint16_t port)
: host(host), port(port), impl(std::make_unique<Impl>(std::string(host), 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;
bool ClientHTTP1::Connected() const noexcept {
return impl && impl->tcp != nullptr;
}
bool ClientHTTP1::Secure() const noexcept {
return impl && impl->tls != nullptr;
}
std::string_view ClientHTTP1::Protocol() const noexcept {
return impl ? std::string_view(impl->protocol) : std::string_view();
}
void ClientHTTP1::Disconnect() {
if (impl) impl->Close();
}
HTTPResponse ClientHTTP1::Send(const HTTPRequest& request) {
HTTPRequest prepared = request;
if (prepared.authority.empty()) {
prepared.authority = DefaultAuthority(host, port, Secure());
}
const std::string wire = HTTP1::SerializeRequest(prepared);
const std::string method = prepared.method.empty() ? std::string("GET") : prepared.method;
// Two attempts at most, and the second one only for the keep-alive
// race: the peer may have closed a pooled connection at the same moment
// we wrote to it, which is indistinguishable from success until the
// read fails. Nothing is replayed once a response byte has arrived.
for (int attempt = 0;; ++attempt) {
const bool reused = impl->tcp != nullptr;
if (!reused) impl->Connect(handshakeTimeout);
bool received = false;
try {
return impl->Exchange(wire, method, limits, timeout, received);
} catch (const std::exception&) {
impl->Close();
if (attempt == 0 && reused && !received) continue;
throw;
}
}
}
void ClientHTTP1::SendAsync(const HTTPRequest& request,
std::function<void(HTTPResponse)> onSuccess,
std::function<void(std::string)> onError) {
HTTPRequest copy = request;
ThreadPool::Enqueue([this, copy = std::move(copy),
onSuccess = std::move(onSuccess),
onError = std::move(onError)]() mutable {
try {
HTTPResponse response = this->Send(copy);
if (onSuccess) onSuccess(std::move(response));
} catch (const std::exception& e) {
if (onError) onError(e.what());
} catch (...) {
if (onError) onError("unknown error");
}
});
}