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>
178 lines
6.6 KiB
C++
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");
|
|
}
|
|
});
|
|
}
|