Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 23 additions & 5 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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: |
Expand All @@ -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 }}"
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 8 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down Expand Up @@ -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 <IP:port> -interface <name> -config
<xray.json>`, 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
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Comment thread
yiguodev marked this conversation as resolved.

```shell
xray run -dns <IP:port> -interface <name> -config <xray.json>
```

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
Expand Down
22 changes: 22 additions & 0 deletions build/app/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import subprocess

from app.cmd import (
create_dir_if_not_exists,
delete_file_if_exists,
delete_dir_if_exists,
)
Expand Down Expand Up @@ -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()
Expand Down
2 changes: 2 additions & 0 deletions build/app/linux.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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()
Expand Down
2 changes: 2 additions & 0 deletions build/app/windows.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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()
Expand Down
90 changes: 90 additions & 0 deletions desktop_bin/main.go
Original file line number Diff line number Diff line change
@@ -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 <IP:port> -interface <name> -config <xray.json>")
}

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)
}
}
24 changes: 24 additions & 0 deletions desktop_bin/main_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
25 changes: 2 additions & 23 deletions dns/dns_android.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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()
}
47 changes: 47 additions & 0 deletions dns/dns_desktop.go
Original file line number Diff line number Diff line change
@@ -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
}

Comment thread
yiguodev marked this conversation as resolved.
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()
}
Loading