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 <noreply@anthropic.com>
This commit is contained in:
Jorijn van der Graaf 2026-10-31 05:06:41 +01:00
commit d6f8f4edcb
Signed by: jorijnvdgraaf
GPG key ID: 2937E59CDCC1BCFB
5 changed files with 235 additions and 40 deletions

View file

@ -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.

View file

@ -12,6 +12,7 @@ module;
#include <sys/time.h>
#include <sys/wait.h>
#include <strings.h>
#include <netdb.h>
#include <cerrno>
module Crafter.Network:ListenerTCP_impl;
@ -21,14 +22,30 @@ import Crafter.Thread;
using namespace Crafter;
ListenerTCP::ListenerTCP(std::uint16_t port, std::function<void(ClientTCP*)> 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<void(ClientTCP*)> 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<void(ClientTCP*)> 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<void(ClientTCP*)> 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<const sockaddr*>(&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<void(ClientTCP*)> con
}
}
std::uint16_t ListenerTCP::Port() const {
sockaddr_storage address {};
socklen_t length = sizeof(address);
if (getsockname(s, reinterpret_cast<sockaddr*>(&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<const sockaddr_in6*>(&address)->sin6_port);
}
return ntohs(reinterpret_cast<const sockaddr_in*>(&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) {

View file

@ -9,12 +9,24 @@ import :ClientTCP;
namespace Crafter {
export class ListenerTCP {
public:
bool running = true;
// Atomic: Stop() is called from another thread than the one running
// an accept loop, which reads this on every turn.
std::atomic<bool> running = true;
std::function<void(ClientTCP*)> 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<void(ClientTCP*)> connectCallback, std::uint32_t concurrentClientLimit = std::numeric_limits<std::uint32_t>::max(), std::uint32_t totalClientLimit = std::numeric_limits<std::uint32_t>::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<void(ClientTCP*)> connectCallback, std::uint32_t concurrentClientLimit = std::numeric_limits<std::uint32_t>::max(), std::uint32_t totalClientLimit = std::numeric_limits<std::uint32_t>::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();

View file

@ -176,6 +176,7 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> 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 });

View file

@ -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 <arpa/inet.h>
#include <ifaddrs.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <unistd.h>
#include <cerrno>
#include <cstdlib>
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<std::string> NonLoopbackIPv4() {
ifaddrs* interfaces = nullptr;
if (getifaddrs(&interfaces) != 0) return std::nullopt;
std::optional<std::string> 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<const sockaddr_in*>(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<int> 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<std::string> 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<ListenerTCP> 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;
}