From 6a60918cc6446dfcb91eeb7e6ddf202700debe82 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 21:51:35 +0000 Subject: [PATCH] fix(nests): connect over a resolved IPv4 address while keeping hostname SNI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After switching the dev harness to `localhost`, the QUIC handshake still stalled and the relay logged nothing at all — packets weren't reaching it. `localhost` resolves to ::1 first on most systems, and IPv6 loopback isn't reliably routable (Docker's IPv6 UDP port-forwarding silently blackholes), so the UDP socket was connecting into a hole. QuicWebTransportFactory.connect now resolves the authority host to a concrete address preferring IPv4 for the UDP socket target, while still passing the original hostname as the TLS SNI server name — so SNI keeps matching the server certificate and stays a legal RFC 6066 host_name. https://claude.ai/code/session_01PoQupfdoKU3ryQwLwTeXeM --- .../transport/QuicWebTransportFactory.kt | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/nestsClient/src/jvmAndroid/kotlin/com/vitorpamplona/nestsclient/transport/QuicWebTransportFactory.kt b/nestsClient/src/jvmAndroid/kotlin/com/vitorpamplona/nestsclient/transport/QuicWebTransportFactory.kt index 485852351..846075273 100644 --- a/nestsClient/src/jvmAndroid/kotlin/com/vitorpamplona/nestsclient/transport/QuicWebTransportFactory.kt +++ b/nestsClient/src/jvmAndroid/kotlin/com/vitorpamplona/nestsclient/transport/QuicWebTransportFactory.kt @@ -112,7 +112,15 @@ class QuicWebTransportFactory( bearerToken: String?, ): WebTransportSession { val (host, port) = splitAuthority(authority) - val socket = UdpSocket.connect(host, port) + // Decouple the socket target from the TLS SNI name. The socket must + // reach a routable address — `localhost` commonly resolves to ::1 + // first, and IPv6 loopback is not always routable (e.g. Docker's + // IPv6 UDP port-forwarding silently blackholes), so prefer an IPv4 + // address for the actual connection. SNI, however, must stay the + // original hostname so it (a) matches the server's certificate and + // (b) is a legal RFC 6066 host_name rather than an IP literal. + val socketHost = resolvePreferIpv4(host) + val socket = UdpSocket.connect(socketHost, port) val conn = QuicConnection( serverName = host, @@ -373,6 +381,25 @@ class QuicWebTransportFactory( return items.joinToString(", ") { "\"$it\"" } } + /** + * Resolve [host] to a concrete address string, preferring IPv4. Returns + * an IPv4 literal when one exists, otherwise the first resolved address, + * otherwise [host] unchanged (already an IP literal, or resolution + * failed — let the socket layer surface the error). The returned IP + * literal is only used as the UDP socket target; the caller still passes + * the original [host] as the TLS SNI server name. + */ + private fun resolvePreferIpv4(host: String): String = + try { + val addrs = java.net.InetAddress.getAllByName(host) + ( + addrs.firstOrNull { it is java.net.Inet4Address } + ?: addrs.firstOrNull() + )?.hostAddress ?: host + } catch (_: java.net.UnknownHostException) { + host + } + private fun splitAuthority(authority: String): Pair { val idx = authority.lastIndexOf(':') if (idx <= 0) return authority to 443