diff --git a/implementations/Crafter.Network-ListenerHTTP1.cpp b/implementations/Crafter.Network-ListenerHTTP1.cpp index c61e888..7cd0460 100644 --- a/implementations/Crafter.Network-ListenerHTTP1.cpp +++ b/implementations/Crafter.Network-ListenerHTTP1.cpp @@ -235,7 +235,9 @@ struct ListenerHTTP1::Impl { ListenerHTTP1::ListenerHTTP1(std::uint16_t port, std::unordered_map> routes) - : ListenerHTTP1(port, std::move(routes), {}) + // Spelled out rather than `{}`: with a TLS overload also taking a third + // argument, a braced empty initialiser no longer names one constructor. + : ListenerHTTP1(port, std::move(routes), std::function{}) {} ListenerHTTP1::ListenerHTTP1(std::uint16_t port, @@ -255,7 +257,8 @@ ListenerHTTP1::ListenerHTTP1(std::uint16_t port, ListenerHTTP1::ListenerHTTP1(std::uint16_t port, std::unordered_map> routes, TLSServerCredentials credentials) - : ListenerHTTP1(port, std::move(routes), {}, std::move(credentials)) + : ListenerHTTP1(port, std::move(routes), + std::function{}, std::move(credentials)) {} ListenerHTTP1::ListenerHTTP1(std::uint16_t port, diff --git a/project.cpp b/project.cpp index 6b2d238..59fd6ec 100644 --- a/project.cpp +++ b/project.cpp @@ -134,10 +134,13 @@ extern "C" Configuration CrafterBuildProject(std::span a cfg.AddTest("ShouldEchoWebTransport").Dependencies({ &cfg }); cfg.AddTest("ShouldFallbackUnknownRoutes").Dependencies({ &cfg }); cfg.AddTest("ShouldInteropCurlHTTP1").Dependencies({ &cfg }); + cfg.AddTest("ShouldInteropCurlHTTPS1").Dependencies({ &cfg }); cfg.AddTest("ShouldNotDropEarlyStreams").Dependencies({ &cfg }); cfg.AddTest("ShouldParseHTTP1").Dependencies({ &cfg }); + cfg.AddTest("ShouldRequireClientCertificateHTTPS1").Dependencies({ &cfg }); cfg.AddTest("ShouldSend").Dependencies({ &cfg }); cfg.AddTest("ShouldSendRecieveHTTP1").Dependencies({ &cfg }); + cfg.AddTest("ShouldSendRecieveHTTPS1").Dependencies({ &cfg }); cfg.AddTest("ShouldSendRecieveKeepaliveHTTP1").Dependencies({ &cfg }); cfg.AddTest("ShouldSendRecieveLargeHTTP1").Dependencies({ &cfg }); cfg.AddTest("ShouldSendRecieveHTTP").Dependencies({ &cfg }); diff --git a/tests/ShouldInteropCurlHTTPS1/main.cpp b/tests/ShouldInteropCurlHTTPS1/main.cpp new file mode 100644 index 0000000..40125bb --- /dev/null +++ b/tests/ShouldInteropCurlHTTPS1/main.cpp @@ -0,0 +1,257 @@ +//SPDX-License-Identifier: LGPL-3.0-only +//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// TLS interop against implementations that are not this library. Speaking +// HTTP/1.1 correctly to ourselves proves very little about the record layer — +// an OpenSSL client and an OpenSSL server can agree on a mistake. +// +// * curl drives our TLS listener, verifying our certificate properly with +// --cacert: keep-alive reuse, POST, HEAD, ALPN, and a status line. +// * ClientHTTP1 drives python3's http.server behind ssl.wrap_socket, which +// answers HTTP/1.0 with `Connection: close` — so the response is framed by +// close_notify rather than by content-length. +// +// Both peers are optional: a missing curl or python3 skips its half rather +// than failing, so the suite still runs on a bare machine. + +#include +#include +#include +#include + +import Crafter.Network; +import std; +using namespace Crafter; + +namespace { + int failures = 0; + + void Check(bool condition, std::string_view what) { + if (!condition) { + std::println("FAIL: {}", what); + ++failures; + } + } + + bool HaveCommand(std::string_view name) { + const std::string probe = "command -v " + std::string(name) + " >/dev/null 2>&1"; + return std::system(probe.c_str()) == 0; + } + + std::string Run(const std::string& command) { + std::string output; + FILE* pipe = popen((command + " 2>&1").c_str(), "r"); + if (pipe == nullptr) return output; + char buffer[4096]; + while (std::size_t read = std::fread(buffer, 1, sizeof(buffer), pipe)) { + output.append(buffer, read); + } + pclose(pipe); + return output; + } + + // A child process, killed when this goes out of scope. + class Child { + public: + explicit Child(std::vector argv) { + std::vector raw; + for (auto& argument : argv) raw.push_back(argument.data()); + raw.push_back(nullptr); + pid = fork(); + if (pid == 0) { + freopen("/dev/null", "w", stdout); + freopen("/dev/null", "w", stderr); + execvp(raw[0], raw.data()); + _exit(127); + } + } + ~Child() { + if (pid > 0) { + kill(pid, SIGTERM); + int status = 0; + waitpid(pid, &status, 0); + } + } + Child(const Child&) = delete; + bool Started() const { return pid > 0; } + + private: + pid_t pid = -1; + }; + + bool WaitForPort(std::uint16_t port, std::chrono::milliseconds budget) { + const auto deadline = std::chrono::steady_clock::now() + budget; + while (std::chrono::steady_clock::now() < deadline) { + try { + ClientTCP probe("localhost", port); + return true; + } catch (const std::exception&) { + std::this_thread::sleep_for(std::chrono::milliseconds(25)); + } + } + return false; + } + + // The development certificate on disk, so peer processes (curl, python) + // can be pointed at it. + struct CertificateFiles { + std::filesystem::path directory; + std::filesystem::path certificate; + std::filesystem::path privateKey; + + CertificateFiles() { + directory = std::filesystem::temp_directory_path() / "crafter-network-https1-interop"; + std::filesystem::create_directories(directory); + certificate = directory / "cert.pem"; + privateKey = directory / "key.pem"; + const TLSCertificatePem& pem = GetSelfSignedCertificatePem(); + std::ofstream(certificate, std::ios::binary) << pem.certificate; + std::ofstream(privateKey, std::ios::binary) << pem.privateKey; + } + ~CertificateFiles() { + std::error_code error; + std::filesystem::remove_all(directory, error); + } + CertificateFiles(const CertificateFiles&) = delete; + }; + + void CurlAgainstListener(const CertificateFiles& files) { + if (!HaveCommand("curl")) { + std::println("skipping the curl half: curl is not installed"); + return; + } + + std::unordered_map> routes; + routes["/hello"] = [](const HTTPRequest&) { + return CreateResponseHTTP("200", {{"content-type", "text/plain"}}, "Hello curl!"); + }; + routes["/echo"] = [](const HTTPRequest& request) { + return CreateResponseHTTP("200", request.method + ":" + request.body); + }; + routes["/scheme"] = [](const HTTPRequest& request) { + return CreateResponseHTTP("200", request.scheme); + }; + + ListenerAsyncHTTP1 listener(8097, std::move(routes), + TLSServerCredentials{ .selfSigned = true }); + Check(WaitForPort(8097, std::chrono::seconds(2)), "the HTTPS listener came up"); + + // --cacert, not --insecure: curl does the full chain and hostname + // check, so this is a real verification of what we present. + const std::string base = "https://localhost:8097"; + const std::string curl = "curl -sS --http1.1 --cacert '" + files.certificate.string() + "' "; + + Check(Run(curl + base + "/hello") == "Hello curl!", "curl GET over TLS"); + Check(Run(curl + base + "/scheme") == "https", "the handler sees scheme=https"); + + // Two URLs in one invocation: curl reuses the TLS session, which only + // works if our framing let it know the first response ended. + const std::uint64_t before = listener.listener.AcceptedCount(); + Check(Run(curl + base + "/hello " + base + "/hello") == "Hello curl!Hello curl!", + "curl got both responses"); + Check(listener.listener.AcceptedCount() == before + 1, + "curl reused one TLS connection for both"); + + Check(Run(curl + "-d 'body text' " + base + "/echo") == "POST:body text", + "curl POST over TLS"); + + const std::string head = Run(curl + "-I " + base + "/hello"); + Check(head.find("HTTP/1.1 200 OK") != std::string::npos, "curl HEAD status line"); + Check(head.find("content-length: 11") != std::string::npos, "curl HEAD keeps content-length"); + Check(head.find("Hello curl!") == std::string::npos, "curl HEAD carries no body"); + + Check(Run(curl + "-o /dev/null -w '%{http_code}' " + base + "/missing") == "404", + "curl reads the 404 status"); + + // curl offers h2 and http/1.1 by default; ours advertises only + // http/1.1, so ALPN has to land there. `-w %{...}` reports what the + // handshake actually agreed on rather than what we hoped for. + Check(Run(curl + "-o /dev/null -w '%{http_version}' " + base + "/hello") == "1.1", + "ALPN settled on HTTP/1.1"); + + // A client offering only h2 shares no protocol with us. RFC 7301 says + // that is a fatal no_application_protocol alert, not a downgrade. + if (Run("curl -sS --http2-prior-knowledge -o /dev/null -w '%{http_code}' --cacert '" + + files.certificate.string() + "' " + base + "/hello").find("200") + == std::string::npos) { + Check(true, "an h2-only client is refused rather than mis-served"); + } else { + Check(false, "an h2-only client is refused rather than mis-served"); + } + + listener.Stop(); + } + + void ClientAgainstPythonServer(const CertificateFiles& files) { + if (!HaveCommand("python3")) { + std::println("skipping the python half: python3 is not installed"); + return; + } + + const std::filesystem::path root = files.directory / "www"; + std::filesystem::create_directories(root); + const std::string content = "served by python over TLS\n"; + std::ofstream(root / "hello.txt", std::ios::binary) << content; + + // http.server answers HTTP/1.0 with `Connection: close`, so over TLS + // the response body is framed by close_notify — the path our reader + // has to treat as an orderly end rather than a truncation. + const std::string script = + "import functools, http.server, ssl\n" + "context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)\n" + "context.load_cert_chain('" + files.certificate.string() + "', '" + + files.privateKey.string() + "')\n" + "handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory='" + + root.string() + "')\n" + "server = http.server.HTTPServer(('127.0.0.1', 8098), handler)\n" + "server.socket = context.wrap_socket(server.socket, server_side=True)\n" + "server.serve_forever()\n"; + + Child server({"python3", "-c", script}); + Check(server.Started(), "python3 TLS http.server was spawned"); + if (!WaitForPort(8098, std::chrono::seconds(10))) { + std::println("skipping the python half: the TLS http.server never came up"); + return; + } + + // Verified against the certificate on disk, exercising caPath (a file) + // rather than the caPem blob the self-contained test uses. + TLSClientCredentials credentials; + credentials.caPath = files.certificate.string(); + ClientHTTP1 client("localhost", 8098, credentials); + + HTTPResponse response = client.Send(CreateRequestHTTP("GET", "/hello.txt", "localhost:8098")); + Check(response.status == "200", "python TLS GET status"); + Check(response.body == content, "python TLS GET body"); + Check(!client.Connected(), "an HTTP/1.0 response closes the TLS connection"); + + HTTPResponse listing = client.Send(CreateRequestHTTP("GET", "/", "localhost:8098")); + Check(listing.status == "200", "python TLS directory listing status"); + Check(listing.body.find("hello.txt") != std::string::npos, + "python TLS directory listing body"); + + HTTPResponse missing = client.Send(CreateRequestHTTP("GET", "/nothing-here", "localhost:8098")); + Check(missing.status == "404", "python TLS 404"); + + HTTPResponse head = client.Send(CreateRequestHTTP("HEAD", "/hello.txt", "localhost:8098")); + Check(head.status == "200", "python TLS HEAD status"); + Check(head.body.empty(), "python TLS HEAD has no body"); + } +} + +int main() { + try { + CertificateFiles files; + CurlAgainstListener(files); + ClientAgainstPythonServer(files); + } catch (const std::exception& error) { + std::println("threw: {}", error.what()); + return 1; + } + + if (failures != 0) { + std::println("{} check(s) failed", failures); + return 1; + } + return 0; +} diff --git a/tests/ShouldRequireClientCertificateHTTPS1/main.cpp b/tests/ShouldRequireClientCertificateHTTPS1/main.cpp new file mode 100644 index 0000000..b4db348 --- /dev/null +++ b/tests/ShouldRequireClientCertificateHTTPS1/main.cpp @@ -0,0 +1,204 @@ +//SPDX-License-Identifier: LGPL-3.0-only +//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// Mutual TLS on the HTTP/1.1 listener, plus the raw :TLS stream API on its +// own. A server that asks for a client certificate is only useful if it also +// refuses the peers that do not have one, so both directions are asserted. +// +// The certificate authority and client certificate are minted with the openssl +// CLI: a client certificate needs the clientAuth extended key usage, which the +// library's built-in development certificate (serverAuth, for listeners) does +// not carry. Without openssl the mTLS half is skipped. + +import Crafter.Network; +import std; +using namespace Crafter; + +namespace { + int failures = 0; + + void Check(bool condition, std::string_view what) { + if (!condition) { + std::println("FAIL: {}", what); + ++failures; + } + } + + bool HaveCommand(std::string_view name) { + const std::string probe = "command -v " + std::string(name) + " >/dev/null 2>&1"; + return std::system(probe.c_str()) == 0; + } + + std::unordered_map> Routes() { + std::unordered_map> routes; + routes["/"] = [](const HTTPRequest&) { + return CreateResponseHTTP("200", "authenticated"); + }; + return routes; + } + + // A throwaway CA and a client certificate signed by it. + struct ClientIdentity { + std::filesystem::path directory; + std::filesystem::path authority; + std::filesystem::path certificate; + std::filesystem::path privateKey; + bool ok = false; + + ClientIdentity() { + directory = std::filesystem::temp_directory_path() / "crafter-network-mtls"; + std::error_code error; + std::filesystem::remove_all(directory, error); + std::filesystem::create_directories(directory); + authority = directory / "ca.pem"; + certificate = directory / "client.pem"; + privateKey = directory / "client-key.pem"; + + const std::string extensions = (directory / "client.ext").string(); + std::ofstream(extensions, std::ios::binary) + << "basicConstraints=critical,CA:FALSE\n" + << "keyUsage=critical,digitalSignature\n" + << "extendedKeyUsage=clientAuth\n"; + + const std::string command = std::format( + "set -e\n" + "openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes" + " -keyout '{0}/ca-key.pem' -out '{1}' -days 5 -subj '/CN=Crafter Test CA'" + " -addext 'basicConstraints=critical,CA:TRUE'" + " -addext 'keyUsage=critical,keyCertSign'\n" + "openssl req -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes" + " -keyout '{2}' -out '{0}/client.csr' -subj '/CN=crafter-test-client'\n" + "openssl x509 -req -in '{0}/client.csr' -CA '{1}' -CAkey '{0}/ca-key.pem'" + " -set_serial 2 -days 5 -extfile '{0}/client.ext' -out '{3}'\n", + directory.string(), authority.string(), privateKey.string(), + certificate.string()); + ok = std::system((command + " >/dev/null 2>&1").c_str()) == 0; + } + ~ClientIdentity() { + std::error_code error; + std::filesystem::remove_all(directory, error); + } + ClientIdentity(const ClientIdentity&) = delete; + }; + + // The :TLS layer without any HTTP on top — the case for anything else that + // owns a connected socket and wants a record layer over it. + void RawStreamAgainstListener() { + ListenerAsyncHTTP1 listener(8099, Routes(), TLSServerCredentials{ .selfSigned = true }); + + TLSClientCredentials credentials; + credentials.caPem = GetSelfSignedCertificatePem().certificate; + auto context = TLSContext::Client(credentials); + + ClientTCP socket("localhost", 8099); + std::unique_ptr stream = + TLSStream::Connect(socket.socketid, context, "localhost", + std::chrono::seconds(5)); + + Check(stream->Secure(), "a TLSStream reports itself as secure"); + Check(stream->Protocol() == "http/1.1", "the raw stream negotiated ALPN"); + Check(stream->Version().starts_with("TLS"), "a TLS version was negotiated"); + // The listener presents the development certificate, whose subject is + // CN=localhost. + Check(stream->PeerCertificateSubject().find("localhost") != std::string::npos, + "the server certificate subject is readable"); + + // Hand-written HTTP/1.1 straight down the stream, to prove the record + // layer is usable on its own. + const std::string request = "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"; + stream->Write(request.data(), request.size(), std::chrono::seconds(5)); + + HTTP1::MessageParser parser(HTTP1::MessageKind::Response); + std::vector chunk(4096); + while (!parser.Complete()) { + std::size_t read = 0; + const StreamStatus status = + stream->ReadSome(chunk.data(), chunk.size(), std::chrono::seconds(5), read); + if (status != StreamStatus::Data) break; + parser.Feed(chunk.data(), read); + } + Check(parser.Complete(), "a response arrived over the raw TLS stream"); + if (parser.Complete()) { + HTTPResponse response = parser.TakeResponse(); + Check(response.status == "200", "raw TLS stream response status"); + Check(response.body == "authenticated", "raw TLS stream response body"); + } + + stream.reset(); + listener.Stop(); + } + + void MutualTLS() { + if (!HaveCommand("openssl")) { + std::println("skipping the mTLS half: openssl is not installed"); + return; + } + ClientIdentity identity; + if (!identity.ok) { + std::println("skipping the mTLS half: could not mint a client certificate"); + return; + } + + TLSServerCredentials server; + server.selfSigned = true; + server.requireClientCertificate = true; + server.clientCaPath = identity.authority.string(); + ListenerAsyncHTTP1 listener(8100, Routes(), server); + + // A client with a certificate the listener's CA vouches for. + { + TLSClientCredentials credentials; + credentials.caPem = GetSelfSignedCertificatePem().certificate; + credentials.certPath = identity.certificate.string(); + credentials.keyPath = identity.privateKey.string(); + ClientHTTP1 client("localhost", 8100, credentials); + + HTTPResponse response = client.Send(CreateRequestHTTP("GET", "/", "localhost")); + Check(response.status == "200", "a client with a trusted certificate is served"); + Check(response.body == "authenticated", "mTLS response body"); + } + Check(listener.listener.HandshakeFailureCount() == 0, + "a valid client certificate is not a handshake failure"); + + // The same client, with no certificate at all. Under TLS 1.3 the + // server's rejection arrives after the client believes the handshake + // finished, so the failure can surface at connect *or* on the first + // exchange — either is a refusal, and neither may be a success. + { + TLSClientCredentials credentials; + credentials.caPem = GetSelfSignedCertificatePem().certificate; + ClientHTTP1 anonymous("localhost", 8100, credentials); + bool refused = false; + try { + anonymous.Send(CreateRequestHTTP("GET", "/", "localhost")); + } catch (const std::exception&) { + refused = true; + } + Check(refused, "a client with no certificate is refused"); + } + for (int wait = 0; wait < 100; ++wait) { + if (listener.listener.HandshakeFailureCount() > 0) break; + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + } + Check(listener.listener.HandshakeFailureCount() == 1, + "the refused client is counted as a handshake failure"); + + listener.Stop(); + } +} + +int main() { + try { + RawStreamAgainstListener(); + MutualTLS(); + } catch (const std::exception& error) { + std::println("threw: {}", error.what()); + return 1; + } + + if (failures != 0) { + std::println("{} check(s) failed", failures); + return 1; + } + return 0; +} diff --git a/tests/ShouldSendRecieveHTTPS1/main.cpp b/tests/ShouldSendRecieveHTTPS1/main.cpp new file mode 100644 index 0000000..6e9a629 --- /dev/null +++ b/tests/ShouldSendRecieveHTTPS1/main.cpp @@ -0,0 +1,187 @@ +//SPDX-License-Identifier: LGPL-3.0-only +//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// The HTTP/1.1 round-trip of ShouldSendRecieveHTTP1, over TLS. The point is +// that nothing above the transport changed: the same routes, the same +// keep-alive reuse, the same 404/500 behaviour, now with libssl underneath. +// +// Also covers what only exists under TLS: ALPN, `scheme` reported as https, +// certificate verification against a private trust anchor, and the two ways +// verification is supposed to fail. + +import Crafter.Network; +import std; +using namespace Crafter; + +namespace { + int failures = 0; + + void Check(bool condition, std::string_view what) { + if (!condition) { + std::println("FAIL: {}", what); + ++failures; + } + } + + std::unordered_map> Routes() { + std::unordered_map> routes; + routes["/"] = [](const HTTPRequest&) { + return CreateResponseHTTP("200", "Hello World!"); + }; + routes["/echo"] = [](const HTTPRequest& request) { + return CreateResponseHTTP("200", {{"content-type", "text/plain"}}, request.body); + }; + routes["/scheme"] = [](const HTTPRequest& request) { + return CreateResponseHTTP("200", request.scheme); + }; + routes["/query"] = [](const HTTPRequest& request) { + return CreateResponseHTTP("200", request.path); + }; + routes["/boom"] = [](const HTTPRequest&) -> HTTPResponse { + throw std::runtime_error("handler exploded"); + }; + return routes; + } +} + +int main() { + try { + // The listener mints an ephemeral certificate; the client is handed + // that same certificate as a trust anchor, so this exercises real + // chain *and* hostname verification rather than skipping both. + ListenerAsyncHTTP1 listener(8095, Routes(), TLSServerCredentials{ .selfSigned = true }); + Check(listener.listener.Secure(), "the listener reports itself as https"); + + TLSClientCredentials credentials; + credentials.caPem = GetSelfSignedCertificatePem().certificate; + ClientHTTP1 client("localhost", 8095, credentials); + Check(client.Secure(), "the client reports itself as https"); + + HTTPResponse hello = client.Send(CreateRequestHTTP("GET", "/", "localhost")); + Check(hello.status == "200", "GET / status"); + Check(hello.body == "Hello World!", "GET / body"); + Check(hello.headers.contains("date"), "the server stamps a Date header"); + Check(hello.headers.at("content-length") == "12", "content-length matches the body"); + + // ALPN is the whole reason a TLS server can tell HTTP/1.1 from h2 + // before reading a byte, so assert it actually got negotiated. + Check(client.Protocol() == "http/1.1", "ALPN negotiated http/1.1"); + + HTTPResponse echoed = client.Send(CreateRequestHTTP("POST", "/echo", "localhost", + std::string("ping pong"))); + Check(echoed.status == "200", "POST /echo status"); + Check(echoed.body == "ping pong", "POST /echo returns the request body"); + Check(echoed.headers.at("content-type") == "text/plain", "handler headers survive"); + + // Origin-form targets carry no scheme; the transport has to supply it. + HTTPResponse scheme = client.Send(CreateRequestHTTP("GET", "/scheme", "localhost")); + Check(scheme.body == "https", "the handler sees scheme=https over TLS"); + + HTTPResponse query = client.Send(CreateRequestHTTP("GET", "/query?a=1&b=2", "localhost")); + Check(query.status == "200", "query-string request routes to the bare path"); + Check(query.body == "/query?a=1&b=2", "the handler sees the full target"); + + HTTPResponse missing = client.Send(CreateRequestHTTP("GET", "/nope", "localhost")); + Check(missing.status == "404", "unknown route is a 404"); + + HTTPResponse head = client.Send(CreateRequestHTTP("HEAD", "/", "localhost")); + Check(head.status == "200", "HEAD status"); + Check(head.body.empty(), "HEAD has no body"); + Check(head.headers.at("content-length") == "12", "HEAD still advertises the length"); + + HTTPResponse boom = client.Send(CreateRequestHTTP("GET", "/boom", "localhost")); + Check(boom.status == "500", "a throwing handler yields 500"); + Check(boom.body.find("handler exploded") != std::string::npos, "500 carries the reason"); + + // A body big enough to span many TLS records, to catch a Write() that + // mishandles a partial SSL_write. + const std::string large(512 * 1024, 'z'); + HTTPResponse bulk = client.Send(CreateRequestHTTP("POST", "/echo", "localhost", large)); + Check(bulk.status == "200", "large POST status"); + Check(bulk.body == large, "a body spanning many TLS records survives intact"); + + // Every exchange above shared one TLS session — no rehandshaking per + // request, which is what makes keep-alive worth having here. + Check(listener.listener.AcceptedCount() == 1, "the whole test used a single connection"); + Check(client.Connected(), "the connection is still pooled"); + Check(listener.listener.HandshakeFailureCount() == 0, "no handshake failed"); + + // ── Verification has to actually fail when it should ────────────── + // Default credentials: system trust store only, so a self-signed + // certificate must be rejected rather than quietly accepted. + { + ClientHTTP1 strict("localhost", 8095, TLSClientCredentials{}); + bool rejected = false; + try { + strict.Send(CreateRequestHTTP("GET", "/", "localhost")); + } catch (const TLSException&) { + rejected = true; + } + Check(rejected, "an untrusted self-signed certificate is rejected"); + Check(!strict.Connected(), "a rejected connection is not left pooled"); + } + + // Right certificate, wrong name: the chain checks out but the SANs say + // localhost, so the name check has to catch it. Verifying the chain + // without the name is the classic way TLS gets deployed insecurely. + { + TLSClientCredentials mismatched; + mismatched.caPem = GetSelfSignedCertificatePem().certificate; + mismatched.serverName = "not-localhost.invalid"; + ClientHTTP1 wrongName("localhost", 8095, mismatched); + bool rejected = false; + try { + wrongName.Send(CreateRequestHTTP("GET", "/", "localhost")); + } catch (const TLSException&) { + rejected = true; + } + Check(rejected, "a certificate for the wrong name is rejected"); + } + + // insecureNoServerValidation is the dev escape hatch; it has to work, + // because the alternative is people shipping their own worse one. + { + ClientHTTP1 insecure("localhost", 8095, + TLSClientCredentials{ .insecureNoServerValidation = true }); + HTTPResponse response = insecure.Send(CreateRequestHTTP("GET", "/", "localhost")); + Check(response.body == "Hello World!", "insecureNoServerValidation talks to the same server"); + } + + // A plaintext client against a TLS listener: its request line is not a + // TLS record, so the handshake fails and the server counts it. This is + // what a port scanner or a misconfigured caller looks like, and it + // must not disturb anything else. + { + const std::uint64_t before = listener.listener.HandshakeFailureCount(); + try { + ClientHTTP1 plaintext("localhost", 8095); + plaintext.Send(CreateRequestHTTP("GET", "/", "localhost")); + } catch (const std::exception&) { + // Expected: the listener drops it without answering. + } + // The handshake is rejected on the connection thread, so give it a + // moment to record the failure before reading the counter. + for (int wait = 0; wait < 100; ++wait) { + if (listener.listener.HandshakeFailureCount() > before) break; + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + } + Check(listener.listener.HandshakeFailureCount() == before + 1, + "a plaintext peer is counted as a handshake failure"); + } + + // And the TLS listener still serves after all that. + HTTPResponse after = client.Send(CreateRequestHTTP("GET", "/", "localhost")); + Check(after.body == "Hello World!", "the listener still serves after a bad peer"); + + listener.Stop(); + } catch (const std::exception& error) { + std::println("threw: {}", error.what()); + return 1; + } + + if (failures != 0) { + std::println("{} check(s) failed", failures); + return 1; + } + return 0; +}