diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 36bdc62f..b59ce4f3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -184,6 +184,12 @@ jobs: run: | python3 build/main.py android + - name: Test Windows desktop Core + if: matrix.target == 'windows' + env: + GOARCH: ${{ matrix.goarch }} + run: go test ./dns ./desktop_bin -count=1 + - name: Build Windows if: matrix.target == 'windows' env: @@ -195,18 +201,28 @@ jobs: if: matrix.target == 'windows' shell: pwsh run: | - $bytes = [System.IO.File]::ReadAllBytes("windows_dll/libXray.dll") - $peOffset = [System.BitConverter]::ToInt32($bytes, 0x3c) - $machine = [System.BitConverter]::ToUInt16($bytes, $peOffset + 4) $expected = switch ("${{ matrix.arch }}") { "x64" { 0x8664 } "arm64" { 0xaa64 } default { throw "unsupported Windows architecture: ${{ matrix.arch }}" } } - if ($machine -ne $expected) { - throw ("unexpected PE machine 0x{0:X4}; expected 0x{1:X4}" -f $machine, $expected) + foreach ($path in @("windows_dll/libXray.dll", "bin/xray.exe")) { + $bytes = [System.IO.File]::ReadAllBytes($path) + $peOffset = [System.BitConverter]::ToInt32($bytes, 0x3c) + $machine = [System.BitConverter]::ToUInt16($bytes, $peOffset + 4) + if ($machine -ne $expected) { + throw ("unexpected PE machine for {0}: 0x{1:X4}; expected 0x{2:X4}" -f $path, $machine, $expected) + } } + - name: Verify Linux desktop Core CLI + if: matrix.target == 'linux' + run: ./bin/xray -h + + - name: Verify Windows desktop Core CLI + if: matrix.target == 'windows' + run: ./bin/xray.exe -h + - name: Build Apple (macOS/iOS) if: matrix.target == 'apple' run: | @@ -225,11 +241,13 @@ jobs: mkdir -p "dist/${{ matrix.artifact }}" cp linux_so/libXray.so "dist/${{ matrix.artifact }}/" cp linux_so/libXray.h "dist/${{ matrix.artifact }}/" + cp bin/xray "dist/${{ matrix.artifact }}/" ;; windows) mkdir -p "dist/${{ matrix.artifact }}" cp windows_dll/libXray.dll "dist/${{ matrix.artifact }}/" cp windows_dll/libXray.h "dist/${{ matrix.artifact }}/" + cp bin/xray.exe "dist/${{ matrix.artifact }}/" ;; android) mkdir -p "dist/${{ matrix.artifact }}" diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 7f67360e..df716d43 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -32,11 +32,12 @@ jobs: - name: Test with race detector run: go test -race ./... -count=1 -timeout 15m - - name: Build Linux C ABI + - name: Build Linux artifacts run: | python3 build/main.py linux grep -q 'CGoFree' linux_so/libXray.h nm -D linux_so/libXray.so | grep -q ' CGoFree$' + ./bin/xray -h - name: Verify source was restored run: git diff --exit-code diff --git a/AGENTS.md b/AGENTS.md index a996452a..fe403bbe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,7 @@ the generic API. | `share/` | Share-link parsing, validation, and generation. | | `geo/` | GeoData inspection helpers. | | `controller/` | Android socket protection and process lookup integration. | -| `dns/` | Android VPN-aware process DNS resolver. | +| `dns/` | VPN-aware process DNS resolver and desktop interface binding. | | `memory/` | Platform-specific memory-pressure handling. | | `nodep/` | Small utilities that do not depend on the managed Xray instance. | | `cgo_bridge/` | C ABI exports used by Apple, Linux, Windows, and Dart FFI. | @@ -139,13 +139,16 @@ typed JSON contract. ## Linux and Windows -Linux produces `linux_so/libXray.so`; Windows produces -`windows_dll/libXray.dll`. Both artifacts expose the C ABI. libXray does not -provide or manage a desktop executable wrapper. +Linux produces `linux_so/libXray.so` and `bin/xray`; Windows produces +`windows_dll/libXray.dll` and `bin/xray.exe`. The libraries expose the C ABI. +The session Core accepts only `run -dns -interface -config +`, installs a process-wide protected Go resolver, and runs one Xray +instance until termination. # Building -Build scripts use the Xray-core version pinned by `go.mod` by default: +Build scripts use the Xray-core version pinned by `go.mod` by default. Linux +and Windows builds produce both the native library and session Core: ```shell python3 build/main.py android diff --git a/README.md b/README.md index 10eea4db..bab9ab30 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,16 @@ python3 build/main.py windows local ``` +Linux and Windows builds also produce `bin/xray` or `bin/xray.exe`. This +session Core protects Go DNS lookups from the VPN route and accepts only: + +```shell +xray run -dns -interface -config +``` + +All three options are required. `-dns` must be an IP endpoint, and `-config` +points directly to the Xray JSON configuration. + > [!WARNING] > **Use only one Go runtime per process.** Go does not support loading multiple > independently built Go runtimes into one process. Every native libXray diff --git a/build/app/build.py b/build/app/build.py index e1779bf4..8d467f8e 100644 --- a/build/app/build.py +++ b/build/app/build.py @@ -2,6 +2,7 @@ import subprocess from app.cmd import ( + create_dir_if_not_exists, delete_file_if_exists, delete_dir_if_exists, ) @@ -162,6 +163,27 @@ def prepare_static_lib(self): def main_package(self) -> str: return "./cgo_bridge" + def build_desktop_bin(self, file_name: str): + output_dir = os.path.join(self.lib_dir, "bin") + create_dir_if_not_exists(output_dir) + output_file = os.path.join(output_dir, file_name) + run_env = os.environ.copy() + run_env["CGO_ENABLED"] = "0" + cmd = [ + "go", + "build", + "-trimpath", + "-buildvcs=false", + "-ldflags", + "-s -w -buildid=", + f"-o={output_file}", + "./desktop_bin", + ] + print(cmd) + ret = subprocess.run(cmd, cwd=self.lib_dir, env=run_env) + if ret.returncode != 0: + raise Exception("build_desktop_bin failed") + def before_build(self): self.prepare_xray_core() self.init_go_env() diff --git a/build/app/linux.py b/build/app/linux.py index 4f8fed02..c05130d2 100644 --- a/build/app/linux.py +++ b/build/app/linux.py @@ -13,6 +13,7 @@ def __init__(self, build_dir: str, use_local_xray_core: bool = False): create_dir_if_not_exists(self.framework_dir) self.lib_file = "libXray.so" self.lib_header_file = "libXray.h" + self.bin_file = "xray" def before_build(self): super().before_build() @@ -23,6 +24,7 @@ def build(self): try: self.before_build() self.build_linux() + self.build_desktop_bin(self.bin_file) finally: try: self.after_build() diff --git a/build/app/windows.py b/build/app/windows.py index d97ed42a..31d01127 100644 --- a/build/app/windows.py +++ b/build/app/windows.py @@ -13,6 +13,7 @@ def __init__(self, build_dir: str, use_local_xray_core: bool = False): create_dir_if_not_exists(self.framework_dir) self.lib_file = "libXray.dll" self.lib_header_file = "libXray.h" + self.bin_file = "xray.exe" def before_build(self): super().before_build() @@ -23,6 +24,7 @@ def build(self): try: self.before_build() self.build_windows() + self.build_desktop_bin(self.bin_file) finally: try: self.after_build() diff --git a/desktop_bin/main.go b/desktop_bin/main.go new file mode 100644 index 00000000..33ba90af --- /dev/null +++ b/desktop_bin/main.go @@ -0,0 +1,90 @@ +//go:build windows || (linux && !android) + +package main + +import ( + "errors" + "flag" + "fmt" + "io" + "os" + "os/signal" + "syscall" + + "github.com/xtls/libxray/dns" + "github.com/xtls/libxray/xray" +) + +type runOptions struct { + dns string + interfaceName string + configPath string +} + +func parseRunOptions(args []string) (runOptions, error) { + var options runOptions + if len(args) == 0 || args[0] != "run" { + return options, errors.New("expected run command") + } + + flags := flag.NewFlagSet("run", flag.ContinueOnError) + flags.SetOutput(io.Discard) + flags.StringVar(&options.dns, "dns", "", "DNS server IP endpoint") + flags.StringVar(&options.interfaceName, "interface", "", "outbound network interface") + flags.StringVar(&options.configPath, "config", "", "Xray JSON configuration path") + if err := flags.Parse(args[1:]); err != nil { + return options, err + } + if flags.NArg() != 0 { + return options, errors.New("unexpected positional arguments") + } + if options.dns == "" || options.interfaceName == "" || options.configPath == "" { + return options, errors.New("dns, interface, and config are required") + } + return options, nil +} + +func run(options runOptions) error { + config, err := os.ReadFile(options.configPath) + if err != nil { + return err + } + if err := dns.SetDNS(options.dns, options.interfaceName); err != nil { + return err + } + defer dns.ResetDNS() + + if err := xray.RunXray(string(config)); err != nil { + return err + } + + signals := make(chan os.Signal, 1) + signal.Notify(signals, os.Interrupt, syscall.SIGTERM) + defer signal.Stop(signals) + <-signals + return xray.StopXray() +} + +func printUsage() { + fmt.Fprintln(os.Stdout, "Usage: xray run -dns -interface -config ") +} + +func main() { + if len(os.Args) == 2 && (os.Args[1] == "-h" || os.Args[1] == "--help") { + printUsage() + return + } + + options, err := parseRunOptions(os.Args[1:]) + if errors.Is(err, flag.ErrHelp) { + printUsage() + return + } + if err == nil { + err = run(options) + } + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/desktop_bin/main_test.go b/desktop_bin/main_test.go new file mode 100644 index 00000000..8d77f4d0 --- /dev/null +++ b/desktop_bin/main_test.go @@ -0,0 +1,24 @@ +//go:build windows || (linux && !android) + +package main + +import "testing" + +func TestParseRunOptions(t *testing.T) { + options, err := parseRunOptions([]string{ + "run", + "-dns", "8.8.8.8:53", + "-interface", "Ethernet", + "-config", `C:\run\xray.json`, + }) + if err != nil { + t.Fatal(err) + } + if options.dns != "8.8.8.8:53" || options.interfaceName != "Ethernet" || options.configPath != `C:\run\xray.json` { + t.Fatalf("unexpected options: %#v", options) + } + + if _, err := parseRunOptions([]string{"run", "-config", "xray.json"}); err == nil { + t.Fatal("missing DNS protection options were accepted") + } +} diff --git a/dns/dns_android.go b/dns/dns_android.go index 8422a047..34ea7fae 100644 --- a/dns/dns_android.go +++ b/dns/dns_android.go @@ -2,16 +2,6 @@ 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 { @@ -20,22 +10,11 @@ func SetDNS(server string, protect protectSocket) error { return err } - resolverMu.Lock() - defer resolverMu.Unlock() - if previousResolver == nil { - previousResolver = net.DefaultResolver - } - net.DefaultResolver = resolver + installDefaultResolver(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 + restoreDefaultResolver() } diff --git a/dns/dns_desktop.go b/dns/dns_desktop.go new file mode 100644 index 00000000..d3001ed3 --- /dev/null +++ b/dns/dns_desktop.go @@ -0,0 +1,47 @@ +//go:build windows || (linux && !android) + +package dns + +import ( + "errors" + "net" +) + +// SetDNS installs a process-wide resolver bound to interfaceName. +func SetDNS(server, interfaceName string) error { + if interfaceName == "" { + return errors.New("dns interface is required") + } + iface, err := net.InterfaceByName(interfaceName) + if err != nil { + return err + } + if err := validateDNSInterface(iface); err != nil { + return err + } + + resolver, err := newProtectedResolver(server, func(network string, fd uintptr) error { + return bindDNSInterface(network, fd, iface) + }) + if err != nil { + return err + } + if err := preflightResolver(resolver, server); err != nil { + return err + } + + installDefaultResolver(resolver) + return nil +} + +func validateDNSInterface(iface *net.Interface) error { + if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 { + return errors.New("dns interface must be an active non-loopback interface") + } + return nil +} + +// ResetDNS restores the resolver that was active before SetDNS. +func ResetDNS() { + restoreDefaultResolver() +} diff --git a/dns/dns_desktop_test.go b/dns/dns_desktop_test.go new file mode 100644 index 00000000..d5eacda9 --- /dev/null +++ b/dns/dns_desktop_test.go @@ -0,0 +1,70 @@ +//go:build windows || (linux && !android) + +package dns + +import ( + "net" + "testing" + + "github.com/stretchr/testify/require" + xrayNet "github.com/xtls/xray-core/common/net" +) + +func TestSetDNSInstallsAndRestoresResolver(t *testing.T) { + interfaces, err := net.Interfaces() + require.NoError(t, err) + + var interfaceName string + for _, iface := range interfaces { + if iface.Flags&net.FlagUp != 0 && iface.Flags&net.FlagLoopback == 0 { + interfaceName = iface.Name + break + } + } + if interfaceName == "" { + t.Skip("no active non-loopback interface") + } + + original := net.DefaultResolver + originalXray := xrayNet.DefaultResolver + t.Cleanup(func() { + resolverMu.Lock() + defer resolverMu.Unlock() + net.DefaultResolver = original + xrayNet.DefaultResolver = originalXray + previousResolver = nil + previousXrayResolver = nil + }) + + require.NoError(t, SetDNS("8.8.8.8:53", interfaceName)) + require.NotSame(t, original, net.DefaultResolver) + require.Same(t, net.DefaultResolver, xrayNet.DefaultResolver) + require.True(t, net.DefaultResolver.PreferGo) + + ResetDNS() + require.Same(t, original, net.DefaultResolver) + require.Same(t, originalXray, xrayNet.DefaultResolver) +} + +func TestValidateDNSInterface(t *testing.T) { + tests := []struct { + name string + flags net.Flags + wantErr bool + }{ + {name: "active", flags: net.FlagUp}, + {name: "inactive", wantErr: true}, + {name: "loopback", flags: net.FlagUp | net.FlagLoopback, wantErr: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := validateDNSInterface(&net.Interface{Flags: test.flags}) + if test.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + }) + } +} diff --git a/dns/dns_linux.go b/dns/dns_linux.go new file mode 100644 index 00000000..ca571b6e --- /dev/null +++ b/dns/dns_linux.go @@ -0,0 +1,17 @@ +//go:build linux && !android + +package dns + +import ( + "fmt" + "net" + + "golang.org/x/sys/unix" +) + +func bindDNSInterface(network string, fd uintptr, iface *net.Interface) error { + if err := unix.BindToDevice(int(fd), iface.Name); err != nil { + return fmt.Errorf("bind DNS %s socket to interface %q: %w", network, iface.Name, err) + } + return nil +} diff --git a/dns/dns_windows.go b/dns/dns_windows.go new file mode 100644 index 00000000..cc4d7087 --- /dev/null +++ b/dns/dns_windows.go @@ -0,0 +1,43 @@ +//go:build windows + +package dns + +import ( + "fmt" + "math/bits" + "net" + + "golang.org/x/sys/windows" +) + +const ( + ipUnicastInterface = 31 + ipv6UnicastInterface = 31 +) + +func bindDNSInterface(network string, fd uintptr, iface *net.Interface) error { + var err error + switch network { + case "tcp4", "udp4", "ip4": + index := int(int32(bits.ReverseBytes32(uint32(iface.Index)))) + err = windows.SetsockoptInt( + windows.Handle(fd), + windows.IPPROTO_IP, + ipUnicastInterface, + index, + ) + case "tcp6", "udp6", "ip6": + err = windows.SetsockoptInt( + windows.Handle(fd), + windows.IPPROTO_IPV6, + ipv6UnicastInterface, + iface.Index, + ) + default: + return fmt.Errorf("unsupported DNS network %q for interface %q", network, iface.Name) + } + if err != nil { + return fmt.Errorf("bind DNS %s socket to interface %q: %w", network, iface.Name, err) + } + return nil +} diff --git a/dns/resolver.go b/dns/resolver.go index ed2bdd8a..990194ff 100644 --- a/dns/resolver.go +++ b/dns/resolver.go @@ -7,46 +7,98 @@ import ( "net" "strconv" "strings" + "sync" "syscall" "time" + + xrayNet "github.com/xtls/xray-core/common/net" ) const resolverTimeout = 16 * time.Second var errProtectDNSConnection = errors.New("protect DNS connection failed") +var ( + resolverMu sync.Mutex + previousResolver *net.Resolver + previousXrayResolver *net.Resolver +) + type protectSocket func(fd uintptr) bool +type controlSocket func(network string, fd uintptr) error + +func installDefaultResolver(resolver *net.Resolver) { + resolverMu.Lock() + defer resolverMu.Unlock() + if previousResolver == nil { + previousResolver = net.DefaultResolver + previousXrayResolver = xrayNet.DefaultResolver + } + net.DefaultResolver = resolver + // Xray-core caches the standard resolver during package initialization. + xrayNet.DefaultResolver = resolver +} + +func restoreDefaultResolver() { + resolverMu.Lock() + defer resolverMu.Unlock() + if previousResolver == nil { + return + } + net.DefaultResolver = previousResolver + xrayNet.DefaultResolver = previousXrayResolver + previousResolver = nil + previousXrayResolver = nil +} func newResolver(server string, protect protectSocket) (*net.Resolver, error) { + var control controlSocket + if protect != nil { + control = func(_ string, fd uintptr) error { + if !protect(fd) { + return errProtectDNSConnection + } + return nil + } + } + return newProtectedResolver(server, control) +} + +func newProtectedResolver(server string, control controlSocket) (*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 control != nil { + dialer.Control = func(network, _ string, connection syscall.RawConn) error { + var controlErr error if err := connection.Control(func(fd uintptr) { - if !protect(fd) { - protectErr = errProtectDNSConnection - } + controlErr = control(network, fd) }); err != nil { return err } - return protectErr + return controlErr } } 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. + // Always use the DNS endpoint selected by the VPN configuration. return dialer.DialContext(ctx, network, server) }, }, nil } +func preflightResolver(resolver *net.Resolver, server string) error { + connection, err := resolver.Dial(context.Background(), "udp", server) + if err != nil { + return err + } + return connection.Close() +} + func validateServer(server string) error { host, portText, err := net.SplitHostPort(server) if err != nil { diff --git a/dns/resolver_test.go b/dns/resolver_test.go index 0e1488dc..d41934ab 100644 --- a/dns/resolver_test.go +++ b/dns/resolver_test.go @@ -2,12 +2,50 @@ package dns import ( "context" + "errors" "net" "testing" "github.com/stretchr/testify/require" + xrayNet "github.com/xtls/xray-core/common/net" ) +func TestDefaultResolverLifecycle(t *testing.T) { + before := net.DefaultResolver + beforeXray := xrayNet.DefaultResolver + original := &net.Resolver{} + originalXray := &net.Resolver{} + net.DefaultResolver = original + xrayNet.DefaultResolver = originalXray + t.Cleanup(func() { + resolverMu.Lock() + defer resolverMu.Unlock() + net.DefaultResolver = before + xrayNet.DefaultResolver = beforeXray + previousResolver = nil + previousXrayResolver = nil + }) + + first := &net.Resolver{PreferGo: true} + second := &net.Resolver{StrictErrors: true} + + installDefaultResolver(first) + require.Same(t, first, net.DefaultResolver) + require.Same(t, first, xrayNet.DefaultResolver) + + installDefaultResolver(second) + require.Same(t, second, net.DefaultResolver) + require.Same(t, second, xrayNet.DefaultResolver) + + restoreDefaultResolver() + require.Same(t, original, net.DefaultResolver) + require.Same(t, originalXray, xrayNet.DefaultResolver) + + restoreDefaultResolver() + require.Same(t, original, net.DefaultResolver) + require.Same(t, originalXray, xrayNet.DefaultResolver) +} + func TestNewResolverUsesConfiguredServerAndProtectsSocket(t *testing.T) { server, err := net.ListenPacket("udp", "127.0.0.1:0") require.NoError(t, err) @@ -53,6 +91,21 @@ func TestNewResolverRejectsFailedProtection(t *testing.T) { require.ErrorIs(t, err, errProtectDNSConnection) } +func TestNewProtectedResolverReturnsControlError(t *testing.T) { + controlErr := errors.New("bind DNS interface") + controlCalled := false + resolver, err := newProtectedResolver("127.0.0.1:53", func(string, uintptr) error { + controlCalled = true + return controlErr + }) + require.NoError(t, err) + + err = preflightResolver(resolver, "127.0.0.1:53") + + require.True(t, controlCalled) + require.ErrorIs(t, err, controlErr) +} + func TestNewResolverValidatesServer(t *testing.T) { tests := []struct { name string diff --git a/go.mod b/go.mod index cef1f3b3..d258b2fa 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/metacubex/age v0.0.0-20260603010618-28d156b4ea78 github.com/stretchr/testify v1.11.1 github.com/xtls/xray-core v1.260327.1-0.20260728075948-5ca6f4b7d4dc + golang.org/x/sys v0.47.0 google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v3 v3.0.1 ) @@ -49,7 +50,6 @@ require ( golang.org/x/mod v0.38.0 // indirect golang.org/x/net v0.57.0 // indirect golang.org/x/sync v0.22.0 // indirect - golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.40.0 // indirect golang.org/x/time v0.14.0 // indirect golang.org/x/tools v0.48.0 // indirect diff --git a/readme/README.zh_CN.md b/readme/README.zh_CN.md index a9e605c7..1c75b6fc 100644 --- a/readme/README.zh_CN.md +++ b/readme/README.zh_CN.md @@ -34,6 +34,16 @@ python3 build/main.py windows python3 build/main.py windows local ``` +Linux 和 Windows 构建还会生成 `bin/xray` 或 `bin/xray.exe`。该会话 Core +会保护 Go DNS 查询不被 VPN 路由重新捕获,并且只接受以下命令: + +```shell +xray run -dns -interface <网卡名> -config +``` + +三个参数都必须提供。`-dns` 必须是 IP endpoint,`-config` 直接指向 Xray +JSON 配置。 + > [!WARNING] > **每个进程只能使用一个 Go runtime。** Go 不支持在同一进程中加载多个独立构建的 > Go runtime。libXray 的所有原生产物都会嵌入 Go runtime,无论它们通过 cgo 还是