test(listener): cover ListenerQUIC's bind address

ShouldBindLoopbackQUIC: a listener on 127.0.0.1 at port 0 serves a
client through 127.0.0.1 and refuses, within 3 s of a 6 s handshake
timeout, one through this host's first non-loopback IPv4 address (found
with getifaddrs; that half is skipped with a line saying so when there
is none) and one through ::1; only the served connection reaches
connectCallback. A listener on ::1 serves ::1 and refuses 127.0.0.1, and
`localhost` is refused as a bind address.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jorijn van der Graaf 2026-11-01 01:48:30 +01:00
commit a50e3f415c
Signed by: jorijnvdgraaf
GPG key ID: 2937E59CDCC1BCFB
2 changed files with 183 additions and 0 deletions

View file

@ -0,0 +1,182 @@
//SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// ListenerQUIC can be told which local address to listen on. Before, it
// always listened on every address of both families
// (QUIC_ADDRESS_FAMILY_UNSPEC), so a server meant for this machine's own
// processes, such as a debug server embedded in an app, was reachable from
// the network.
//
// msquic binds the UDP port on every address whatever it is given and
// filters by the address a connection was sent to at its own layer, so what
// is checked is the connection: one to 127.0.0.1 is served, while one to
// this machine's LAN address, or to ::1, fails, and fails at once (msquic
// refuses it outright) rather than after the handshake timeout.
//
// Also: ::1 works as a bind address, a name is refused, and only the
// connections that were served reach connectCallback.
#include <arpa/inet.h>
#include <ifaddrs.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <cstdio>
import Crafter.Network;
import Crafter.Thread;
import std;
using namespace Crafter;
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;
}
bool HasIPv6Loopback() {
ifaddrs* interfaces = nullptr;
if (getifaddrs(&interfaces) != 0) return false;
bool found = false;
for (const ifaddrs* entry = interfaces; entry != nullptr && !found; entry = entry->ifa_next) {
if (entry->ifa_addr == nullptr || entry->ifa_addr->sa_family != AF_INET6) continue;
const auto* v6 = reinterpret_cast<const sockaddr_in6*>(entry->ifa_addr);
found = IN6_IS_ADDR_LOOPBACK(&v6->sin6_addr);
}
freeifaddrs(interfaces);
return found;
}
const std::string alpn = "crafter/test-bind";
QUICClientCredentials ClientCredentials() {
QUICClientCredentials creds;
creds.insecureNoServerValidation = true;
// Long enough that a connection msquic silently dropped would show as
// slow below, short enough to stay inside the watchdog.
creds.settings.handshakeIdleTimeoutMs = 6000;
return creds;
}
// One stream to host:port and back. Empty when the echo did not come back.
std::string Echo(const std::string& host, std::uint16_t port, std::string_view text) {
ClientQUIC client(host, port, alpn, ClientCredentials());
QUICStream stream = client.OpenStream();
stream.SendSync(text.data(), static_cast<std::uint32_t>(text.size()), true);
std::vector<char> echoed = stream.RecieveUntilCloseSync();
return std::string(echoed.begin(), echoed.end());
}
// Connects to host:port and reports how long it took to be refused; nullopt
// when the connection was served instead.
std::optional<std::chrono::milliseconds> RefusedAfter(const std::string& host, std::uint16_t port) {
const auto start = std::chrono::steady_clock::now();
try {
ClientQUIC client(host, port, alpn, ClientCredentials());
return std::nullopt;
} catch (const QUICException& e) {
const auto took = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - start);
std::println(" ({} refused after {} ms: {})", host, took.count(), e.what());
return took;
}
}
} // namespace
int main() {
ThreadPool::Start();
std::thread([] {
std::this_thread::sleep_for(std::chrono::seconds(45));
std::println("FAIL timed out");
std::fflush(stdout);
std::_Exit(1);
}).detach();
QUICServerCredentials serverCreds;
serverCreds.selfSigned = true;
std::atomic<int> accepted = 0;
auto serve = [&](ClientQUIC* peer) {
++accepted;
peer->OnStream([](QUICStream stream) {
try {
std::vector<char> got = stream.RecieveUntilCloseSync();
stream.SendSync(got.data(), static_cast<std::uint32_t>(got.size()), true);
} catch (...) {}
});
};
// A refusal well inside the handshake timeout is msquic turning the
// connection away; one at the timeout is a silent drop.
constexpr std::chrono::milliseconds atOnce{ 3000 };
try {
// ── 127.0.0.1 ────────────────────────────────────────────────────────
ListenerQUIC loopback("127.0.0.1", 0, alpn, serverCreds, serve);
loopback.ListenAsyncAsync();
const std::uint16_t port = loopback.Port();
Check(port != 0, std::format("a listener on 127.0.0.1 at port 0 reports its port ({})", port));
Check(Echo("127.0.0.1", port, "loopback") == "loopback", "it serves a client that connects through 127.0.0.1");
if (const std::optional<std::string> lan = NonLoopbackIPv4()) {
const std::optional<std::chrono::milliseconds> refused = RefusedAfter(*lan, port);
Check(refused.has_value(), std::format("a client that connects through this host's LAN address {} is not served", *lan));
Check(refused && *refused < atOnce, "and is refused at once, not timed out");
} else {
std::println("skipping the LAN half: this host has no IPv4 address but loopback");
}
if (HasIPv6Loopback()) {
const std::optional<std::chrono::milliseconds> refused = RefusedAfter("::1", port);
Check(refused.has_value() && *refused < atOnce, "nor is one through ::1, the other loopback address");
}
Check(accepted.load() == 1, std::format("only the served connection reached connectCallback ({})", accepted.load()));
// ── ::1 ──────────────────────────────────────────────────────────────
if (HasIPv6Loopback()) {
ListenerQUIC loopback6("::1", 0, alpn, serverCreds, serve);
loopback6.ListenAsyncAsync();
Check(Echo("::1", loopback6.Port(), "v6") == "v6", "a listener on ::1 serves a client that connects through ::1");
const std::optional<std::chrono::milliseconds> refused = RefusedAfter("127.0.0.1", loopback6.Port());
Check(refused.has_value() && *refused < atOnce, "and refuses one through 127.0.0.1");
} else {
std::println("skipping the ::1 half: this host has no IPv6 loopback");
}
// ── A name ───────────────────────────────────────────────────────────
bool threw = false;
try {
ListenerQUIC named("localhost", 0, alpn, serverCreds, serve);
} catch (const QUICException& e) {
threw = true;
std::println(" ({})", e.what());
}
Check(threw, "a name such as localhost is refused as a bind address");
} catch (const std::exception& e) {
Check(false, std::format("threw: {}", e.what()));
}
std::println("{}", failures == 0 ? "all checks passed" : "check(s) failed");
// As the other QUIC tests: skip the static teardown, which waits for the
// accepted peers' idle timeout. _Exit doesn't flush.
std::fflush(stdout);
std::_Exit(failures == 0 ? 0 : 1);
}