From 3071fbfa25dcb3b8a1a17dc2df49e767ddd9d1a6 Mon Sep 17 00:00:00 2001 From: Eliot the Cougar Date: Wed, 28 Oct 2026 13:15:58 +0300 Subject: [PATCH] Sockopt: Handle single-family interface binding on Windows, including TUN inbound's `autoOutboundsInterface` (#7214) And https://github.com/XTLS/Xray-core/pull/7214#issuecomment-6329118637 https://github.com/XTLS/Xray-core/pull/7201#issuecomment-6314453444 Fixes https://github.com/XTLS/Xray-core/issues/7160#issuecomment-6246944012 --------- Co-authored-by: patterniha <71074308+patterniha@users.noreply.github.com> --- features/dns/localdns/client.go | 10 +++ proxy/tun/tun_windows.go | 26 +++++++- transport/internet/system_dialer.go | 12 ++++ .../internet/system_dialer_windows_test.go | 63 +++++++++++++++++++ 4 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 transport/internet/system_dialer_windows_test.go diff --git a/features/dns/localdns/client.go b/features/dns/localdns/client.go index 41480b179..4472e65ab 100644 --- a/features/dns/localdns/client.go +++ b/features/dns/localdns/client.go @@ -2,6 +2,7 @@ package localdns import ( "context" + "net/netip" "syscall" "time" @@ -100,6 +101,15 @@ func New() *Client { if internet.IsSkippedDNSServer(address) { return nil, errors.New("skipped DNS server ", address) } + // A socket of the server's IP version only needs that version bound + // to the interface, which a dual-stack one would need both of. + if ap, err := netip.ParseAddrPort(address); err == nil && (network == "udp" || network == "tcp") { + if ap.Addr().Unmap().Is4() { + network += "4" + } else { + network += "6" + } + } return d.DialContext(ctx, network, address) }, } diff --git a/proxy/tun/tun_windows.go b/proxy/tun/tun_windows.go index e0bf9f1b9..245b6b053 100644 --- a/proxy/tun/tun_windows.go +++ b/proxy/tun/tun_windows.go @@ -401,6 +401,15 @@ func resolveOnOwn() *savedResolver { if internet.IsSkippedDNSServer(address) { return nil, errors.New("skipped DNS server ", address) } + // A socket of the server's IP version only needs that version bound + // to the interface, which a dual-stack one would need both of. + if ap, err := netip.ParseAddrPort(address); err == nil && (network == "udp" || network == "tcp") { + if ap.Addr().Unmap().Is4() { + network += "4" + } else { + network += "6" + } + } return dialer.DialContext(ctx, network, address) } net.DefaultResolver.PreferGo = true @@ -558,10 +567,22 @@ func setinterface(network, address string, fd uintptr, iface *net.Interface) err switch network { case "tcp6", "udp6", "ip6": + dualStack := false + if network == "udp6" { + ipv6Only, err := windows.GetsockoptInt(windows.Handle(fd), windows.IPPROTO_IPV6, windows.IPV6_V6ONLY) + if err != nil { + return internet.NewBindError(err) + } + dualStack = ipv6Only == 0 + } err1 = windows.SetsockoptInt(windows.Handle(fd), windows.IPPROTO_IPV6, IPV6_UNICAST_IF, iface.Index) if network == "udp6" { err2 = windows.SetsockoptInt(windows.Handle(fd), windows.IPPROTO_IPV6, windows.IPV6_MULTICAST_IF, iface.Index) } + // Only dual-stack UDP can send to both IPv4 and IPv6 destinations. + if !dualStack { + break + } fallthrough case "tcp4", "udp4", "ip4": err3 = windows.SetsockoptInt(windows.Handle(fd), windows.IPPROTO_IP, IP_UNICAST_IF, *(*int)(unsafe.Pointer(&index[0]))) @@ -572,7 +593,10 @@ func setinterface(network, address string, fd uintptr, iface *net.Interface) err panic(network + " " + address) } - return errors.Combine(err1, err2, err3, err4) + if err := errors.Combine(err1, err2, err3, err4); err != nil { + return internet.NewBindError(err) + } + return nil } func findOutboundInterface(tunIndex int, fixedName string) (*net.Interface, error) { diff --git a/transport/internet/system_dialer.go b/transport/internet/system_dialer.go index 9f7fe8c29..f881a200c 100644 --- a/transport/internet/system_dialer.go +++ b/transport/internet/system_dialer.go @@ -2,6 +2,8 @@ package internet import ( "context" + go_errors "errors" + "runtime" "sync" "syscall" "time" @@ -92,6 +94,16 @@ func (d *DefaultSystemDialer) Dial(ctx context.Context, src net.Address, dest ne } } packetConn, err := lc.ListenPacket(ctx, srcAddr.Network(), srcAddr.String()) + var bindErr *BindError + if err != nil && runtime.GOOS == "windows" && go_errors.As(err, &bindErr) { + // Keep dual-stack UDP when possible. If the interface cannot bind both + // families, retry a socket that cannot send on the unbound family. + network := "udp6" + if destAddr.IP.To4() != nil { + network = "udp4" + } + packetConn, err = lc.ListenPacket(ctx, network, srcAddr.String()) + } if err != nil { return nil, err } diff --git a/transport/internet/system_dialer_windows_test.go b/transport/internet/system_dialer_windows_test.go new file mode 100644 index 000000000..779cbefdc --- /dev/null +++ b/transport/internet/system_dialer_windows_test.go @@ -0,0 +1,63 @@ +package internet_test + +import ( + "context" + "errors" + "slices" + "syscall" + "testing" + + "github.com/xtls/xray-core/common/net" + "github.com/xtls/xray-core/transport/internet" + "golang.org/x/sys/windows" +) + +// TestDialUDPBindFallback checks that a UDP dial whose dual-stack socket +// cannot be bound to both IP versions, as on an interface without one of +// them, moves to a socket of the destination's version only, and still +// fails when that one cannot be bound either. +func TestDialUDPBindFallback(t *testing.T) { + for _, c := range []struct { + dest string + reject []string // the kinds of socket that cannot be bound + tried []string // the kinds of socket tried, in order + ok bool + }{ + {"127.0.0.1", nil, []string{"dual"}, true}, + {"127.0.0.1", []string{"dual"}, []string{"dual", "udp4"}, true}, + {"::1", []string{"dual"}, []string{"dual", "udp6"}, true}, + {"127.0.0.1", []string{"dual", "udp4"}, []string{"dual", "udp4"}, false}, + } { + var tried []string + internet.ControllersLock.Lock() + saved := internet.Controllers + internet.Controllers = []func(string, string, syscall.RawConn) error{func(network, _ string, rc syscall.RawConn) error { + kind := network + if network == "udp6" { + rc.Control(func(fd uintptr) { + if v6only, _ := windows.GetsockoptInt(windows.Handle(fd), windows.IPPROTO_IPV6, windows.IPV6_V6ONLY); v6only == 0 { + kind = "dual" + } + }) + } + tried = append(tried, kind) + if slices.Contains(c.reject, kind) { + return internet.NewBindError(errors.New("cannot bind " + kind)) + } + return nil + }} + internet.ControllersLock.Unlock() + + conn, err := internet.DialSystem(context.Background(), net.UDPDestination(net.ParseAddress(c.dest), 53), nil) + if conn != nil { + conn.Close() + } + internet.ControllersLock.Lock() + internet.Controllers = saved + internet.ControllersLock.Unlock() + + if (err == nil) != c.ok || !slices.Equal(tried, c.tried) { + t.Errorf("%s rejecting %v: tried %v, err %v", c.dest, c.reject, tried, err) + } + } +}