From d6f8f4edcb41eba5883c3029bc6446a126bd9fc7 Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Sat, 31 Oct 2026 05:06:41 +0100 Subject: [PATCH] feat(tcp): ListenerTCP binds the address it is given, at port 0 if asked A new constructor takes a numeric bind address (127.0.0.1, ::1, 0.0.0.0, ::) and accepts port 0, and Port() reports the port the listener got. Until now every listener bound 0.0.0.0 at a port the caller chose, so a helper meant for this machine's own processes was reachable from the network, and the caller had to guess a free port. A name such as `localhost` is refused instead of bound to whichever address the resolver ranks first. The port-only constructor still listens on every IPv4 interface. `running` is atomic now: Stop() runs on another thread than the accept loop that reads it. The accept loops take a sockaddr_storage, so an IPv6 peer's address is no longer cut short. New: ShouldBindLoopbackEphemeral. Co-Authored-By: Claude Opus 5.5 --- README.md | 10 ++ .../Crafter.Network-ListenerTCP.cpp | 53 +++++-- interfaces/Crafter.Network-ListenerTCP.cppm | 68 +++++---- project.cpp | 1 + tests/ShouldBindLoopbackEphemeral/main.cpp | 143 ++++++++++++++++++ 5 files changed, 235 insertions(+), 40 deletions(-) create mode 100644 tests/ShouldBindLoopbackEphemeral/main.cpp diff --git a/README.md b/README.md index ca19e69..b47bd56 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,15 @@ Crafter::ListenerTCP listener(8080, callback); listener.ListenSyncSync(); // Synchronous listening ``` +The port-only constructor listens on every IPv4 interface. To keep a listener to this machine, or to let the kernel pick the port, name the address and pass port 0; `Port()` reports the port it got. The address must be numeric (`127.0.0.1`, `::1`, `0.0.0.0`, `::`): a name such as `localhost` is refused rather than bound to whichever address the resolver ranks first. + +```cpp +Crafter::ListenerTCP broker("127.0.0.1", 0, callback); +const std::uint16_t port = broker.Port(); +std::thread([&] { broker.ListenSyncSync(); }).detach(); +// ... broker.Stop() ends the loop from another thread. +``` + ### HTTP/3 Components HTTP/3 runs over QUIC, which always requires TLS. Pass server credentials when constructing the listener (or set `selfSigned = true` for a development-only ephemeral cert) and matching client credentials when constructing the client (`insecureNoServerValidation = true` for self-signed servers). @@ -301,6 +310,7 @@ The library includes tests covering: - Mutual TLS and the raw stream (`ShouldRequireClientCertificateHTTPS1`) — a client certificate the listener's CA vouches for is served and one absent is refused; `TLSStream` is also driven directly with hand-written HTTP/1.1 to keep `:TLS` usable without `:ClientHTTP1` on top - Dual-stack connect (`ShouldConnectDualStack`) — `localhost` reaches a server on `::1` only and one on `127.0.0.1` only, an IPv6 literal connects over IPv6, and the last failure is what is thrown when no address answers; skips on a host without IPv6 loopback - Connect errors (`ShouldReportConnectErrors`) — a refused connect and an unresolvable name throw `SocketErrorException` with their errno or getaddrinfo code and the message ClientTCP always threw, and still reach a `std::runtime_error` handler +- Loopback listeners (`ShouldBindLoopbackEphemeral`) — a listener on `127.0.0.1` at port 0 reports its port, takes loopback clients and refuses them through the host's other address; `::1` works too, a name is refused, and the port-only constructor still listens on every interface The external-interop test requires outbound UDP/443; if your network blocks it the test will fail. `ShouldInteropCurlHTTP1` and `ShouldInteropCurlHTTPS1` skip whichever half is unavailable when `curl` or `python3` is not installed, and the mutual-TLS half of `ShouldRequireClientCertificateHTTPS1` skips without the `openssl` CLI (a *client* certificate needs the `clientAuth` extended key usage, which the built-in development certificate does not carry) — so all three pass on a bare machine. Install `curl`, `python3` and `openssl` to actually exercise them. diff --git a/implementations/Crafter.Network-ListenerTCP.cpp b/implementations/Crafter.Network-ListenerTCP.cpp index 57e956f..29f92ed 100755 --- a/implementations/Crafter.Network-ListenerTCP.cpp +++ b/implementations/Crafter.Network-ListenerTCP.cpp @@ -12,6 +12,7 @@ module; #include #include #include +#include #include module Crafter.Network:ListenerTCP_impl; @@ -21,14 +22,30 @@ import Crafter.Thread; using namespace Crafter; -ListenerTCP::ListenerTCP(std::uint16_t port, std::function connectCallback, std::uint32_t concurrentClientLimit, std::uint32_t totalClientLimit) : connectCallback(connectCallback), concurrentClientLimit(concurrentClientLimit), totalClientLimit(totalClientLimit) { - sockaddr_in servAddr; - bzero((char*)&servAddr, sizeof(servAddr)); - servAddr.sin_family = AF_INET; - servAddr.sin_addr.s_addr = htonl(INADDR_ANY); - servAddr.sin_port = htons(port); +ListenerTCP::ListenerTCP(std::uint16_t port, std::function connectCallback, std::uint32_t concurrentClientLimit, std::uint32_t totalClientLimit) + : ListenerTCP("0.0.0.0", port, std::move(connectCallback), concurrentClientLimit, totalClientLimit) {} - s = socket(AF_INET, SOCK_STREAM, 0); +ListenerTCP::ListenerTCP(std::string_view bindAddress, std::uint16_t port, std::function connectCallback, std::uint32_t concurrentClientLimit, std::uint32_t totalClientLimit) : connectCallback(connectCallback), concurrentClientLimit(concurrentClientLimit), totalClientLimit(totalClientLimit) { + // AI_NUMERICHOST: no lookup, so `localhost` is refused here rather than + // bound to whichever of ::1 and 127.0.0.1 the resolver ranks first. + addrinfo hints{}; + hints.ai_family = AF_UNSPEC; + hints.ai_socktype = SOCK_STREAM; + hints.ai_flags = AI_PASSIVE | AI_NUMERICHOST | AI_NUMERICSERV; + const std::string host(bindAddress); + const std::string service = std::to_string(port); + addrinfo* resolved = nullptr; + const int status = getaddrinfo(host.c_str(), service.c_str(), &hints, &resolved); + if (status != 0 || resolved == nullptr) { + throw std::runtime_error("Could not bind " + host + ": not a numeric IPv4 or IPv6 address (" + gai_strerror(status) + ")"); + } + sockaddr_storage servAddr {}; + std::memcpy(&servAddr, resolved->ai_addr, resolved->ai_addrlen); + const socklen_t servAddrLength = resolved->ai_addrlen; + const int family = resolved->ai_family; + freeaddrinfo(resolved); + + s = socket(family, SOCK_STREAM, 0); if(s < 0) { throw std::runtime_error("Error establishing the server socket"); @@ -39,7 +56,7 @@ ListenerTCP::ListenerTCP(std::uint16_t port, std::function con int reuse = 1; setsockopt(s, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)); - if(bind(s, (struct sockaddr*) &servAddr, sizeof(servAddr)) < 0) { + if(bind(s, reinterpret_cast(&servAddr), servAddrLength) < 0) { // Ignoring this left the listener silently accepting nothing at // all, with no hint as to why. const std::string reason = std::strerror(errno); @@ -55,6 +72,18 @@ ListenerTCP::ListenerTCP(std::uint16_t port, std::function con } } +std::uint16_t ListenerTCP::Port() const { + sockaddr_storage address {}; + socklen_t length = sizeof(address); + if (getsockname(s, reinterpret_cast(&address), &length) == -1) { + throw std::runtime_error(std::string("Could not read the listening port: ") + std::strerror(errno)); + } + if (address.ss_family == AF_INET6) { + return ntohs(reinterpret_cast(&address)->sin6_port); + } + return ntohs(reinterpret_cast(&address)->sin_port); +} + void ListenerTCP::Stop() { running = false; shutdown(s, SHUT_RDWR); @@ -64,7 +93,7 @@ void ListenerTCP::Stop() { void ListenerTCP::ListenSyncSync() { while (running && totalClientCounter < totalClientLimit) { - sockaddr_in newSockAddr; + sockaddr_storage newSockAddr; socklen_t newSockAddrSize = sizeof(newSockAddr); int client = accept(s, (sockaddr*)&newSockAddr, &newSockAddrSize); if (client > 0) { @@ -81,7 +110,7 @@ void ListenerTCP::ListenSyncSync() { void ListenerTCP::ListenSyncAsync() { while (running && totalClientCounter < totalClientLimit) { - sockaddr_in newSockAddr; + sockaddr_storage newSockAddr; socklen_t newSockAddrSize = sizeof(newSockAddr); int client = accept(s, (sockaddr*)&newSockAddr, &newSockAddrSize); if (client > 0) { @@ -97,7 +126,7 @@ void ListenerTCP::ListenSyncAsync() { void ListenerTCP::ListenAsyncSync() { ThreadPool::Enqueue([this]() { while (running && totalClientCounter < totalClientLimit) { - sockaddr_in newSockAddr; + sockaddr_storage newSockAddr; socklen_t newSockAddrSize = sizeof(newSockAddr); int client = accept(s, (sockaddr*)&newSockAddr, &newSockAddrSize); if (client > 0) { @@ -114,7 +143,7 @@ void ListenerTCP::ListenAsyncSync() { void ListenerTCP::ListenAsyncAsync() { ThreadPool::Enqueue([this]() { while (running && totalClientCounter < totalClientLimit) { - sockaddr_in newSockAddr; + sockaddr_storage newSockAddr; socklen_t newSockAddrSize = sizeof(newSockAddr); int client = accept(s, (sockaddr*)&newSockAddr, &newSockAddrSize); if (client > 0) { diff --git a/interfaces/Crafter.Network-ListenerTCP.cppm b/interfaces/Crafter.Network-ListenerTCP.cppm index 7795b67..d41253e 100755 --- a/interfaces/Crafter.Network-ListenerTCP.cppm +++ b/interfaces/Crafter.Network-ListenerTCP.cppm @@ -1,28 +1,40 @@ -//SPDX-License-Identifier: LGPL-3.0-only -//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® - -export module Crafter.Network:ListenerTCP; -import std; -import :ClientTCP; - -#ifndef CRAFTER_NETWORK_BROWSER -namespace Crafter { - export class ListenerTCP { - public: - bool running = true; - std::function connectCallback; - std::uint32_t concurrentClientLimit; - std::uint32_t totalClientLimit; - ListenerTCP(std::uint16_t port, std::function connectCallback, std::uint32_t concurrentClientLimit = std::numeric_limits::max(), std::uint32_t totalClientLimit = std::numeric_limits::max()); - ~ListenerTCP(); - void Stop(); - void ListenSyncSync(); - void ListenSyncAsync(); - void ListenAsyncSync(); - void ListenAsyncAsync(); - private: - std::uint32_t totalClientCounter = 0; - int s; - }; -} -#endif \ No newline at end of file +//SPDX-License-Identifier: LGPL-3.0-only +//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +export module Crafter.Network:ListenerTCP; +import std; +import :ClientTCP; + +#ifndef CRAFTER_NETWORK_BROWSER +namespace Crafter { + export class ListenerTCP { + public: + // Atomic: Stop() is called from another thread than the one running + // an accept loop, which reads this on every turn. + std::atomic running = true; + std::function connectCallback; + std::uint32_t concurrentClientLimit; + std::uint32_t totalClientLimit; + // Every IPv4 interface (0.0.0.0) at `port`. + ListenerTCP(std::uint16_t port, std::function connectCallback, std::uint32_t concurrentClientLimit = std::numeric_limits::max(), std::uint32_t totalClientLimit = std::numeric_limits::max()); + // `bindAddress` is a numeric IPv4 or IPv6 address — 127.0.0.1 or ::1 + // for this machine alone, 0.0.0.0 or :: for every interface — and a + // name such as `localhost` is refused: the address decides who can + // reach the listener, so it is spelled out rather than looked up. A + // `port` of 0 lets the kernel pick a free one; Port() says which. + ListenerTCP(std::string_view bindAddress, std::uint16_t port, std::function connectCallback, std::uint32_t concurrentClientLimit = std::numeric_limits::max(), std::uint32_t totalClientLimit = std::numeric_limits::max()); + ~ListenerTCP(); + // The port the listener is bound to: the one asked for, or the + // kernel's pick when that was 0. + std::uint16_t Port() const; + void Stop(); + void ListenSyncSync(); + void ListenSyncAsync(); + void ListenAsyncSync(); + void ListenAsyncAsync(); + private: + std::uint32_t totalClientCounter = 0; + int s; + }; +} +#endif diff --git a/project.cpp b/project.cpp index 66b1019..b54f9c7 100644 --- a/project.cpp +++ b/project.cpp @@ -176,6 +176,7 @@ extern "C" Configuration CrafterBuildProject(std::span a cfg.AddTest("ShouldRecieveTCP").Dependencies({ &cfg }); cfg.AddTest("ShouldConnectDualStack").Dependencies({ &cfg }); cfg.AddTest("ShouldReportConnectErrors").Dependencies({ &cfg }); + cfg.AddTest("ShouldBindLoopbackEphemeral").Dependencies({ &cfg }); cfg.AddTest("ShouldInteropCurlHTTP1").Dependencies({ &cfg }); cfg.AddTest("ShouldInteropCurlHTTPS1").Dependencies({ &cfg }); cfg.AddTest("ShouldParseHTTP1").Dependencies({ &cfg }); diff --git a/tests/ShouldBindLoopbackEphemeral/main.cpp b/tests/ShouldBindLoopbackEphemeral/main.cpp new file mode 100644 index 0000000..a897e22 --- /dev/null +++ b/tests/ShouldBindLoopbackEphemeral/main.cpp @@ -0,0 +1,143 @@ +//SPDX-License-Identifier: LGPL-3.0-only +//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// ListenerTCP can be told the address it binds and take port 0, with Port() +// reporting what the kernel picked. Before, it always bound 0.0.0.0 at a port +// the caller chose: a helper meant for this machine's own processes — a broker +// that hands out database access, say — was reachable from the network, and +// the caller had to guess a free port and hope. +// +// Also: a host name is refused rather than bound to whichever address the +// resolver ranks first, and the port-only constructor keeps listening on every +// IPv4 interface. + +#include +#include +#include +#include +#include +#include +#include + +import Crafter.Network; +import std; +using namespace Crafter; +using namespace std::chrono_literals; + +namespace { + +int failures = 0; + +void Check(bool ok, std::string_view what) { + std::println("{} {}", ok ? "PASS" : "FAIL", what); + if (!ok) ++failures; +} + +// An IPv4 address of this host that is not loopback, if it has one. +std::optional NonLoopbackIPv4() { + ifaddrs* interfaces = nullptr; + if (getifaddrs(&interfaces) != 0) return std::nullopt; + std::optional found; + for (const ifaddrs* entry = interfaces; entry != nullptr && !found; entry = entry->ifa_next) { + if (entry->ifa_addr == nullptr || entry->ifa_addr->sa_family != AF_INET) continue; + const auto* v4 = reinterpret_cast(entry->ifa_addr); + if (ntohl(v4->sin_addr.s_addr) >> 24 == 127) continue; + char text[INET_ADDRSTRLEN] = {}; + inet_ntop(AF_INET, &v4->sin_addr, text, sizeof(text)); + found = std::string(text); + } + freeifaddrs(interfaces); + return found; +} + +// Runs a listener's accept loop on a thread of its own and counts the +// connections it was handed. +struct Serving { + ListenerTCP& listener; + std::atomic accepted = 0; + std::thread loop; + + explicit Serving(ListenerTCP& listener) : listener(listener) { + listener.connectCallback = [this](ClientTCP* client) { + ++accepted; + delete client; + }; + loop = std::thread([this] { this->listener.ListenSyncSync(); }); + } + ~Serving() { + listener.Stop(); + loop.join(); + } + bool WaitFor(int count) { + for (int i = 0; i < 200 && accepted.load() < count; ++i) std::this_thread::sleep_for(10ms); + return accepted.load() >= count; + } +}; + +} // namespace + +int main() { + { + ListenerTCP listener("127.0.0.1", 0, nullptr); + const std::uint16_t port = listener.Port(); + Check(port != 0, std::format("port 0 gets a port from the kernel, which Port() reports ({})", port)); + Serving serving(listener); + { + ClientTCP client("127.0.0.1", port, 2s); + } + Check(serving.WaitFor(1), "a client on loopback reaches it"); + + if (const std::optional outside = NonLoopbackIPv4()) { + bool refused = false; + try { + ClientTCP client(*outside, port, 2s); + } catch (const SocketErrorException& e) { + refused = e.code == ECONNREFUSED; + } catch (const std::exception&) { + } + Check(refused, std::format("through {} it is refused: it listens on 127.0.0.1 alone", *outside)); + } else { + std::println("SKIP no non-loopback IPv4 address to try from"); + } + } + + { + std::optional v6; + try { + v6.emplace("::1", 0, nullptr); + } catch (const std::exception&) { + } + if (v6) { + Serving serving(*v6); + { + ClientTCP client("::1", v6->Port(), 2s); + } + Check(serving.WaitFor(1), "an IPv6 loopback listener takes a client on ::1"); + } else { + std::println("SKIP no IPv6 loopback on this host"); + } + } + + { + bool refused = false; + try { + ListenerTCP named("localhost", 0, nullptr); + } catch (const std::runtime_error&) { + refused = true; + } + Check(refused, "a host name is refused: the address to bind is spelled out"); + } + + { + ListenerTCP any(0, nullptr); + Check(any.Port() != 0, "the port-only constructor takes port 0 as well"); + Serving serving(any); + { + ClientTCP client("127.0.0.1", any.Port(), 2s); + } + Check(serving.WaitFor(1), "and still listens on every IPv4 interface, loopback included"); + } + + std::println("{}", failures == 0 ? "all checks passed" : "check(s) failed"); + return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE; +}