//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 #include #include #include #include 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 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; } 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(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(text.size()), true); std::vector 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 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::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 accepted = 0; auto serve = [&](ClientQUIC* peer) { ++accepted; peer->OnStream([](QUICStream stream) { try { std::vector got = stream.RecieveUntilCloseSync(); stream.SendSync(got.data(), static_cast(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 lan = NonLoopbackIPv4()) { const std::optional 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 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 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); }