From f6c636cbadae1d9c01444c90f5e9a501411b8c5f Mon Sep 17 00:00:00 2001 From: yiguo Date: Fri, 10 Jul 2026 21:27:38 +0800 Subject: [PATCH 1/3] Harden Xray lifecycle and C ABI builds --- .github/workflows/build.yml | 4 +- .github/workflows/validate.yml | 48 ++++++++++++ README.md | 4 + build/app/android.py | 49 +++++++------ build/app/apple_go.py | 129 ++++++++------------------------- build/app/apple_gomobile.py | 39 +++++----- build/app/build.py | 110 ++++++++++------------------ build/app/linux.py | 18 +++-- build/app/windows.py | 18 +++-- build/template/main.gotemplate | 11 --- cgo_bridge/main.go | 25 +++++++ readme/README.zh_CN.md | 4 + xray/xray.go | 32 +++++++- xray/xray_test.go | 118 ++++++++++++++++++++++++++++++ 14 files changed, 364 insertions(+), 245 deletions(-) create mode 100644 .github/workflows/validate.yml delete mode 100644 build/template/main.gotemplate create mode 100644 cgo_bridge/main.go create mode 100644 xray/xray_test.go diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 85493c55..d57cbab6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -11,8 +11,8 @@ concurrency: cancel-in-progress: false env: - GO_VERSION: "stable" - XCODE_VERSION: "latest-stable" + GO_VERSION: "1.26.5" + XCODE_VERSION: "26.6" PYTHON_VERSION: "3.12" NDK_VERSION: r29 diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 00000000..5cdc3c9c --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,48 @@ +name: Validate libXray + +on: + push: + branches: + - main + pull_request: + +concurrency: + group: validate-libxray-${{ github.ref }} + cancel-in-progress: true + +env: + GO_VERSION: "1.26.5" + +jobs: + go: + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-go@v6 + with: + go-version: ${{ env.GO_VERSION }} + check-latest: true + + - name: Download modules + run: go mod download + + - name: Vet + run: go vet ./... + + - name: Test with race detector + run: go test -race ./... -count=1 -timeout 15m + + - name: Build Linux C ABI + run: | + python3 build/main.py linux + grep -q 'CGoFree' linux_so/libXray.h + nm -D linux_so/libXray.so | grep -q ' CGoFree$' + + - name: Verify source was restored + run: git diff --exit-code + + - name: Check whitespace + run: git diff --check diff --git a/README.md b/README.md index 7b14c801..255b47f0 100644 --- a/README.md +++ b/README.md @@ -121,8 +121,12 @@ The C export is: ```c char* CGoInvoke(char* requestJSON); +void CGoFree(char* value); ``` +`CGoInvoke` allocates its response. The caller must release every non-null +response with `CGoFree`; do not use a platform allocator directly. + The request is a JSON object: ```json diff --git a/build/app/android.py b/build/app/android.py index d8fdd708..9ab3c3bd 100644 --- a/build/app/android.py +++ b/build/app/android.py @@ -12,26 +12,29 @@ def before_build(self): self.prepare_gomobile() def build(self): - self.before_build() - - clean_files = ["libXray-sources.jar", "libXray.aar"] - self.clean_lib_files(clean_files) - os.chdir(self.lib_dir) - # keep same with flutter - ret = subprocess.run( - [ - "gomobile", - "bind", - "-target", - "android", - "-androidapi", - "21", - "-ldflags=-checklinkname=0 -extldflags=-Wl,-z,max-page-size=16384", - ] - ) - if ret.returncode != 0: - raise Exception("build failed") - - self.after_build() - - self.revert_go_env() + self.snapshot_go_env() + try: + self.before_build() + + clean_files = ["libXray-sources.jar", "libXray.aar"] + self.clean_lib_files(clean_files) + os.chdir(self.lib_dir) + # keep same with flutter + ret = subprocess.run( + [ + "gomobile", + "bind", + "-target", + "android", + "-androidapi", + "21", + "-ldflags=-checklinkname=0 -extldflags=-Wl,-z,max-page-size=16384", + ] + ) + if ret.returncode != 0: + raise Exception("build failed") + finally: + try: + self.after_build() + finally: + self.restore_go_env() diff --git a/build/app/apple_go.py b/build/app/apple_go.py index df6c5869..52fadb3d 100644 --- a/build/app/apple_go.py +++ b/build/app/apple_go.py @@ -1,8 +1,6 @@ import os.path import shutil import subprocess -import random -import time from app.build import Builder from app.cmd import create_dir_if_not_exists, delete_dir_if_exists @@ -104,32 +102,34 @@ def before_build(self): self.prepare_static_lib() def build(self): - self.before_build() - # build ios - self.build_targets(self.ios_targets) - self.merge_static_lib( - self.ios_targets[1].sdk, - [self.ios_targets[1].apple_arch, self.ios_targets[2].apple_arch], - ) - # build macos - self.build_targets(self.macos_targets) - self.merge_static_lib( - self.macos_targets[0].sdk, - [self.macos_targets[0].apple_arch, self.macos_targets[1].apple_arch], - ) - # build tvos - self.build_targets(self.tvos_targets) - self.merge_static_lib( - self.tvos_targets[1].sdk, - [self.tvos_targets[1].apple_arch, self.tvos_targets[2].apple_arch], - ) - - self.after_build() - - self.create_include_dir() - self.create_framework() - - self.revert_go_env() + self.snapshot_go_env() + try: + self.before_build() + # build ios + self.build_targets(self.ios_targets) + self.merge_static_lib( + self.ios_targets[1].sdk, + [self.ios_targets[1].apple_arch, self.ios_targets[2].apple_arch], + ) + # build macos + self.build_targets(self.macos_targets) + self.merge_static_lib( + self.macos_targets[0].sdk, + [self.macos_targets[0].apple_arch, self.macos_targets[1].apple_arch], + ) + # build tvos + self.build_targets(self.tvos_targets) + self.merge_static_lib( + self.tvos_targets[1].sdk, + [self.tvos_targets[1].apple_arch, self.tvos_targets[2].apple_arch], + ) + self.create_include_dir() + self.create_framework() + finally: + try: + self.after_build() + finally: + self.restore_go_env() def build_targets(self, targets: list[AppleTarget]): for target in targets: @@ -162,51 +162,7 @@ def run_build_cmd( run_env["CGO_ENABLED"] = "1" run_env["DARWIN_SDK"] = sdk - # Enhanced obfuscation for uniqueness of each build - ldflags = "-s -w" # Strip symbol table and DWARF debug info - - # Add build timestamp with microseconds for greater uniqueness - build_timestamp = time.strftime("%Y%m%d%H%M%S") + str( - int(time.time() * 1000) % 1000 - ) - ldflags += f" -X 'main.buildTime={build_timestamp}'" - - # Add random identifier for each build - random_id = "".join( - random.choices( - "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", k=16 - ) - ) - ldflags += f" -X 'main.buildID={random_id}'" - - # Add git commit hash if available - try: - git_hash = ( - subprocess.check_output( - ["git", "rev-parse", "--short", "HEAD"], stderr=subprocess.DEVNULL - ) - .decode() - .strip() - ) - ldflags += f" -X 'main.buildVersion={git_hash}'" - except: - # If git is not available, use a random hash - fake_hash = "".join(random.choices("0123456789abcdef", k=7)) - ldflags += f" -X 'main.buildVersion={fake_hash}'" - - # Add random unused variables to change binary fingerprint - for i in range(5): - var_name = f"unusedVar{i}" - var_value = "".join( - random.choices( - "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", - k=8, - ) - ) - ldflags += f" -X 'main.{var_name}={var_value}'" - - # Use compatible optimization flags - gcflags = "all=-l=4" # Limit inlining but keep other optimizations + ldflags = "-s -w -buildid=" cmd = [ "go", @@ -214,10 +170,9 @@ def run_build_cmd( "-trimpath", # Remove all file system paths "-ldflags", ldflags, - "-gcflags", - gcflags, f"-o={output_file}", "-buildmode=c-archive", + self.main_package(), ] os.chdir(self.lib_dir) print(cmd) @@ -298,27 +253,3 @@ def create_framework(self): ret = subprocess.run(cmd) if ret.returncode != 0: raise Exception(f"create_framework failed") - - def after_build(self): - super().after_build() - self.reset_files() - - # Modify framework metadata for additional uniqueness - try: - framework_path = os.path.join(self.lib_dir, "LibXray.xcframework") - info_plist = os.path.join(framework_path, "Info.plist") - if os.path.exists(info_plist): - # Add random comment to Info.plist - random_comment = f"" - with open(info_plist, "r") as f: - content = f.read() - - if " str: + return "./cgo_bridge" def before_build(self): self.prepare_xray_core() @@ -142,41 +146,3 @@ def build(self): def after_build(self): pass - - def reset_files(self): - self.clean_lib_files(["main.go"]) - files = os.listdir(self.lib_dir) - for file in files: - if file.endswith(".go"): - self.reset_package_name(file) - - def reset_package_name(self, file_name: str): - file_path = os.path.join(self.lib_dir, file_name) - new_lines = [] - with open(file_path, "r") as f: - lines = f.readlines() - for line in lines: - new_line = line - if re.match(r"^package\s+main", line): - new_line = "package libXray\n" - new_lines.append(new_line) - with open(file_path, "w") as f: - f.writelines(new_lines) - - def revert_go_env(self): - os.chdir(self.lib_dir) - ret = subprocess.run( - ["go", "mod", "edit", f"-dropreplace={XRAY_CORE_MOD_NAME}"] - ) - if ret.returncode != 0: - raise Exception("go mod edit dropreplace failed") - - ret = subprocess.run( - ["go", "get", f"{XRAY_CORE_MOD_NAME}@{DEFAULT_XRAY_CORE_VERSION}"] - ) - if ret.returncode != 0: - raise Exception("go get xray-core failed") - - ret = subprocess.run(["go", "mod", "tidy"]) - if ret.returncode != 0: - raise Exception("go mod tidy failed") diff --git a/build/app/linux.py b/build/app/linux.py index d0d69ee5..4f8fed02 100644 --- a/build/app/linux.py +++ b/build/app/linux.py @@ -19,10 +19,15 @@ def before_build(self): self.prepare_static_lib() def build(self): - self.before_build() - self.build_linux() - self.after_build() - self.revert_go_env() + self.snapshot_go_env() + try: + self.before_build() + self.build_linux() + finally: + try: + self.after_build() + finally: + self.restore_go_env() def build_linux(self): output_dir = self.framework_dir @@ -41,13 +46,10 @@ def build_linux(self): "-s -w", f"-o={output_file}", "-buildmode=c-shared", + self.main_package(), ] os.chdir(self.lib_dir) print(cmd) ret = subprocess.run(cmd, env=run_env) if ret.returncode != 0: raise Exception(f"build_linux failed") - - def after_build(self): - super().after_build() - self.reset_files() diff --git a/build/app/windows.py b/build/app/windows.py index 7b0353ae..d97ed42a 100644 --- a/build/app/windows.py +++ b/build/app/windows.py @@ -19,10 +19,15 @@ def before_build(self): self.prepare_static_lib() def build(self): - self.before_build() - self.build_windows() - self.after_build() - self.revert_go_env() + self.snapshot_go_env() + try: + self.before_build() + self.build_windows() + finally: + try: + self.after_build() + finally: + self.restore_go_env() def build_windows(self): output_dir = self.framework_dir @@ -41,13 +46,10 @@ def build_windows(self): "-s -w", f"-o={output_file}", "-buildmode=c-shared", + self.main_package(), ] os.chdir(self.lib_dir) print(cmd) ret = subprocess.run(cmd, env=run_env) if ret.returncode != 0: raise Exception(f"run_build_cmd failed") - - def after_build(self): - super().after_build() - self.reset_files() diff --git a/build/template/main.gotemplate b/build/template/main.gotemplate deleted file mode 100644 index 216320e5..00000000 --- a/build/template/main.gotemplate +++ /dev/null @@ -1,11 +0,0 @@ -package main - -import "C" - -func main() {} - -//export CGoInvoke -func CGoInvoke(requestJSON *C.char) *C.char { - text := C.GoString(requestJSON) - return C.CString(Invoke(text)) -} diff --git a/cgo_bridge/main.go b/cgo_bridge/main.go new file mode 100644 index 00000000..2b9ed660 --- /dev/null +++ b/cgo_bridge/main.go @@ -0,0 +1,25 @@ +package main + +/* +#include +*/ +import "C" + +import ( + "unsafe" + + libXray "github.com/xtls/libxray" +) + +func main() {} + +//export CGoInvoke +func CGoInvoke(requestJSON *C.char) *C.char { + text := C.GoString(requestJSON) + return C.CString(libXray.Invoke(text)) +} + +//export CGoFree +func CGoFree(value *C.char) { + C.free(unsafe.Pointer(value)) +} diff --git a/readme/README.zh_CN.md b/readme/README.zh_CN.md index cbda2a92..a1cab762 100644 --- a/readme/README.zh_CN.md +++ b/readme/README.zh_CN.md @@ -89,8 +89,12 @@ C 导出为: ```c char* CGoInvoke(char* requestJSON); +void CGoFree(char* value); ``` +`CGoInvoke` 会分配返回值。调用方必须使用 `CGoFree` 释放每个非空返回值, +不要直接使用平台分配器释放。 + 请求是 JSON 对象: ```json diff --git a/xray/xray.go b/xray/xray.go index 3dcea0c9..a684bffb 100644 --- a/xray/xray.go +++ b/xray/xray.go @@ -1,7 +1,9 @@ package xray import ( + "errors" "runtime/debug" + "sync" "github.com/xtls/libxray/memory" "github.com/xtls/xray-core/common/cmdarg" @@ -10,9 +12,12 @@ import ( ) var ( - coreServer *core.Instance + coreServerMu sync.Mutex + coreServer *core.Instance ) +var ErrAlreadyRunning = errors.New("xray is already running") + func StartXray(configPath string) (*core.Instance, error) { file := cmdarg.Arg{configPath} config, err := core.LoadConfig("json", file) @@ -44,15 +49,23 @@ func StartXrayFromJSON(configJSON string) (*core.Instance, error) { // Run Xray instance. // configPath means the config.json file path. func RunXray(configPath string) (err error) { + coreServerMu.Lock() + defer coreServerMu.Unlock() + if coreServer != nil { + return ErrAlreadyRunning + } + memory.InitForceFree() - coreServer, err = StartXray(configPath) + server, err := StartXray(configPath) if err != nil { return } - if err = coreServer.Start(); err != nil { + if err = server.Start(); err != nil { + _ = server.Close() return } + coreServer = server debug.FreeOSMemory() return nil @@ -61,11 +74,18 @@ func RunXray(configPath string) (err error) { // Run Xray instance with JSON configuration string. // configJSON means the JSON configuration string. func RunXrayFromJSON(configJSON string) (err error) { + coreServerMu.Lock() + defer coreServerMu.Unlock() + if coreServer != nil { + return ErrAlreadyRunning + } + memory.InitForceFree() - coreServer, err = StartXrayFromJSON(configJSON) + server, err := StartXrayFromJSON(configJSON) if err != nil { return } + coreServer = server debug.FreeOSMemory() return nil @@ -73,11 +93,15 @@ func RunXrayFromJSON(configJSON string) (err error) { // Get Xray State func GetXrayState() bool { + coreServerMu.Lock() + defer coreServerMu.Unlock() return coreServer != nil && coreServer.IsRunning() } // Stop Xray instance. func StopXray() error { + coreServerMu.Lock() + defer coreServerMu.Unlock() if coreServer != nil { err := coreServer.Close() coreServer = nil diff --git a/xray/xray_test.go b/xray/xray_test.go new file mode 100644 index 00000000..a0b7f3bc --- /dev/null +++ b/xray/xray_test.go @@ -0,0 +1,118 @@ +package xray + +import ( + "errors" + "sync" + "testing" +) + +const minimalConfig = `{ + "log": {"loglevel": "none"}, + "inbounds": [], + "outbounds": [{"protocol": "freedom", "tag": "direct"}] +}` + +func TestRunXrayFromJSONRejectsDuplicateStart(t *testing.T) { + t.Cleanup(func() { + if err := StopXray(); err != nil { + t.Errorf("stop xray: %v", err) + } + }) + + if err := StopXray(); err != nil { + t.Fatalf("reset xray state: %v", err) + } + if err := RunXrayFromJSON(minimalConfig); err != nil { + t.Fatalf("start xray: %v", err) + } + if !GetXrayState() { + t.Fatal("xray should be running") + } + if err := RunXrayFromJSON(minimalConfig); !errors.Is(err, ErrAlreadyRunning) { + t.Fatalf("duplicate start error = %v, want %v", err, ErrAlreadyRunning) + } +} + +func TestXrayLifecycleConcurrentStateReads(t *testing.T) { + if err := StopXray(); err != nil { + t.Fatalf("reset xray state: %v", err) + } + if err := RunXrayFromJSON(minimalConfig); err != nil { + t.Fatalf("start xray: %v", err) + } + + var readers sync.WaitGroup + for range 32 { + readers.Add(1) + go func() { + defer readers.Done() + _ = GetXrayState() + }() + } + readers.Wait() + + if err := StopXray(); err != nil { + t.Fatalf("stop xray: %v", err) + } + if GetXrayState() { + t.Fatal("xray should be stopped") + } +} + +func TestRunXrayFromJSONFailureDoesNotPublishInstance(t *testing.T) { + if err := StopXray(); err != nil { + t.Fatalf("reset xray state: %v", err) + } + if err := RunXrayFromJSON(`{"outbounds":[`); err == nil { + t.Fatal("invalid config should fail") + } + if GetXrayState() { + t.Fatal("failed start must not publish an instance") + } + if err := RunXrayFromJSON(minimalConfig); err != nil { + t.Fatalf("start after failure: %v", err) + } + if err := StopXray(); err != nil { + t.Fatalf("stop xray: %v", err) + } +} + +func TestRunXrayFromJSONSerializesConcurrentStarts(t *testing.T) { + if err := StopXray(); err != nil { + t.Fatalf("reset xray state: %v", err) + } + t.Cleanup(func() { + if err := StopXray(); err != nil { + t.Errorf("stop xray: %v", err) + } + }) + + const starts = 8 + errorsByStart := make(chan error, starts) + var starters sync.WaitGroup + for range starts { + starters.Add(1) + go func() { + defer starters.Done() + errorsByStart <- RunXrayFromJSON(minimalConfig) + }() + } + starters.Wait() + close(errorsByStart) + + successes := 0 + duplicates := 0 + for err := range errorsByStart { + switch { + case err == nil: + successes++ + case errors.Is(err, ErrAlreadyRunning): + duplicates++ + default: + t.Fatalf("unexpected start error: %v", err) + } + } + if successes != 1 || duplicates != starts-1 { + t.Fatalf("successes=%d duplicates=%d", successes, duplicates) + } +} From 1ddbf44f2d51efc4b097f277c0841ee4fd073dc0 Mon Sep 17 00:00:00 2001 From: yiguo Date: Fri, 10 Jul 2026 23:05:03 +0800 Subject: [PATCH 2/3] Use latest gomobile for builds --- build/app/build.py | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/build/app/build.py b/build/app/build.py index c52f7800..88fb9f24 100644 --- a/build/app/build.py +++ b/build/app/build.py @@ -10,7 +10,6 @@ XRAY_CORE_MOD_NAME = "github.com/xtls/xray-core" # This pseudo-version pins the Xray-core main commit that added root env config. DEFAULT_XRAY_CORE_VERSION = "v1.260327.1-0.20260710025649-d5bc58dc6b76" -GOMOBILE_VERSION = "v0.0.0-20260611195102-4dd8f1dbf5d2" LOCAL_XRAY_CORE_DIR_NAME = "Xray-core" @@ -115,11 +114,38 @@ def download_geo(self): raise Exception("download_geo failed") def prepare_gomobile(self): + result = subprocess.run( + [ + "go", + "list", + "-m", + "-f", + "{{.Version}}", + "golang.org/x/mobile@latest", + ], + capture_output=True, + text=True, + ) + version = result.stdout.strip() + if result.returncode != 0 or not version: + raise Exception("resolve latest gomobile version failed") + + ret = subprocess.run( + [ + "go", + "get", + "-tool", + f"golang.org/x/mobile/cmd/gobind@{version}", + ] + ) + if ret.returncode != 0: + raise Exception("add gobind tool dependency failed") + ret = subprocess.run( [ "go", "install", - f"golang.org/x/mobile/cmd/gomobile@{GOMOBILE_VERSION}", + f"golang.org/x/mobile/cmd/gomobile@{version}", ] ) if ret.returncode != 0: From 976bc4d6eed6b7494c1f1b40df72902ffafd3c2e Mon Sep 17 00:00:00 2001 From: yiguo Date: Sat, 11 Jul 2026 10:15:39 +0800 Subject: [PATCH 3/3] Use stable toolchains in workflows --- .github/workflows/build.yml | 12 +++++------- .github/workflows/libxray-test.yml | 3 ++- .github/workflows/validate.yml | 5 +---- 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d57cbab6..1211799a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -11,9 +11,6 @@ concurrency: cancel-in-progress: false env: - GO_VERSION: "1.26.5" - XCODE_VERSION: "26.6" - PYTHON_VERSION: "3.12" NDK_VERSION: r29 jobs: @@ -62,7 +59,8 @@ jobs: - name: Setup Python uses: actions/setup-python@v6 with: - python-version: ${{ env.PYTHON_VERSION }} + python-version: "3.x" + check-latest: true - name: Install Python deps run: | @@ -74,7 +72,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v6 with: - go-version: ${{ env.GO_VERSION }} + go-version: stable check-latest: true - name: Download Go modules @@ -83,11 +81,11 @@ jobs: # ========================= # Apple toolchain # ========================= - - name: Select latest stable Xcode + - name: Select Xcode if: matrix.target == 'apple' uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: ${{ env.XCODE_VERSION }} + xcode-version: latest-stable # ========================= # Android toolchain diff --git a/.github/workflows/libxray-test.yml b/.github/workflows/libxray-test.yml index 51f32eb2..044b999c 100644 --- a/.github/workflows/libxray-test.yml +++ b/.github/workflows/libxray-test.yml @@ -16,7 +16,8 @@ jobs: - name: Set up Go uses: actions/setup-go@v6 with: - go-version-file: go.mod + go-version: stable + check-latest: true cache-dependency-path: go.sum - name: Go test diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 5cdc3c9c..6fd42472 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -10,9 +10,6 @@ concurrency: group: validate-libxray-${{ github.ref }} cancel-in-progress: true -env: - GO_VERSION: "1.26.5" - jobs: go: runs-on: ubuntu-24.04 @@ -23,7 +20,7 @@ jobs: - uses: actions/setup-go@v6 with: - go-version: ${{ env.GO_VERSION }} + go-version: stable check-latest: true - name: Download modules