diff --git a/README.md b/README.md index 9e13d08b..f4427494 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,26 @@ getXrayState Used to solve the socket protect problem on Android. +### DNS resolver + +Android may expose a loopback DNS server to Go's resolver while a VPN is +active. Call `SetDNS` before `runXray` to make Go use the DNS server selected by +the VPN configuration and protect the DNS socket from the VPN tunnel. The +server must be an IP endpoint with a port, such as `8.8.8.8:53` or +`[2001:4860:4860::8888]:53`. + +Call `ResetDNS` after Xray has stopped. These APIs are available only in the +Android artifact and change the process-wide Go resolver. + +```java +LibXray.setDNS(controller, "8.8.8.8:53"); +LibXray.invoke(runXrayRequest); + +// Later, when stopping the core: +LibXray.invoke(stopXrayRequest); +LibXray.resetDNS(); +``` + ### Process finder (per-app routing) `ConnectivityManager.getConnectionOwnerUid()` is API 30+. On older Android diff --git a/android_wrapper.go b/android_wrapper.go index 88dfe24a..fcaae8fe 100644 --- a/android_wrapper.go +++ b/android_wrapper.go @@ -2,12 +2,33 @@ package libXray -import c "github.com/xtls/libxray/controller" +import ( + "errors" + + c "github.com/xtls/libxray/controller" + "github.com/xtls/libxray/dns" +) type DialerController interface { ProtectFd(int) bool } +// SetDNS installs an Android VPN-aware process resolver. server must be an IP +// endpoint such as 8.8.8.8:53 or [2001:4860:4860::8888]:53. +func SetDNS(controller DialerController, server string) error { + if controller == nil { + return errors.New("dns dialer controller is nil") + } + return dns.SetDNS(server, func(fd uintptr) bool { + return controller.ProtectFd(int(fd)) + }) +} + +// ResetDNS restores the resolver that was active before SetDNS. +func ResetDNS() { + dns.ResetDNS() +} + // ProcessFinder -> implemented by apps to resolve UID from connection details. // See RegisterProcessFinder. type ProcessFinder interface { diff --git a/dns/dns_android.go b/dns/dns_android.go new file mode 100644 index 00000000..8422a047 --- /dev/null +++ b/dns/dns_android.go @@ -0,0 +1,41 @@ +//go:build android + +package dns + +import ( + "net" + "sync" +) + +var ( + resolverMu sync.Mutex + previousResolver *net.Resolver +) + +// SetDNS replaces Go's process-wide default resolver with an Android VPN-aware +// resolver. The caller must serialize this with the Xray lifecycle. +func SetDNS(server string, protect protectSocket) error { + resolver, err := newResolver(server, protect) + if err != nil { + return err + } + + resolverMu.Lock() + defer resolverMu.Unlock() + if previousResolver == nil { + previousResolver = net.DefaultResolver + } + net.DefaultResolver = resolver + return nil +} + +// ResetDNS restores the resolver that was active before SetDNS. +func ResetDNS() { + resolverMu.Lock() + defer resolverMu.Unlock() + if previousResolver == nil { + return + } + net.DefaultResolver = previousResolver + previousResolver = nil +} diff --git a/dns/resolver.go b/dns/resolver.go new file mode 100644 index 00000000..ed2bdd8a --- /dev/null +++ b/dns/resolver.go @@ -0,0 +1,66 @@ +package dns + +import ( + "context" + "errors" + "fmt" + "net" + "strconv" + "strings" + "syscall" + "time" +) + +const resolverTimeout = 16 * time.Second + +var errProtectDNSConnection = errors.New("protect DNS connection failed") + +type protectSocket func(fd uintptr) bool + +func newResolver(server string, protect protectSocket) (*net.Resolver, error) { + if err := validateServer(server); err != nil { + return nil, err + } + + dialer := &net.Dialer{Timeout: resolverTimeout} + if protect != nil { + dialer.Control = func(_, _ string, connection syscall.RawConn) error { + var protectErr error + if err := connection.Control(func(fd uintptr) { + if !protect(fd) { + protectErr = errProtectDNSConnection + } + }); err != nil { + return err + } + return protectErr + } + } + + return &net.Resolver{ + PreferGo: true, + Dial: func(ctx context.Context, network, _ string) (net.Conn, error) { + // Android may report a loopback resolver to Go. Always use the DNS + // endpoint selected by the VPN configuration instead. + return dialer.DialContext(ctx, network, server) + }, + }, nil +} + +func validateServer(server string) error { + host, portText, err := net.SplitHostPort(server) + if err != nil { + return fmt.Errorf("invalid DNS server %q: %w", server, err) + } + if zoneIndex := strings.LastIndexByte(host, '%'); zoneIndex >= 0 { + host = host[:zoneIndex] + } + if net.ParseIP(host) == nil { + return fmt.Errorf("invalid DNS server IP %q", host) + } + port, err := strconv.Atoi(portText) + if err != nil || port < 1 || port > 65535 { + return fmt.Errorf("invalid DNS server port %q", portText) + } + return nil +} diff --git a/dns/resolver_test.go b/dns/resolver_test.go new file mode 100644 index 00000000..0e1488dc --- /dev/null +++ b/dns/resolver_test.go @@ -0,0 +1,84 @@ +package dns + +import ( + "context" + "net" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNewResolverUsesConfiguredServerAndProtectsSocket(t *testing.T) { + server, err := net.ListenPacket("udp", "127.0.0.1:0") + require.NoError(t, err) + defer server.Close() + + protected := false + resolver, err := newResolver(server.LocalAddr().String(), func(uintptr) bool { + protected = true + return true + }) + require.NoError(t, err) + + connection, err := resolver.Dial( + context.Background(), + "udp", + "127.0.0.1:53", + ) + require.NoError(t, err) + defer connection.Close() + + require.True(t, protected) + require.Equal(t, server.LocalAddr().String(), connection.RemoteAddr().String()) +} + +func TestNewResolverRejectsFailedProtection(t *testing.T) { + server, err := net.ListenPacket("udp", "127.0.0.1:0") + require.NoError(t, err) + defer server.Close() + + resolver, err := newResolver(server.LocalAddr().String(), func(uintptr) bool { + return false + }) + require.NoError(t, err) + + connection, err := resolver.Dial( + context.Background(), + "udp", + "127.0.0.1:53", + ) + if connection != nil { + connection.Close() + } + require.ErrorIs(t, err, errProtectDNSConnection) +} + +func TestNewResolverValidatesServer(t *testing.T) { + tests := []struct { + name string + server string + }{ + {name: "missing port", server: "8.8.8.8"}, + {name: "hostname", server: "dns.example.com:53"}, + {name: "zero port", server: "8.8.8.8:0"}, + {name: "invalid port", server: "8.8.8.8:dns"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + resolver, err := newResolver(test.server, nil) + require.Nil(t, resolver) + require.Error(t, err) + }) + } + + for _, server := range []string{ + "8.8.8.8:53", + "[2001:4860:4860::8888]:53", + } { + t.Run(server, func(t *testing.T) { + resolver, err := newResolver(server, nil) + require.NotNil(t, resolver) + require.NoError(t, err) + }) + } +} diff --git a/readme/README.zh_CN.md b/readme/README.zh_CN.md index 2209662d..263ee5d9 100644 --- a/readme/README.zh_CN.md +++ b/readme/README.zh_CN.md @@ -141,6 +141,25 @@ getXrayState 用于解决 Android 上 socket protect 问题。 +### DNS 解析器 + +Android VPN 运行时可能会向 Go 解析器提供回环 DNS 地址。请在调用 +`runXray` 前调用 `SetDNS`,让 Go 使用 VPN 配置指定的 DNS,并通过 +`protectFd` 将 DNS socket 排除在 VPN 隧道外。DNS 必须是包含端口的 IP +地址,例如 `8.8.8.8:53` 或 `[2001:4860:4860::8888]:53`。 + +Xray 停止后调用 `ResetDNS`。这两个 API 仅存在于 Android 产物中,并会 +修改 Go 进程级默认解析器。 + +```java +LibXray.setDNS(controller, "8.8.8.8:53"); +LibXray.invoke(runXrayRequest); + +// 稍后停止 Core 时: +LibXray.invoke(stopXrayRequest); +LibXray.resetDNS(); +``` + ## geo ### count