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:
parent
78853033e8
commit
d6f8f4edcb
5 changed files with 235 additions and 40 deletions
10
README.md
10
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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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<void(ClientTCP*)> connectCallback;
|
||||
std::uint32_t concurrentClientLimit;
|
||||
std::uint32_t totalClientLimit;
|
||||
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());
|
||||
~ListenerTCP();
|
||||
void Stop();
|
||||
void ListenSyncSync();
|
||||
void ListenSyncAsync();
|
||||
void ListenAsyncSync();
|
||||
void ListenAsyncAsync();
|
||||
private:
|
||||
std::uint32_t totalClientCounter = 0;
|
||||
int s;
|
||||
};
|
||||
}
|
||||
#endif
|
||||
//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<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();
|
||||
void ListenAsyncSync();
|
||||
void ListenAsyncAsync();
|
||||
private:
|
||||
std::uint32_t totalClientCounter = 0;
|
||||
int s;
|
||||
};
|
||||
}
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
143
tests/ShouldBindLoopbackEphemeral/main.cpp
Normal file
143
tests/ShouldBindLoopbackEphemeral/main.cpp
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue