diff --git a/project.cpp b/project.cpp index 5b978dd..d214ef7 100644 --- a/project.cpp +++ b/project.cpp @@ -192,6 +192,7 @@ extern "C" Configuration CrafterBuildProject(std::span a cfg.AddTest("ShouldSendRecieveLargeHTTP1").Dependencies({ &cfg }); cfg.AddTest("ShouldSurviveAbuseHTTP1").Dependencies({ &cfg }); if (quic) { + cfg.AddTest("ShouldBindLoopbackQUIC").Dependencies({ &cfg }); cfg.AddTest("ShouldEchoWebTransport").Dependencies({ &cfg }); cfg.AddTest("ShouldFallbackUnknownRoutes").Dependencies({ &cfg }); cfg.AddTest("ShouldHonourQUICSettings").Dependencies({ &cfg }); diff --git a/tests/ShouldBindLoopbackQUIC/main.cpp b/tests/ShouldBindLoopbackQUIC/main.cpp new file mode 100644 index 0000000..8ef72b5 --- /dev/null +++ b/tests/ShouldBindLoopbackQUIC/main.cpp @@ -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 +#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); +}