Commit 60f5f4e0a7 for bind

commit 60f5f4e0a7d847a50250288f8ba7de0c1fe3644c
Author: Aydın Mercan <aydin@isc.org>
Date:   Sat Jul 4 11:11:21 2026 +0300

    Add QUIC specific routing mechanisms

    QUIC is a protocol that is agnostic of the source address/port and uses
    connection IDs and stateless reset tokens to distinguish sessions.

    The thread-safe `isc_quic_router_t` structure keeps track of this
    information and also has functionality for inspecting packets to find
    the appropriate action alongside the associated connection if it exists.

diff --git a/lib/isc/include/isc/quic.h b/lib/isc/include/isc/quic.h
new file mode 100644
index 0000000000..a0af5b8000
--- /dev/null
+++ b/lib/isc/include/isc/quic.h
@@ -0,0 +1,296 @@
+/*
+ * Copyright (C) Internet Systems Consortium, Inc. ("ISC")
+ *
+ * SPDX-License-Identifier: MPL-2.0
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at https://mozilla.org/MPL/2.0/.
+ *
+ * See the COPYRIGHT file distributed with this work for additional
+ * information regarding copyright ownership.
+ */
+
+#pragma once
+
+#include <isc/refcount.h>
+#include <isc/region.h>
+#include <isc/result.h>
+#include <isc/tid.h>
+#include <isc/types.h>
+
+/*! \file isc/quic.h */
+
+/**
+ * \brief
+ * Minimum allowed length of a CID.
+ *
+ * Specified in RFC9000, Section 17.2.
+ */
+#define ISC_QUIC_CID_MIN_LENGTH 1
+
+/**
+ * \brief
+ * Maximum allowed length of a CID.
+ *
+ * Specified in RFC9000, Section 17.2.
+ */
+#define ISC_QUIC_CID_MAX_LENGTH 20
+
+/**
+ * \brief
+ * Length of the stateless reset token.
+ *
+ * Specified in RFC9000, Section 10.3.
+ */
+#define ISC_QUIC_STATELESS_TOKEN_LENGTH 16
+
+/**
+ * \brief
+ * Thread-safe data structure for associating CID's and stateless reset tokens
+ * to QUIC connections.
+ */
+typedef struct isc_quic_router isc_quic_router_t;
+
+/**
+ * \brief
+ * Callbacks the router uses to keep a connection alive while it is reachable
+ * through the router or has been handed out by a lookup.
+ *
+ * `conn_ref` must acquire a reference on `conn`; `conn_unref` must release one.
+ * They allow the router to hand out a retained connection pointer without
+ * racing a concurrent teardown.
+ *
+ * \warning
+ * `conn_unref` may be invoked from an RCU worker thread, because the router
+ * releases its own reference from a deferred-reclamation (call_rcu) callback.
+ * It must therefore be safe to call from any thread.
+ */
+typedef void (*isc_quic_conn_ref_t)(void *conn);
+typedef void (*isc_quic_conn_unref_t)(void *conn);
+
+typedef enum isc_quic_version {
+	/** Invalid QUIC version */
+	ISC_QUIC_VERSION_INVALID = 0,
+
+	/** Unknown QUIC version */
+	ISC_QUIC_VERSION_UNKNOWN = 1,
+
+	/** RFC9000 */
+	ISC_QUIC_VERSION_V1 = 2,
+
+	/** RFC9369 */
+	ISC_QUIC_VERSION_V2 = 3,
+
+	ISC_QUIC_VERSION__MAX = 4,
+} isc_quic_version_t;
+
+void
+isc_quic_router_create(isc_mem_t *mctx, size_t cidlen,
+		       isc_quic_conn_ref_t   conn_ref,
+		       isc_quic_conn_unref_t conn_unref,
+		       isc_quic_router_t   **routerp);
+/**<
+ * \brief
+ * Create a new QUIC CID router.
+ *
+ * `cidlen` is the length of the Connection IDs this endpoint issues. It is
+ * needed to route incoming Short header (1-RTT) packets, whose Destination
+ * Connection ID length is not carried on the wire (RFC9000, Section 5.2).
+ *
+ * `conn_ref` and `conn_unref` acquire and release a reference on a connection.
+ * The router holds a reference for as long as a connection is reachable and
+ * takes an extra one on every value it hands out (see isc_quic_router_get_cid()
+ * and isc_quic_router_get_stateless_reset()).
+ *
+ * \par Requires:
+ * \li `mctx` is a valid memory context.
+ * \li `cidlen` ∈ [1, 20]
+ * \li `conn_ref != NULL` and `conn_unref != NULL`
+ * \li `routerp != NULL` and `*routerp == NULL`
+ */
+
+ISC_REFCOUNT_DECL(isc_quic_router);
+
+isc_result_t
+isc_quic_router_add_cid(isc_quic_router_t *router, isc_constregion_t cid,
+			isc_tid_t tid, void *conn);
+/**<
+ * \brief
+ * Associate a connection with the given CID.
+ *
+ * The CID will be copied and thus the parameter free from any lifetime
+ * requirements.
+ *
+ * `tid` is stored alongside the connection and returned by
+ * isc_quic_router_get_cid(); it identifies the loop that owns `conn`.
+ *
+ * \par Requires:
+ * \li `router` is a valid router.
+ * \li `cid.base != NULL` and `cid.length` ∈ [1, 20]
+ * \li `tid >= 0`
+ * \li `conn != NULL`
+ *
+ * \retval ISC_R_SUCCESS on success
+ * \retval ISC_R_EXISTS if a connection exists for the given CID
+ */
+
+isc_result_t
+isc_quic_router_get_cid(isc_quic_router_t *router, isc_constregion_t cid,
+			isc_tid_t *tidp, void **connp);
+/**<
+ * \brief
+ * Get the connection associated with a given CID and its thread if `tidp` is
+ * not NULL.
+ *
+ * \note
+ * On success `*connp` carries an extra reference on the connection, taken with
+ * the router's `conn_ref` callback; the caller must release it with the
+ * matching `conn_unref` once done. This keeps the connection alive across the
+ * handoff to its owning `tid`, closing the race with a concurrent teardown.
+ *
+ * \par Requires:
+ * \li `router` is a valid router.
+ * \li `cid.base != NULL` and `cid.length` ∈ [1, 20]
+ *
+ * \retval ISC_R_SUCCESS on success
+ * \retval ISC_R_NOTFOUND if no connection exists for the given CID.
+ */
+
+isc_result_t
+isc_quic_router_del_cid(isc_quic_router_t *router, isc_constregion_t cid);
+/**<
+ * \brief
+ * Remove the connection associated with the given CID.
+ *
+ * \par Requires:
+ * \li `router` is a valid router.
+ * \li `cid.base != NULL` and `cid.length` ∈ [1, 20]
+ *
+ * \retval ISC_R_SUCCESS on success
+ * \retval ISC_R_NOTFOUND if no connection exists for the given CID.
+ */
+
+isc_result_t
+isc_quic_router_add_stateless_reset(
+	isc_quic_router_t *router,
+	const uint8_t	   token[restrict ISC_QUIC_STATELESS_TOKEN_LENGTH],
+	isc_tid_t tid, void *conn);
+/**<
+ * \brief
+ * Associate a connection with the given stateless reset token.
+ *
+ * `tid` is stored alongside the connection and returned by
+ * isc_quic_router_get_stateless_reset(); it identifies the loop that owns
+ * `conn`.
+ *
+ * The token will be copied and thus the parameter free from any lifetime
+ * requirements.
+ *
+ * \par Requires:
+ * \li `router` is a valid router.
+ * \li `token != NULL`
+ * \li `tid >= 0`
+ * \li `conn != NULL`
+ *
+ * \retval ISC_R_SUCCESS on success
+ * \retval ISC_R_EXISTS if a connection is already associated with the given
+ * token.
+ */
+
+isc_result_t
+isc_quic_router_get_stateless_reset(
+	isc_quic_router_t *router,
+	const uint8_t token[const restrict ISC_QUIC_STATELESS_TOKEN_LENGTH],
+	isc_tid_t *tidp, void **connp);
+/**<
+ * \brief
+ * Get the connection associated with the given stateless reset token and its
+ * thread if `tidp` is not NULL.
+ *
+ * \note
+ * On success `*connp` carries an extra reference on the connection (see
+ * isc_quic_router_get_cid()); the caller must release it with the router's
+ * `conn_unref` callback.
+ *
+ * \par Requires:
+ * \li `router` is a valid router.
+ * \li `token` != NULL
+ *
+ * \retval ISC_R_SUCCESS on success
+ * \retval ISC_R_NOTFOUND if no connection exists for the given token.
+ */
+
+isc_result_t
+isc_quic_router_del_stateless_reset(
+	isc_quic_router_t *router,
+	const uint8_t token[const restrict ISC_QUIC_STATELESS_TOKEN_LENGTH]);
+/**<
+ * \brief
+ * Remove the connection associated with the given stateless reset token.
+ *
+ * \par Requires:
+ * \li `router` is a valid router.
+ * \li `token != NULL`
+ *
+ * \retval ISC_R_SUCCESS on success
+ * \retval ISC_R_NOTFOUND if no connection exists for the given token.
+ */
+
+isc_result_t
+isc_quic_router_handle_packet(isc_quic_router_t	 *router,
+			      isc_constregion_t	  packet,
+			      isc_quic_version_t *versionp,
+			      isc_constregion_t	 *dcidp,
+			      isc_constregion_t *scidp, isc_tid_t *tidp,
+			      void **connp);
+/**<
+ * \brief
+ * Extracts the associated value and relevant information of a given QUIC
+ * packet.
+ *
+ * \par Requires:
+ * \li `router` is a valid router.
+ * \li `packet.base != NULL` and `packet.length != 0`
+ * \li `dcidp != NULL`
+ * \li `scidp != NULL`
+ * \li `connp != NULL` and `*connp == NULL`
+ *
+ * It checks for the following
+ * \li Check if the packet header is valid. If so, extract relevant information.
+ * \li If the DCID is associated with a value, return it.
+ * \li Otherwise, check if the packet version is unknown. If so, also check if
+ * it has a stateless reset token.
+ * \li Check if it can be accepted as a handshake.
+ *
+ * \warning
+ * The router will **NOT** automatically remove the CID on stateless reset.
+ *
+ * \note
+ * On return, `*dcidp` and `*scidp` alias into `packet.base`; they are only
+ * valid for as long as `packet` is, and are left unset on the
+ * ISC_R_INVALIDPROTO path (where the header could not be decoded).
+ *
+ * \note
+ * When a connection is returned via `*connp` (ISC_R_SUCCESS or
+ * ISC_R_CONNECTIONRESET), it carries an extra reference the caller must release
+ * with the router's `conn_unref` callback (see isc_quic_router_get_cid()).
+ *
+ * \retval ISC_R_SUCCESS when an associated connection for the packet is found
+ * \retval ISC_R_CONNECTIONRESET when a stateless reset packet for a session has
+ * been found.
+ * \retval ISC_R_NOTFOUND when the packet should be accepted as the handshake of
+ * a new connection.
+ * \retval ISC_R_NOTIMPLEMENTED when the version is unsupported and a Version
+ * Negotiation packet should be sent; `*dcidp` and `*scidp` are populated with
+ * the peer's connection IDs (which may exceed ISC_QUIC_CID_MAX_LENGTH).
+ * \retval ISC_R_INVALIDPROTO on packet misformat
+ * \retval ISC_R_UNEXPECTED when a Short header (1-RTT) packet is not associated
+ * with a connection and is not a known stateless reset. The caller MAY respond
+ * with a stateless reset (RFC9000, Section 10.3).
+ * \retval ISC_R_IGNORE when the packet should be dropped silently: a Long
+ * header packet that cannot be accepted as a handshake (e.g. Handshake or
+ * 0-RTT for an unknown connection) or a received Version Negotiation packet.
+ * Unlike ISC_R_UNEXPECTED, the caller MUST NOT send a stateless reset in
+ * response (RFC9000, Section 10.3).
+ */
diff --git a/lib/isc/meson.build b/lib/isc/meson.build
index dc8fb420c0..7717832a03 100644
--- a/lib/isc/meson.build
+++ b/lib/isc/meson.build
@@ -23,6 +23,7 @@ isc_inc_p += include_directories('.')
 subdir('crypto')
 subdir('netmgr')
 subdir('ossl_wrap')
+subdir('quic', if_found: [ngtcp2_dep])

 isc_srcset.add(
     m_dep,
diff --git a/lib/isc/quic/meson.build b/lib/isc/quic/meson.build
new file mode 100644
index 0000000000..18a216ae73
--- /dev/null
+++ b/lib/isc/quic/meson.build
@@ -0,0 +1,17 @@
+# Copyright (C) Internet Systems Consortium, Inc. ("ISC")
+#
+# SPDX-License-Identifier: MPL-2.0
+#
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0.  If a copy of the MPL was not distributed with this
+# file, you can obtain one at https://mozilla.org/MPL/2.0/.
+#
+# See the COPYRIGHT file distributed with this work for additional
+# information regarding copyright ownership.
+
+isc_srcset.add(
+    ngtcp2_dep,
+    files(
+        'router.c',
+    ),
+)
diff --git a/lib/isc/quic/router.c b/lib/isc/quic/router.c
new file mode 100644
index 0000000000..4858d6570c
--- /dev/null
+++ b/lib/isc/quic/router.c
@@ -0,0 +1,517 @@
+/*
+ * Copyright (C) Internet Systems Consortium, Inc. ("ISC")
+ *
+ * SPDX-License-Identifier: MPL-2.0
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at https://mozilla.org/MPL/2.0/.
+ *
+ * See the COPYRIGHT file distributed with this work for additional
+ * information regarding copyright ownership.
+ */
+
+#include <stdint.h>
+
+#include <ngtcp2/ngtcp2.h>
+
+#include <isc/attributes.h>
+#include <isc/hash.h>
+#include <isc/magic.h>
+#include <isc/mem.h>
+#include <isc/quic.h>
+#include <isc/safe.h>
+#include <isc/urcu.h>
+#include <isc/util.h>
+
+typedef struct cid_entry cid_entry_t;
+typedef struct reset_entry_t reset_entry_t;
+
+struct cid_entry {
+	struct cds_lfht_node node;
+	struct rcu_head rcu_head;
+	isc_mem_t *mctx;
+	isc_quic_conn_unref_t unref;
+	void *value;
+	isc_tid_t tid;
+	size_t length;
+	uint8_t data[] ISC_ATTR_COUNTED_BY(length);
+};
+
+struct reset_entry_t {
+	struct cds_lfht_node node;
+	struct rcu_head rcu_head;
+	isc_mem_t *mctx;
+	isc_quic_conn_unref_t unref;
+	isc_tid_t tid;
+	void *value;
+	uint8_t token[ISC_QUIC_STATELESS_TOKEN_LENGTH];
+};
+
+struct isc_quic_router {
+	uint32_t magic;
+	isc_refcount_t references;
+	isc_mem_t *mctx;
+	size_t cidlen;
+	isc_quic_conn_ref_t conn_ref;
+	isc_quic_conn_unref_t conn_unref;
+	struct cds_lfht *cid_ht;
+	struct cds_lfht *reset_ht;
+};
+
+constexpr uint32_t router_magic = ISC_MAGIC('Q', 'U', 'I', 'r');
+
+/*
+ * Header Form bit, set on Long header packets and clear on Short header
+ * (1-RTT) packets (RFC9000, Section 17). ngtcp2 only exposes this in a
+ * private header, so it is defined locally.
+ */
+constexpr uint8_t quic_long_header_bit = 0x80;
+constexpr unsigned long ht_init_size = (1 << 4); /* Must be power of 2 */
+constexpr unsigned long ht_min_size = (1 << 4);	 /* Must be power of 2 */
+
+STATIC_ASSERT(ISC_QUIC_CID_MIN_LENGTH == NGTCP2_MIN_CIDLEN,
+	      "QUIC minimum CID length mismatches with the definition "
+	      "inside ngtcp2");
+
+STATIC_ASSERT(ISC_QUIC_CID_MAX_LENGTH == NGTCP2_MAX_CIDLEN,
+	      "QUIC maximum CID length mismatches with the definition "
+	      "inside ngtcp2");
+
+STATIC_ASSERT(ISC_QUIC_STATELESS_TOKEN_LENGTH ==
+		      NGTCP2_STATELESS_RESET_TOKENLEN,
+	      "QUIC stateless token length mismatches with the definition "
+	      "inside ngtcp2");
+
+static int
+cid_match(struct cds_lfht_node *node, const void *key) {
+	cid_entry_t *entry = caa_container_of(node, cid_entry_t, node);
+	const isc_constregion_t *data = key;
+	return (entry->length == data->length) &&
+	       (memcmp(entry->data, data->base, entry->length) == 0);
+}
+
+static void
+cid_free(struct rcu_head *rcu_head) {
+	cid_entry_t *entry = caa_container_of(rcu_head, cid_entry_t, rcu_head);
+	entry->unref(entry->value);
+	isc_mem_putanddetach(&entry->mctx, entry,
+			     STRUCT_FLEX_SIZE(entry, data, entry->length));
+}
+
+static int
+reset_match(struct cds_lfht_node *node, const void *key) {
+	reset_entry_t *entry = caa_container_of(node, reset_entry_t, node);
+	return isc_safe_memequal(entry->token, key,
+				 ISC_QUIC_STATELESS_TOKEN_LENGTH);
+}
+
+static void
+reset_free(struct rcu_head *rcu_head) {
+	reset_entry_t *entry = caa_container_of(rcu_head, reset_entry_t,
+						rcu_head);
+	entry->unref(entry->value);
+	isc_mem_putanddetach(&entry->mctx, entry, sizeof(*entry));
+}
+
+static void
+destroy(isc_quic_router_t *router) {
+	struct cds_lfht_iter iter;
+	reset_entry_t *r;
+	cid_entry_t *c;
+
+	cds_lfht_for_each_entry(router->cid_ht, &iter, c, node) {
+		INSIST(!cds_lfht_del(router->cid_ht, &c->node));
+		call_rcu(&c->rcu_head, cid_free);
+	}
+	RUNTIME_CHECK(!cds_lfht_destroy(router->cid_ht, NULL));
+
+	cds_lfht_for_each_entry(router->reset_ht, &iter, r, node) {
+		INSIST(!cds_lfht_del(router->reset_ht, &r->node));
+		call_rcu(&r->rcu_head, reset_free);
+	}
+	RUNTIME_CHECK(!cds_lfht_destroy(router->reset_ht, NULL));
+
+	router->magic = 0;
+
+	isc_mem_putanddetach(&router->mctx, router, sizeof(*router));
+}
+
+ISC_REFCOUNT_IMPL(isc_quic_router, destroy);
+
+void
+isc_quic_router_create(isc_mem_t *mctx, size_t cidlen,
+		       isc_quic_conn_ref_t conn_ref,
+		       isc_quic_conn_unref_t conn_unref,
+		       isc_quic_router_t **routerp) {
+	isc_quic_router_t *router;
+
+	REQUIRE(routerp != NULL && *routerp == NULL);
+	REQUIRE(cidlen >= ISC_QUIC_CID_MIN_LENGTH &&
+		cidlen <= ISC_QUIC_CID_MAX_LENGTH);
+	REQUIRE(conn_ref != NULL && conn_unref != NULL);
+
+	router = isc_mem_get(mctx, sizeof(*router));
+	*router = (isc_quic_router_t){
+		.magic = router_magic,
+		.references = ISC_REFCOUNT_INITIALIZER(1),
+		.mctx = isc_mem_ref(mctx),
+		.cidlen = cidlen,
+		.conn_ref = conn_ref,
+		.conn_unref = conn_unref,
+		.cid_ht = cds_lfht_new(
+			ht_init_size, ht_min_size, 0,
+			CDS_LFHT_AUTO_RESIZE | CDS_LFHT_ACCOUNTING, NULL),
+		.reset_ht = cds_lfht_new(
+			ht_init_size, ht_min_size, 0,
+			CDS_LFHT_AUTO_RESIZE | CDS_LFHT_ACCOUNTING, NULL),
+	};
+	INSIST(router->cid_ht != NULL);
+	INSIST(router->reset_ht != NULL);
+
+	*routerp = router;
+}
+
+isc_result_t
+isc_quic_router_add_cid(isc_quic_router_t *router, isc_constregion_t cid,
+			isc_tid_t tid, void *conn) {
+	struct cds_lfht_node *node;
+	cid_entry_t *entry;
+	uint32_t hash;
+
+	REQUIRE(router != NULL && router->magic == router_magic);
+	REQUIRE(cid.base != NULL && cid.length >= ISC_QUIC_CID_MIN_LENGTH &&
+		cid.length <= ISC_QUIC_CID_MAX_LENGTH);
+	REQUIRE(tid >= 0);
+
+	hash = isc_hash32(cid.base, cid.length, true);
+
+	entry = isc_mem_get(router->mctx,
+			    STRUCT_FLEX_SIZE(entry, data, cid.length));
+	*entry = (cid_entry_t){
+		.value = conn,
+		.mctx = isc_mem_ref(router->mctx),
+		.unref = router->conn_unref,
+		.tid = tid,
+		.length = cid.length,
+	};
+	memmove(entry->data, cid.base, cid.length);
+
+	/* The routing table holds a reference for as long as it stores conn. */
+	router->conn_ref(conn);
+
+	rcu_read_lock();
+	node = cds_lfht_add_unique(router->cid_ht, hash, cid_match, &cid,
+				   &entry->node);
+	rcu_read_unlock();
+
+	if (node != &entry->node) {
+		router->conn_unref(conn);
+		isc_mem_putanddetach(&entry->mctx, entry,
+				     STRUCT_FLEX_SIZE(entry, data, cid.length));
+		return ISC_R_EXISTS;
+	}
+
+	return ISC_R_SUCCESS;
+}
+
+isc_result_t
+isc_quic_router_get_cid(isc_quic_router_t *router, isc_constregion_t cid,
+			isc_tid_t *tidp, void **connp) {
+	struct cds_lfht_node *node;
+	struct cds_lfht_iter iter;
+	isc_result_t result;
+	cid_entry_t *entry;
+	uint32_t hash;
+
+	REQUIRE(router != NULL && router->magic == router_magic);
+	REQUIRE(cid.base != NULL && cid.length >= ISC_QUIC_CID_MIN_LENGTH &&
+		cid.length <= ISC_QUIC_CID_MAX_LENGTH);
+
+	hash = isc_hash32(cid.base, cid.length, true);
+
+	rcu_read_lock();
+	cds_lfht_lookup(router->cid_ht, hash, cid_match, &cid, &iter);
+	node = cds_lfht_iter_get_node(&iter);
+	entry = cds_lfht_entry(node, cid_entry_t, node);
+	if (entry == NULL) {
+		result = ISC_R_NOTFOUND;
+	} else {
+		result = ISC_R_SUCCESS;
+		if (connp != NULL) {
+			router->conn_ref(entry->value);
+			*connp = entry->value;
+		}
+		SET_IF_NOT_NULL(tidp, entry->tid);
+	}
+	rcu_read_unlock();
+
+	return result;
+}
+
+isc_result_t
+isc_quic_router_del_cid(isc_quic_router_t *router, isc_constregion_t cid) {
+	struct cds_lfht_node *node;
+	struct cds_lfht_iter iter;
+	isc_result_t result;
+	cid_entry_t *entry;
+	uint32_t hash;
+
+	REQUIRE(router != NULL && router->magic == router_magic);
+	REQUIRE(cid.base != NULL && cid.length >= ISC_QUIC_CID_MIN_LENGTH &&
+		cid.length <= ISC_QUIC_CID_MAX_LENGTH);
+
+	hash = isc_hash32(cid.base, cid.length, true);
+
+	rcu_read_lock();
+	cds_lfht_lookup(router->cid_ht, hash, cid_match, &cid, &iter);
+	node = cds_lfht_iter_get_node(&iter);
+	if (node == NULL) {
+		result = ISC_R_NOTFOUND;
+	} else if (cds_lfht_del(router->cid_ht, node) != 0) {
+		/* Another thread removed the same entry first. */
+		result = ISC_R_NOTFOUND;
+	} else {
+		entry = caa_container_of(node, cid_entry_t, node);
+		call_rcu(&entry->rcu_head, cid_free);
+		result = ISC_R_SUCCESS;
+	}
+	rcu_read_unlock();
+
+	return result;
+}
+
+isc_result_t
+isc_quic_router_add_stateless_reset(
+	isc_quic_router_t *router,
+	const uint8_t token[restrict ISC_QUIC_STATELESS_TOKEN_LENGTH],
+	isc_tid_t tid, void *conn) {
+	struct cds_lfht_node *node;
+	reset_entry_t *entry;
+	uint32_t hash;
+
+	REQUIRE(router != NULL && router->magic == router_magic);
+	REQUIRE(token != NULL);
+	REQUIRE(tid >= 0);
+
+	hash = isc_hash32(token, ISC_QUIC_STATELESS_TOKEN_LENGTH, true);
+
+	entry = isc_mem_get(router->mctx, sizeof(*entry));
+	*entry = (reset_entry_t){
+		.value = conn,
+		.mctx = isc_mem_ref(router->mctx),
+		.unref = router->conn_unref,
+		.tid = tid,
+	};
+	memmove(entry->token, token, ISC_QUIC_STATELESS_TOKEN_LENGTH);
+
+	/* The routing table holds a reference for as long as it stores conn. */
+	router->conn_ref(conn);
+
+	rcu_read_lock();
+	node = cds_lfht_add_unique(router->reset_ht, hash, reset_match, token,
+				   &entry->node);
+	rcu_read_unlock();
+
+	if (node != &entry->node) {
+		router->conn_unref(conn);
+		isc_mem_putanddetach(&entry->mctx, entry, sizeof(*entry));
+		return ISC_R_EXISTS;
+	}
+
+	return ISC_R_SUCCESS;
+}
+
+isc_result_t
+isc_quic_router_get_stateless_reset(
+	isc_quic_router_t *router,
+	const uint8_t token[const restrict ISC_QUIC_STATELESS_TOKEN_LENGTH],
+	isc_tid_t *tidp, void **connp) {
+	struct cds_lfht_node *node;
+	struct cds_lfht_iter iter;
+	reset_entry_t *entry;
+	isc_result_t result;
+	uint32_t hash;
+
+	REQUIRE(router != NULL && router->magic == router_magic);
+	REQUIRE(token != NULL);
+
+	hash = isc_hash32(token, NGTCP2_STATELESS_RESET_TOKENLEN, true);
+
+	rcu_read_lock();
+	cds_lfht_lookup(router->reset_ht, hash, reset_match, token, &iter);
+	node = cds_lfht_iter_get_node(&iter);
+	entry = cds_lfht_entry(node, reset_entry_t, node);
+	if (entry == NULL) {
+		result = ISC_R_NOTFOUND;
+	} else {
+		result = ISC_R_SUCCESS;
+		SET_IF_NOT_NULL(tidp, entry->tid);
+		if (connp != NULL) {
+			router->conn_ref(entry->value);
+			*connp = entry->value;
+		}
+	}
+	rcu_read_unlock();
+
+	return result;
+}
+
+isc_result_t
+isc_quic_router_del_stateless_reset(
+	isc_quic_router_t *router,
+	const uint8_t token[const restrict ISC_QUIC_STATELESS_TOKEN_LENGTH]) {
+	struct cds_lfht_node *node;
+	struct cds_lfht_iter iter;
+	isc_result_t result;
+	reset_entry_t *entry;
+	uint32_t hash;
+
+	REQUIRE(router != NULL && router->magic == router_magic);
+	REQUIRE(token != NULL);
+
+	hash = isc_hash32(token, NGTCP2_STATELESS_RESET_TOKENLEN, true);
+
+	rcu_read_lock();
+	cds_lfht_lookup(router->reset_ht, hash, reset_match, token, &iter);
+	node = cds_lfht_iter_get_node(&iter);
+	if (node == NULL) {
+		result = ISC_R_NOTFOUND;
+	} else if (cds_lfht_del(router->reset_ht, node) != 0) {
+		/* Another thread removed the same entry first. */
+		result = ISC_R_NOTFOUND;
+	} else {
+		entry = caa_container_of(node, reset_entry_t, node);
+		call_rcu(&entry->rcu_head, reset_free);
+		result = ISC_R_SUCCESS;
+	}
+	rcu_read_unlock();
+
+	return result;
+}
+
+isc_result_t
+isc_quic_router_handle_packet(isc_quic_router_t *router,
+			      isc_constregion_t packet,
+			      isc_quic_version_t *versionp,
+			      isc_constregion_t *dcidp,
+			      isc_constregion_t *scidp, isc_tid_t *tidp,
+			      void **connp) {
+	isc_constregion_t dcid, scid;
+	ngtcp2_version_cid version;
+	const uint8_t *token;
+	int r;
+
+	REQUIRE(router != NULL && router->magic == router_magic);
+	REQUIRE(packet.base != NULL && packet.length != 0);
+	REQUIRE(dcidp != NULL && scidp != NULL);
+	REQUIRE(connp != NULL && *connp == NULL);
+
+	r = ngtcp2_pkt_decode_version_cid(&version, packet.base, packet.length,
+					  router->cidlen);
+	switch (r) {
+	case 0:
+		break;
+	case NGTCP2_ERR_VERSION_NEGOTIATION:
+		SET_IF_NOT_NULL(versionp, ISC_QUIC_VERSION_UNKNOWN);
+		/*
+		 * ngtcp2 populates all fields of `version` on this path so
+		 * the caller can build the Version Negotiation response.
+		 */
+		*dcidp = (isc_constregion_t){ version.dcidlen, version.dcid };
+		*scidp = (isc_constregion_t){ version.scidlen, version.scid };
+		return ISC_R_NOTIMPLEMENTED;
+	case NGTCP2_ERR_INVALID_ARGUMENT:
+	default:
+		/*
+		 * The default is technically not necessary since ngtcp2 prefers
+		 * to add a new function version instead but this is an easy
+		 * reassurance.
+		 */
+		SET_IF_NOT_NULL(versionp, ISC_QUIC_VERSION_INVALID);
+		return ISC_R_INVALIDPROTO;
+	}
+
+	switch (version.version) {
+	case NGTCP2_PROTO_VER_V1:
+		SET_IF_NOT_NULL(versionp, ISC_QUIC_VERSION_V1);
+		break;
+	case NGTCP2_PROTO_VER_V2:
+		SET_IF_NOT_NULL(versionp, ISC_QUIC_VERSION_V2);
+		break;
+	default:
+		SET_IF_NOT_NULL(versionp, ISC_QUIC_VERSION_UNKNOWN);
+		break;
+	}
+
+	dcid = (isc_constregion_t){ version.dcidlen, version.dcid };
+	scid = (isc_constregion_t){ version.scidlen, version.scid };
+
+	*dcidp = dcid;
+	*scidp = scid;
+
+	if (version.version == 0) {
+		/*
+		 * A zero version is reported both for a Short header (1-RTT)
+		 * packet and for a received Version Negotiation packet (Long
+		 * header). The Header Form bit (RFC9000, Section 17) tells them
+		 * apart: only a Short header packet can be a Stateless Reset.
+		 */
+		if ((((const uint8_t *)packet.base)[0] &
+		     quic_long_header_bit) != 0)
+		{
+			return ISC_R_IGNORE;
+		}
+
+		/*
+		 * A Stateless Reset is at least
+		 * NGTCP2_MIN_STATELESS_RESET_RANDLEN + the token length long
+		 * (RFC9000, Section 10.3).
+		 */
+		if (packet.length < NGTCP2_MIN_STATELESS_RESET_RANDLEN +
+					    ISC_QUIC_STATELESS_TOKEN_LENGTH)
+		{
+			return ISC_R_IGNORE;
+		}
+
+		token = (const uint8_t *)packet.base + packet.length -
+			ISC_QUIC_STATELESS_TOKEN_LENGTH;
+
+		if (isc_quic_router_get_stateless_reset(router, token, tidp,
+							connp) == ISC_R_SUCCESS)
+		{
+			return ISC_R_CONNECTIONRESET;
+		}
+
+		/* Not a Stateless Reset: fall through to the CID lookup. */
+	}
+
+	/*
+	 * The DCID length is taken from the wire and, for a supported version,
+	 * is only bounded above by ngtcp2.  Guard the lookup so an out-of-range
+	 * (e.g. zero-length) DCID does not trip the REQUIRE in get_cid().
+	 */
+	if (dcid.length >= ISC_QUIC_CID_MIN_LENGTH &&
+	    dcid.length <= ISC_QUIC_CID_MAX_LENGTH &&
+	    isc_quic_router_get_cid(router, dcid, tidp, connp) == ISC_R_SUCCESS)
+	{
+		return ISC_R_SUCCESS;
+	}
+
+	if (version.version == 0) {
+		/*
+		 * A Short header (1-RTT) packet for a connection we do not
+		 * know, and not a known Stateless Reset either: the caller
+		 * MAY answer with a Stateless Reset (RFC9000, Section 10.3).
+		 * ngtcp2_accept() only ever accepts a Long header Initial,
+		 * so there is nothing further to try.
+		 */
+		return ISC_R_UNEXPECTED;
+	}
+
+	if (ngtcp2_accept(NULL, packet.base, packet.length) != 0) {
+		return ISC_R_IGNORE;
+	}
+
+	return ISC_R_NOTFOUND;
+}
diff --git a/tests/isc/meson.build b/tests/isc/meson.build
index 5966a51380..aa47457556 100644
--- a/tests/isc/meson.build
+++ b/tests/isc/meson.build
@@ -66,7 +66,7 @@ flaky_isc_test = [

 is_el8 = run_command('grep', '-q', '-F', 'platform:el8', '/etc/os-release', check: false).returncode() == 0

-_all_isc_test = isc_test + ['doh']
+_all_isc_test = isc_test + ['doh', 'quic']
 run_command(
     check_test_registration,
     meson.current_source_dir(),
@@ -78,6 +78,10 @@ if config.has('HAVE_LIBNGHTTP2') and not is_el8
     isc_test += 'doh'
 endif

+if config.has('HAVE_LIBNGTCP2')
+    isc_test += 'quic'
+endif
+
 foreach unit : isc_test
     test_bin = executable(
         unit,
diff --git a/tests/isc/quic_test.c b/tests/isc/quic_test.c
new file mode 100644
index 0000000000..c6791eff88
--- /dev/null
+++ b/tests/isc/quic_test.c
@@ -0,0 +1,369 @@
+/*
+ * Copyright (C) Internet Systems Consortium, Inc. ("ISC")
+ *
+ * SPDX-License-Identifier: MPL-2.0
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at https://mozilla.org/MPL/2.0/.
+ *
+ * See the COPYRIGHT file distributed with this work for additional
+ * information regarding copyright ownership.
+ */
+
+#include <inttypes.h>
+#include <sched.h>  /* IWYU pragma: keep */
+#include <setjmp.h> /* IWYU pragma: keep */
+#include <stdarg.h>
+#include <stddef.h>
+#include <stdlib.h> /* IWYU pragma: keep */
+#include <unistd.h>
+
+#define UNIT_TESTING
+#include <cmocka.h>
+
+#include <isc/atomic.h>
+#include <isc/lib.h>
+#include <isc/quic.h>
+#include <isc/random.h>
+#include <isc/urcu.h>
+
+#include <tests/isc.h>
+
+constexpr isc_tid_t connection_tid = 3;
+
+/*
+ * The tests use a sentinel pointer as the "connection"; these callbacks let
+ * them assert that the router balances every reference it takes and hands out.
+ */
+static atomic_int conn_refs = 0;
+
+static void
+test_conn_ref(void *conn) {
+	(void)conn;
+	(void)atomic_fetch_add(&conn_refs, 1);
+}
+
+static void
+test_conn_unref(void *conn) {
+	/* May run on an RCU worker thread, hence the atomic counter. */
+	(void)conn;
+	(void)atomic_fetch_sub(&conn_refs, 1);
+}
+
+constexpr uint8_t client_dcid[] = {
+	0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
+};
+
+constexpr uint8_t unknown_cid[] = {
+	0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09,
+};
+
+/* Adapted from https://quic.xargs.org/ */
+constexpr uint8_t initial_frame_v1[1200] = {
+	/*
+	 * Long Header Packet Byte (RFC9000, Section 17.2):
+	 * 11000000
+	 * ││├┘├┘├┘
+	 * │││ │ └── Packet number field length (1-byte)
+	 * │││ └──── Reserved Bits (RFC9000, Section 17.2.2)
+	 * ││└────── Long Packet Type (RFC9000, Section 17.2, Table 5)
+	 * │└─────── Fixed Bit
+	 * └──────── Header Form
+	 */
+	0xc0,
+	/* QUIC version v1 */
+	0x00, 0x00, 0x00, 0x01,
+	/* 8-bytes of DCID to follow */
+	0x08,
+	/* DCID */
+	0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
+	/* 5-bytes of SCID to follow */
+	0x05,
+	/* SCID */
+	0xF0, 0xF1, 0xF2, 0xF3, 0xF4,
+	/* 0-bytes of token to follow */
+	0x00,
+	/* 259-bytes of packet to follow */
+	0x41, 0x03,
+	/* Packet number */
+	0x00
+	/* The rest is zero-padded packet information that won't be inspected */
+};
+
+constexpr uint8_t initial_frame_reserved_version[1200] = {
+	/*
+	 * Long Header Packet Byte (RFC9000, Section 17.2):
+	 * 11000000
+	 * ││├┘├┘├┘
+	 * │││ │ └── Packet number field length (1-byte)
+	 * │││ └──── Reserved Bits (RFC9000, Section 17.2.2)
+	 * ││└────── Long Packet Type (RFC9000, Section 17.2, Table 5)
+	 * │└─────── Fixed Bit
+	 * └──────── Header Form
+	 */
+	0xC0,
+	/* QUIC reserved version */
+	0x0A, 0x0A, 0x0A, 0x0A,
+	/* 8-bytes of DCID to follow */
+	0x08,
+	/* DCID */
+	0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
+	/* 5-bytes of SCID to follow */
+	0x05,
+	/* SCID */
+	0xF0, 0xF1, 0xF2, 0xF3, 0xF4,
+	/* 0-bytes of token to follow */
+	0x00,
+	/* 259-bytes of packet to follow */
+	0x41, 0x03,
+	/* Packet number */
+	0x00
+	/* The rest is zero-padded packet information that won't be inspected */
+};
+
+constexpr uint8_t stateless_reset_token[ISC_QUIC_STATELESS_TOKEN_LENGTH] = {
+	0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
+	0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F,
+};
+
+constexpr uint8_t stateless_reset_packet[] = {
+	0x40, 0xF0, 0xF1, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0x00, 0x01, 0x02, 0x03,
+	0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F,
+};
+
+/*
+ * A 1-RTT packet for the connection identified by client_dcid. Its last 16
+ * bytes deliberately differ from stateless_reset_token.
+ */
+constexpr uint8_t short_header_packet[] = {
+	/*
+	 * Short Header Packet Byte (RFC9000, Section 17.3.1):
+	 * 01000000
+	 * │││├┘│├┘
+	 * ││││ │└── Packet Number Length (1-byte)
+	 * ││││ └─── Key Phase
+	 * │││└───── Reserved Bits
+	 * ││└────── Spin Bit
+	 * │└─────── Fixed Bit
+	 * └──────── Header Form
+	 */
+	0x40,
+	/* DCID (always router->cidlen bytes in a Short header) */
+	0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
+	/* Packet number */
+	0x00,
+	/* Protected payload including the AEAD tag; never inspected */
+	0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xAB,
+	0xAC, 0xAD, 0xAE, 0xAF
+};
+
+ISC_RUN_TEST_IMPL(isc_quic_router_cid) {
+	isc_quic_router_t *router = NULL;
+	isc_constregion_t cid;
+	isc_result_t result;
+	isc_tid_t tid = ISC_TID_UNKNOWN;
+	void *value = NULL;
+
+	isc_quic_router_create(isc_g_mctx, sizeof(client_dcid), test_conn_ref,
+			       test_conn_unref, &router);
+
+	cid = (isc_constregion_t){ sizeof(client_dcid), client_dcid };
+	result = isc_quic_router_add_cid(router, cid, connection_tid, router);
+	assert_int_equal(result, ISC_R_SUCCESS);
+
+	cid = (isc_constregion_t){ sizeof(client_dcid), client_dcid };
+	result = isc_quic_router_add_cid(router, cid, connection_tid, router);
+	assert_int_equal(result, ISC_R_EXISTS);
+
+	cid = (isc_constregion_t){ sizeof(client_dcid), client_dcid };
+	result = isc_quic_router_get_cid(router, cid, &tid, &value);
+	assert_int_equal(result, ISC_R_SUCCESS);
+	assert_ptr_equal(value, router);
+	assert_int_equal(tid, connection_tid);
+	test_conn_unref(value);
+
+	value = NULL;
+	cid = (isc_constregion_t){ sizeof(unknown_cid), unknown_cid };
+	result = isc_quic_router_get_cid(router, cid, NULL, &value);
+	assert_int_equal(result, ISC_R_NOTFOUND);
+
+	cid = (isc_constregion_t){ sizeof(unknown_cid), unknown_cid };
+	result = isc_quic_router_add_cid(router, cid, connection_tid, router);
+	assert_int_equal(result, ISC_R_SUCCESS);
+
+	isc_quic_router_detach(&router);
+
+	/* Flush deferred frees, then check the router balanced every ref. */
+	rcu_barrier();
+	assert_int_equal(atomic_load(&conn_refs), 0);
+}
+
+ISC_RUN_TEST_IMPL(isc_quic_router_stateless_reset) {
+	uint8_t token[ISC_QUIC_STATELESS_TOKEN_LENGTH];
+	isc_quic_router_t *router = NULL;
+	isc_result_t result;
+	isc_tid_t tid = ISC_TID_UNKNOWN;
+	void *value = NULL;
+
+	isc_quic_router_create(isc_g_mctx, sizeof(client_dcid), test_conn_ref,
+			       test_conn_unref, &router);
+
+	isc_random_buf(token, sizeof(token));
+
+	result = isc_quic_router_get_stateless_reset(router, token, NULL,
+						     &value);
+	assert_int_equal(result, ISC_R_NOTFOUND);
+
+	result = isc_quic_router_add_stateless_reset(router, token,
+						     connection_tid, router);
+	assert_int_equal(result, ISC_R_SUCCESS);
+
+	result = isc_quic_router_add_stateless_reset(router, token,
+						     connection_tid, router);
+	assert_int_equal(result, ISC_R_EXISTS);
+
+	result = isc_quic_router_get_stateless_reset(router, token, &tid,
+						     &value);
+	assert_int_equal(result, ISC_R_SUCCESS);
+	assert_ptr_equal(value, router);
+	assert_int_equal(tid, connection_tid);
+	test_conn_unref(value);
+
+	result = isc_quic_router_del_stateless_reset(router, token);
+	assert_int_equal(result, ISC_R_SUCCESS);
+
+	result = isc_quic_router_get_stateless_reset(router, token, NULL,
+						     &value);
+	assert_int_equal(result, ISC_R_NOTFOUND);
+
+	result = isc_quic_router_del_stateless_reset(router, token);
+	assert_int_equal(result, ISC_R_NOTFOUND);
+
+	isc_quic_router_detach(&router);
+
+	/* Flush deferred frees, then check the router balanced every ref. */
+	rcu_barrier();
+	assert_int_equal(atomic_load(&conn_refs), 0);
+}
+
+ISC_RUN_TEST_IMPL(isc_quic_router_packet) {
+	isc_quic_router_t *router = NULL;
+	isc_quic_version_t version;
+	isc_constregion_t dcid, scid;
+	isc_result_t result;
+	isc_tid_t tid = ISC_TID_UNKNOWN;
+	void *value = NULL;
+
+	isc_quic_router_create(isc_g_mctx, sizeof(client_dcid), test_conn_ref,
+			       test_conn_unref, &router);
+
+	/* Initial CRYPTO frames MUST be 1200 bytes */
+	result = isc_quic_router_handle_packet(
+		router, (isc_constregion_t){ 1199, initial_frame_v1 }, &version,
+		&dcid, &scid, NULL, &value);
+	assert_int_equal(result, ISC_R_IGNORE);
+	assert_int_equal(version, ISC_QUIC_VERSION_V1);
+
+	/* Use reserved version 0x0A0A0A0A */
+	result = isc_quic_router_handle_packet(
+		router,
+		(isc_constregion_t){ 1200, initial_frame_reserved_version },
+		&version, &dcid, &scid, NULL, &value);
+	assert_int_equal(result, ISC_R_NOTIMPLEMENTED);
+	assert_int_equal(version, ISC_QUIC_VERSION_UNKNOWN);
+
+	result = isc_quic_router_handle_packet(
+		router, (isc_constregion_t){ 1200, initial_frame_v1 }, &version,
+		&dcid, &scid, NULL, &value);
+	assert_int_equal(result, ISC_R_NOTFOUND);
+	assert_int_equal(version, ISC_QUIC_VERSION_V1);
+	assert_int_equal(dcid.length, sizeof(client_dcid));
+	assert_memory_equal(dcid.base, client_dcid, dcid.length);
+
+	/* A 1-RTT packet for an unknown connection: caller MAY send a reset */
+	result = isc_quic_router_handle_packet(
+		router,
+		(isc_constregion_t){ sizeof(short_header_packet),
+				     short_header_packet },
+		NULL, &dcid, &scid, NULL, &value);
+	assert_int_equal(result, ISC_R_UNEXPECTED);
+	assert_null(value);
+	assert_int_equal(dcid.length, sizeof(client_dcid));
+	assert_memory_equal(dcid.base, client_dcid, dcid.length);
+
+	result = isc_quic_router_add_cid(router, dcid, connection_tid, router);
+	assert_int_equal(result, ISC_R_SUCCESS);
+
+	result = isc_quic_router_handle_packet(
+		router, (isc_constregion_t){ 1200, initial_frame_v1 }, &version,
+		&dcid, &scid, NULL, &value);
+	assert_int_equal(result, ISC_R_SUCCESS);
+	assert_int_equal(version, ISC_QUIC_VERSION_V1);
+	assert_ptr_equal(value, router);
+	test_conn_unref(value);
+
+	value = NULL;
+	result = isc_quic_router_handle_packet(
+		router, (isc_constregion_t){ 1200, initial_frame_v1 }, &version,
+		&dcid, &scid, NULL, &value);
+	assert_int_equal(result, ISC_R_SUCCESS);
+	assert_ptr_equal(value, router);
+	test_conn_unref(value);
+
+	value = NULL;
+	result = isc_quic_router_handle_packet(
+		router,
+		(isc_constregion_t){ sizeof(short_header_packet),
+				     short_header_packet },
+		NULL, &dcid, &scid, &tid, &value);
+	assert_int_equal(result, ISC_R_SUCCESS);
+	assert_ptr_equal(value, router);
+	assert_int_equal(tid, connection_tid);
+	assert_int_equal(dcid.length, sizeof(client_dcid));
+	assert_memory_equal(dcid.base, client_dcid, dcid.length);
+	test_conn_unref(value);
+
+	result = isc_quic_router_del_cid(router, dcid);
+	assert_int_equal(result, ISC_R_SUCCESS);
+
+	value = NULL;
+	result = isc_quic_router_handle_packet(
+		router, (isc_constregion_t){ 1200, initial_frame_v1 }, &version,
+		&dcid, &scid, NULL, &value);
+	assert_int_equal(result, ISC_R_NOTFOUND);
+
+	result = isc_quic_router_handle_packet(
+		router,
+		(isc_constregion_t){ sizeof(short_header_packet),
+				     short_header_packet },
+		NULL, &dcid, &scid, NULL, &value);
+	assert_int_equal(result, ISC_R_UNEXPECTED);
+	assert_null(value);
+
+	result = isc_quic_router_add_stateless_reset(
+		router, stateless_reset_token, connection_tid, router);
+	assert_int_equal(result, ISC_R_SUCCESS);
+
+	result = isc_quic_router_handle_packet(
+		router,
+		(isc_constregion_t){ sizeof(stateless_reset_packet),
+				     stateless_reset_packet },
+		NULL, &dcid, &scid, NULL, &value);
+	assert_int_equal(result, ISC_R_CONNECTIONRESET);
+	test_conn_unref(value);
+
+	isc_quic_router_detach(&router);
+
+	/* Flush deferred frees, then check the router balanced every ref. */
+	rcu_barrier();
+	assert_int_equal(atomic_load(&conn_refs), 0);
+}
+
+ISC_TEST_LIST_START
+ISC_TEST_ENTRY(isc_quic_router_cid)
+ISC_TEST_ENTRY(isc_quic_router_stateless_reset)
+ISC_TEST_ENTRY(isc_quic_router_packet)
+ISC_TEST_LIST_END
+
+ISC_TEST_MAIN