fix(dns): implement RFC 6724 Rule 2 for IPv6 source address selection

This properly fixes the VPN IPv6 timeout issue by checking if a global
IPv6 source address is available before preferring IPv6 destinations.

RFC 6724 Rule 2 (Prefer matching scope) states that destinations should
be sorted based on whether a matching-scope source address is available.
When only link-local IPv6 source addresses exist (common on VPNs), global
IPv6 destinations will fail because link-local cannot route to global.

Implementation:
- Added IPv6SourceAvailability to check for global IPv6 source addresses
  via getifaddrs() with 30-second caching
- If global IPv6 source available: interleave IPv6/IPv4 (Happy Eyeballs)
- If only link-local IPv6 source: prefer IPv4 over global IPv6
- Always deprioritize link-local IPv6 destinations

This matches the behavior of properly configured systems and fixes
connection timeouts on corporate VPNs (e.g., Cisco AnyConnect).

Fixes #25619

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Claude Bot
2026-01-12 23:05:41 +00:00
parent 2f29fe7c42
commit 832657baf2

View File

@@ -1428,10 +1428,100 @@ pub const internal = struct {
fn isLinkLocalIPv6(this: *const ResultEntry) bool {
if (this.info.family != std.c.AF.INET6) return false;
const addr6: *const std.c.sockaddr.in6 = @ptrCast(@alignCast(&this.addr));
// fe80::/10 means first 10 bits are 1111111010
// First byte must be 0xFE, second byte high nibble must be 8, 9, A, or B (0b10xx)
const bytes = @as(*const [16]u8, @ptrCast(&addr6.addr));
return bytes[0] == 0xfe and (bytes[1] & 0xc0) == 0x80;
return isLinkLocalIPv6Addr(&addr6.addr);
}
/// Check if this is a global IPv6 address (not link-local, not loopback, not ULA).
fn isGlobalIPv6(this: *const ResultEntry) bool {
if (this.info.family != std.c.AF.INET6) return false;
const addr6: *const std.c.sockaddr.in6 = @ptrCast(@alignCast(&this.addr));
return isGlobalIPv6Addr(&addr6.addr);
}
};
/// Check if an IPv6 address is link-local (fe80::/10).
fn isLinkLocalIPv6Addr(addr: *const [16]u8) bool {
return addr[0] == 0xfe and (addr[1] & 0xc0) == 0x80;
}
/// Check if an IPv6 address is a Unique Local Address (fc00::/7).
fn isULAIPv6Addr(addr: *const [16]u8) bool {
return (addr[0] & 0xfe) == 0xfc;
}
/// Check if an IPv6 address is loopback (::1).
fn isLoopbackIPv6Addr(addr: *const [16]u8) bool {
const zero_part = @as(*const u128, @ptrCast(addr)).*;
return zero_part == @as(u128, 1) << 120; // ::1 in big-endian
}
/// Check if an IPv6 address is global scope (routable on the internet).
/// Global addresses are in the 2000::/3 range (first 3 bits = 001).
fn isGlobalIPv6Addr(addr: *const [16]u8) bool {
// Global unicast: 2000::/3 (first byte 0x20-0x3F)
return (addr[0] & 0xe0) == 0x20;
}
/// RFC 6724 Rule 2: Check if a global-scope IPv6 source address is available.
/// This determines whether we should prefer IPv6 destinations.
/// Returns true if there's at least one non-link-local, non-loopback IPv6 address.
const IPv6SourceAvailability = struct {
var cached_result: enum { unknown, available, unavailable } = .unknown;
var cache_timestamp: i64 = 0;
const CACHE_TTL_MS: i64 = 30000; // 30 seconds
fn hasGlobalIPv6Source() bool {
if (comptime bun.Environment.isWindows) {
// TODO: Implement Windows support using GetAdaptersAddresses
return true; // Assume available on Windows for now
}
const now = std.time.milliTimestamp();
if (cached_result != .unknown and (now - cache_timestamp) < CACHE_TTL_MS) {
return cached_result == .available;
}
// Check network interfaces for global IPv6 addresses
var ifap: ?*c.ifaddrs = null;
if (c.getifaddrs(&ifap) != 0) {
// On error, assume IPv6 is available to avoid breaking things
return true;
}
defer c.freeifaddrs(ifap);
var has_global_ipv6 = false;
var ifa = ifap;
while (ifa) |iface| : (ifa = iface.ifa_next) {
// Skip interfaces that aren't up and running
if (iface.ifa_flags & c.IFF_UP == 0) continue;
if (iface.ifa_flags & c.IFF_RUNNING == 0) continue;
if (iface.ifa_addr == null) continue;
// Skip loopback interfaces
if (iface.ifa_flags & c.IFF_LOOPBACK != 0) continue;
// Check if this is an IPv6 address
const sa: *std.posix.sockaddr = @ptrCast(@alignCast(iface.ifa_addr));
if (sa.family != std.c.AF.INET6) continue;
const addr6: *const std.c.sockaddr.in6 = @ptrCast(@alignCast(iface.ifa_addr));
const addr_bytes = @as(*const [16]u8, @ptrCast(&addr6.addr));
// Check if this is a global IPv6 address (2000::/3)
if (isGlobalIPv6Addr(addr_bytes)) {
has_global_ipv6 = true;
break;
}
}
cached_result = if (has_global_ipv6) .available else .unavailable;
cache_timestamp = now;
return has_global_ipv6;
}
/// Invalidate the cache (e.g., when network changes are detected)
pub fn invalidateCache() void {
cached_result = .unknown;
}
};
@@ -1466,10 +1556,23 @@ pub const internal = struct {
info_ = ai.next;
}
// Sort addresses for optimal connection behavior:
// 1. Move link-local IPv6 (fe80::/10) to the end - they can't route to global destinations
// and cause timeouts on VPN networks. See: https://github.com/oven-sh/bun/issues/25619
// 2. Interleave global IPv6 and IPv4 (Happy Eyeballs)
// RFC 6724 compliant address sorting for optimal connection behavior.
// See: https://github.com/oven-sh/bun/issues/25619
//
// Key insight: If no global IPv6 source address is available (only link-local),
// then global IPv6 destinations will fail because link-local sources cannot
// route to global destinations. This commonly happens on VPN networks.
//
// Sorting strategy:
// 1. Check if a global IPv6 source is available (RFC 6724 Rule 2)
// 2. If yes: interleave global IPv6 and IPv4 (Happy Eyeballs)
// 3. If no: prefer IPv4 over global IPv6 (avoid scope mismatch timeouts)
// 4. Always move link-local IPv6 destinations to the end
const has_global_ipv6_source = IPv6SourceAvailability.hasGlobalIPv6Source();
// First pass: move link-local IPv6 destinations to the end.
// These can only reach link-local destinations, not internet hosts.
var link_local_start: usize = count;
for (0..link_local_start) |idx| {
if (results[idx].isLinkLocalIPv6()) {
@@ -1488,18 +1591,36 @@ pub const internal = struct {
}
}
// Now interleave IPv4 and IPv6 among the non-link-local addresses
var want: usize = std.c.AF.INET6;
for (0..link_local_start) |idx| {
if (results[idx].info.family == want) continue;
for (idx + 1..link_local_start) |j| {
if (results[j].info.family == want) {
std.mem.swap(ResultEntry, &results[idx], &results[j]);
want = if (want == std.c.AF.INET6) std.c.AF.INET else std.c.AF.INET6;
// Second pass: sort non-link-local addresses based on IPv6 source availability.
if (has_global_ipv6_source) {
// Global IPv6 source available: interleave IPv6 and IPv4 (Happy Eyeballs)
// Start with IPv6 as it's generally preferred when available.
var want: usize = std.c.AF.INET6;
for (0..link_local_start) |idx| {
if (results[idx].info.family == want) continue;
for (idx + 1..link_local_start) |j| {
if (results[j].info.family == want) {
std.mem.swap(ResultEntry, &results[idx], &results[j]);
want = if (want == std.c.AF.INET6) std.c.AF.INET else std.c.AF.INET6;
}
} else {
// the rest of the non-link-local list is all one address family
break;
}
}
} else {
// No global IPv6 source: prefer IPv4 over global IPv6.
// RFC 6724 Rule 2: Prefer matching scope. Since we only have link-local
// IPv6 source, global IPv6 destinations will fail with scope mismatch.
// Put all IPv4 addresses first, then global IPv6 (as fallback).
var ipv4_end: usize = 0;
for (0..link_local_start) |idx| {
if (results[idx].info.family == std.c.AF.INET) {
if (idx != ipv4_end) {
std.mem.swap(ResultEntry, &results[idx], &results[ipv4_end]);
}
ipv4_end += 1;
}
} else {
// the rest of the non-link-local list is all one address family
break;
}
}
@@ -3516,6 +3637,7 @@ const std = @import("std");
const Allocator = std.mem.Allocator;
const bun = @import("bun");
const c = bun.c;
const Async = bun.Async;
const Environment = bun.Environment;
const Global = bun.Global;