182 lines
8 KiB
C++
182 lines
8 KiB
C++
|
|
//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);
|
||
|
|
}
|