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
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);
|
||||
}
|
||||
Loading…
Reference in a new issue