feat(listener): ListenerQUIC listens on the address it is given

A new constructor takes a numeric bind address first, as ListenerTCP's
does: 127.0.0.1 or ::1 for this machine alone, 0.0.0.0 or :: for every
address of one family. Until now every QUIC listener 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. A name such as `localhost` is
refused (AI_NUMERICHOST) rather than bound to whichever address the
resolver ranks first. The port-only constructor keeps listening on every
address.

msquic binds the dual-stack wildcard UDP socket for the port whatever
address it is given, and filters by the address a connection was sent
to in its own listener lookup (QuicBindingGetListener). A connection to
another address of the machine is closed with no_application_protocol
during its first flight, so the client fails at once
(QUIC_STATUS_ALPN_NEG_FAILURE, 4 ms on loopback here) rather than
waiting out its handshake timeout, and never reaches connectCallback.

Both constructors now share Impl::Start, which leaves nothing open when
it throws.

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 bffb757ab7
Signed by: jorijnvdgraaf
GPG key ID: 2937E59CDCC1BCFB
2 changed files with 112 additions and 33 deletions

View file

@ -6,6 +6,8 @@ module;
#include <cstdlib>
#include <cstdio>
#include <cstring>
#include <netdb.h>
#include <sys/socket.h>
#include <unistd.h>
module Crafter.Network:ListenerQUIC_impl;
import :ListenerQUIC;
@ -62,6 +64,33 @@ namespace {
return r;
}
// `bindAddress` at `port`, for ListenerStart. AI_NUMERICHOST, as
// ListenerTCP resolves its bind address: `localhost` is refused here
// rather than bound to whichever of ::1 and 127.0.0.1 the resolver
// ranks first, since the address decides who can reach the listener.
// QUIC_ADDR is the sockaddr_in/sockaddr_in6 union on POSIX, so the
// resolver's answer copies straight in.
QUIC_ADDR NumericAddress(std::string_view bindAddress, std::uint16_t port) {
addrinfo hints{};
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_DGRAM;
hints.ai_flags = AI_PASSIVE | AI_NUMERICHOST;
const std::string host(bindAddress);
addrinfo* resolved = nullptr;
const int status = getaddrinfo(host.c_str(), nullptr, &hints, &resolved);
if (status != 0 || resolved == nullptr) {
throw QUICException(std::format(
"could not listen on '{}': not a numeric IPv4 or IPv6 address ({})",
host, gai_strerror(status)));
}
QUIC_ADDR address{};
std::memcpy(&address, resolved->ai_addr,
std::min<std::size_t>(resolved->ai_addrlen, sizeof(address)));
freeaddrinfo(resolved);
QuicAddrSetPort(&address, port);
return address;
}
// Lazily generate (and cache) a self-signed cert + key pair on disk by
// shelling out to the openssl CLI. Used by ListenerQUIC when the caller
// passes QUICServerCredentials{selfSigned=true}. The pair lives in a
@ -171,6 +200,10 @@ struct ListenerQUIC::Impl {
// by raw pointer; running it past the destructor is a use-after-free).
std::thread acceptLoop;
// Open the configuration and the listener and start it on `address`,
// or throw with nothing left open. Shared by both constructors.
void Start(const QUIC_ADDR& address, const QUICServerCredentials& creds);
static QUIC_STATUS QUIC_API Callback(HQUIC, void* ctx, QUIC_LISTENER_EVENT* ev) {
auto* self = static_cast<Impl*>(ctx);
switch (ev->Type) {
@ -268,6 +301,54 @@ static HQUIC OpenServerConfiguration(const std::string& alpn,
return cfg;
}
void ListenerQUIC::Impl::Start(const QUIC_ADDR& address, const QUICServerCredentials& creds) {
configuration = OpenServerConfiguration(outer->alpn, creds, alpnBuf);
QUIC_STATUS s = Runtime().api->ListenerOpen(Runtime().registration,
reinterpret_cast<QUIC_LISTENER_CALLBACK_HANDLER>(&Impl::Callback),
this, &listener);
if (QUIC_FAILED(s)) {
Runtime().api->ConfigurationClose(configuration);
configuration = nullptr;
throw QUICException(std::format("ListenerOpen failed: 0x{:x}", static_cast<unsigned>(s)));
}
QUIC_BUFFER alpnBuffer{};
alpnBuffer.Length = static_cast<std::uint32_t>(alpnBuf.size());
alpnBuffer.Buffer = alpnBuf.data();
// Whatever the address, msquic binds the dual-stack wildcard socket for
// the port and matches a new connection's local address against this
// one itself (listener.c, QuicBindingGetListener).
s = Runtime().api->ListenerStart(listener, &alpnBuffer, 1, &address);
if (QUIC_FAILED(s)) {
Runtime().api->ListenerClose(listener);
Runtime().api->ConfigurationClose(configuration);
listener = nullptr;
configuration = nullptr;
throw QUICException(std::format("ListenerStart failed: 0x{:x}", static_cast<unsigned>(s)));
}
// ListenerStart writes the port of the UDP socket it bound into the
// listener's local address when it was asked for port 0, and only
// then; read it back rather than trusting the argument. The UNSPEC
// address still carries it: the port sits at the same offset in
// sockaddr_in and sockaddr_in6, and QuicAddrGetPort reads the latter
// for anything that is not INET.
QUIC_ADDR bound{};
std::uint32_t boundSize = sizeof(bound);
s = Runtime().api->GetParam(listener, QUIC_PARAM_LISTENER_LOCAL_ADDRESS, &boundSize, &bound);
if (QUIC_FAILED(s)) {
Runtime().api->ListenerClose(listener);
Runtime().api->ConfigurationClose(configuration);
listener = nullptr;
configuration = nullptr;
throw QUICException(std::format("GetParam(QUIC_PARAM_LISTENER_LOCAL_ADDRESS) failed: 0x{:x}",
static_cast<unsigned>(s)));
}
port = QuicAddrGetPort(&bound);
}
ListenerQUIC::ListenerQUIC(std::uint16_t port,
std::string alpnIn,
QUICServerCredentials creds,
@ -281,47 +362,28 @@ ListenerQUIC::ListenerQUIC(std::uint16_t port,
, impl(std::make_unique<Impl>())
{
impl->outer = this;
impl->configuration = OpenServerConfiguration(alpn, creds, impl->alpnBuf);
QUIC_STATUS s = Runtime().api->ListenerOpen(Runtime().registration,
reinterpret_cast<QUIC_LISTENER_CALLBACK_HANDLER>(&Impl::Callback),
impl.get(), &impl->listener);
if (QUIC_FAILED(s)) {
Runtime().api->ConfigurationClose(impl->configuration);
throw QUICException(std::format("ListenerOpen failed: 0x{:x}", static_cast<unsigned>(s)));
}
QUIC_BUFFER alpnBuffer{};
alpnBuffer.Length = static_cast<std::uint32_t>(impl->alpnBuf.size());
alpnBuffer.Buffer = impl->alpnBuf.data();
// UNSPEC: every address of both families.
QUIC_ADDR addr{};
QuicAddrSetFamily(&addr, QUIC_ADDRESS_FAMILY_UNSPEC);
QuicAddrSetPort(&addr, port);
s = Runtime().api->ListenerStart(impl->listener, &alpnBuffer, 1, &addr);
if (QUIC_FAILED(s)) {
Runtime().api->ListenerClose(impl->listener);
Runtime().api->ConfigurationClose(impl->configuration);
throw QUICException(std::format("ListenerStart failed: 0x{:x}", static_cast<unsigned>(s)));
impl->Start(addr, creds);
}
// ListenerStart writes the port of the UDP socket it bound into the
// listener's local address when it was asked for port 0, and only
// then; read it back rather than trusting `port`. The UNSPEC address
// still carries it: the port sits at the same offset in sockaddr_in
// and sockaddr_in6, and QuicAddrGetPort reads the latter for anything
// that is not INET.
QUIC_ADDR bound{};
std::uint32_t boundSize = sizeof(bound);
s = Runtime().api->GetParam(impl->listener, QUIC_PARAM_LISTENER_LOCAL_ADDRESS, &boundSize, &bound);
if (QUIC_FAILED(s)) {
Runtime().api->ListenerClose(impl->listener);
Runtime().api->ConfigurationClose(impl->configuration);
throw QUICException(std::format("GetParam(QUIC_PARAM_LISTENER_LOCAL_ADDRESS) failed: 0x{:x}",
static_cast<unsigned>(s)));
}
impl->port = QuicAddrGetPort(&bound);
ListenerQUIC::ListenerQUIC(std::string_view bindAddress,
std::uint16_t port,
std::string alpnIn,
QUICServerCredentials creds,
std::function<void(ClientQUIC*)> cb,
std::uint32_t concurrentClientLimit,
std::uint32_t totalClientLimit)
: connectCallback(std::move(cb))
, concurrentClientLimit(concurrentClientLimit)
, totalClientLimit(totalClientLimit)
, alpn(std::move(alpnIn))
, impl(std::make_unique<Impl>())
{
impl->outer = this;
impl->Start(NumericAddress(bindAddress, port), creds);
}
std::uint16_t ListenerQUIC::Port() const {

View file

@ -27,12 +27,29 @@ namespace Crafter {
std::uint32_t totalClientLimit;
std::string alpn;
// Every address of both families at `port`.
ListenerQUIC(std::uint16_t port,
std::string alpn,
QUICServerCredentials creds,
std::function<void(ClientQUIC*)> connectCallback,
std::uint32_t concurrentClientLimit = std::numeric_limits<std::uint32_t>::max(),
std::uint32_t totalClientLimit = std::numeric_limits<std::uint32_t>::max());
// One local address. `bindAddress` is a numeric IPv4 or IPv6 address
// (127.0.0.1 or ::1 for this machine alone, 0.0.0.0 for every IPv4
// address, :: for every IPv6 one) and a name such as `localhost` is
// refused with QUICException: the address decides who can reach the
// listener, so it is spelled out rather than looked up, as
// ListenerTCP takes it. msquic still binds the UDP port on every
// address and filters by the address a connection was sent to, at
// its own layer: one sent to another address of this machine fails
// its handshake at once, refused as having no application protocol.
ListenerQUIC(std::string_view bindAddress,
std::uint16_t port,
std::string alpn,
QUICServerCredentials creds,
std::function<void(ClientQUIC*)> connectCallback,
std::uint32_t concurrentClientLimit = std::numeric_limits<std::uint32_t>::max(),
std::uint32_t totalClientLimit = std::numeric_limits<std::uint32_t>::max());
~ListenerQUIC();
ListenerQUIC(const ListenerQUIC&) = delete;