From 30f375abf9059dad4971ce726d78c7593ad22df7 Mon Sep 17 00:00:00 2001 From: yiguodev <147401898+yiguodev@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:32:51 +0800 Subject: [PATCH 01/38] Restore invoke env and desktop wrapper (#133) * Document layered invoke env behavior * Remove Invoke env support * Return ping delay sentinel on failure * Restore invoke env and desktop wrapper * Make geo data invoke test self contained * Clarify invoke env documentation * Harden desktop wrapper errors --- .github/workflows/build.yml | 2 + README.md | 41 +++----- build/app/build.py | 23 +++++ build/app/linux.py | 2 + build/app/windows.py | 2 + desktop_bin/main.go | 38 +++++++ desktop_bin/run.go | 65 ++++++++++++ download_geo/main.go | 7 +- invoke.go | 40 +++----- invoke_model.go | 18 +--- invoke_test.go | 194 ++++++++++++++++++++++++------------ readme/README.zh_CN.md | 35 ++----- 12 files changed, 305 insertions(+), 162 deletions(-) create mode 100644 desktop_bin/main.go create mode 100644 desktop_bin/run.go diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 85493c55..d23b54cb 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -147,11 +147,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/README.md b/README.md index 0cfadd07..d8cc0a90 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,9 @@ depends on git and go. By default, the build script does not clone [Xray-core](https://github.com/XTLS/Xray-core). It uses Go modules and pins Xray-core to tag `v26.6.27` (recorded by Go as the matching pseudo-version). Pass the optional `local` argument to use an existing local checkout at `../Xray-core` through a Go module `replace`. -Linux and Windows builds only produce the libXray shared library. Applications that need a standalone Xray executable should use official Xray-core release binaries. +Linux and Windows builds also produce a small desktop wrapper executable: +`bin/xray` or `bin/xray.exe`. The wrapper accepts `-configPath` pointing to a +`LibXrayInvokeRequest` JSON file whose method is `runXray`. ### Usage @@ -131,7 +133,6 @@ The request is a JSON object: "apiVersion": 1, "method": "runXray", "env": { - "xray.location.config": "/path/to/config.json", "xray.location.asset": "/path/to/dat", "xray.location.cert": "/path/to/dat", "xray.tun.fd": "123" @@ -152,33 +153,19 @@ The response is a JSON object: } ``` -`env` is optional and only supports Xray-core environment variables that are -explicitly modeled by libXray: - -| JSON key | Meaning | -| --- | --- | -| `xray.location.config` | Xray config file location | -| `xray.location.confdir` | Xray config directory location | -| `xray.location.asset` | Directory containing `geosite.dat`, `geoip.dat`, and custom GeoData files | -| `xray.location.cert` | Certificate directory used by Xray-core | -| `xray.buf.readv` | Xray-core readv buffer switch | -| `xray.buf.splice` | Xray-core splice buffer switch | -| `xray.vmess.padding` | VMess padding switch | -| `xray.cone.disabled` | Cone behavior switch | -| `xray.json.strict` | Strict JSON parsing switch | -| `xray.ray.buffer.size` | Ray buffer size | -| `xray.browser.dialer` | Browser dialer address | -| `xray.xudp.show` | XUDP log display switch | -| `xray.xudp.basekey` | XUDP base key | -| `xray.tun.fd` | TUN file descriptor for Android, iOS, and macOS packet tunnel integrations | - Design notes: -1. `env` is modeled as fixed fields in Go and Dart. It is not a free-form map. -2. Unknown `env` keys are ignored and are not written to the process environment. -3. `env` only sets modeled, non-empty fields. Missing fields are not unset. -4. libXray does not restore previous environment values after a method returns. Callers must pass the required env fields on every request that depends on them. This avoids concurrent calls restoring stale values over newer values. -5. `SetTunFd` has been removed. Pass `xray.tun.fd` in the `env` object of the `runXray` request. +1. `Invoke.env` supports only fixed Xray-core runtime environment keys: + `xray.location.asset`, `xray.location.cert`, and `xray.tun.fd`. +2. Non-empty `env` fields are set on the process before the method runs. + Missing fields are not unset and old values are not restored. +3. `xray.json.strict`, `xray.location.config`, and `xray.location.confdir` are + load-stage process environment variables and cannot be supplied through + `Invoke.env`. +4. `SetTunFd` has been removed. Set the `"xray.tun.fd"` key in the request + `env` object when the fd is only known at runtime. +5. `countGeoData` is not backed by an Xray config, so its `datDir` is passed in + the method payload. Supported methods: diff --git a/build/app/build.py b/build/app/build.py index e7b3166f..be2c62bb 100644 --- a/build/app/build.py +++ b/build/app/build.py @@ -4,6 +4,7 @@ import subprocess from app.cmd import ( + create_dir_if_not_exists, delete_file_if_exists, delete_dir_if_exists, ) @@ -142,6 +143,28 @@ def before_build(self): def build(self): pass + def build_desktop_bin(self, bin_file: str): + bin_dir = os.path.join(self.lib_dir, "bin") + create_dir_if_not_exists(bin_dir) + output_file = os.path.join(bin_dir, bin_file) + run_env = os.environ.copy() + run_env["CGO_ENABLED"] = "0" + + cmd = [ + "go", + "build", + "-trimpath", + "-ldflags", + "-s -w", + f"-o={output_file}", + "./desktop_bin", + ] + os.chdir(self.lib_dir) + print(cmd) + ret = subprocess.run(cmd, env=run_env) + if ret.returncode != 0: + raise Exception("build_desktop_bin failed") + def after_build(self): pass diff --git a/build/app/linux.py b/build/app/linux.py index d0d69ee5..181c1279 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() @@ -22,6 +23,7 @@ def build(self): self.before_build() self.build_linux() self.after_build() + self.build_desktop_bin(self.bin_file) self.revert_go_env() def build_linux(self): diff --git a/build/app/windows.py b/build/app/windows.py index 7b0353ae..9cb70a45 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() @@ -22,6 +23,7 @@ def build(self): self.before_build() self.build_windows() self.after_build() + self.build_desktop_bin(self.bin_file) self.revert_go_env() def build_windows(self): diff --git a/desktop_bin/main.go b/desktop_bin/main.go new file mode 100644 index 00000000..dafade96 --- /dev/null +++ b/desktop_bin/main.go @@ -0,0 +1,38 @@ +package main + +import ( + "flag" + "fmt" + "os" + "os/signal" + "runtime" + "runtime/debug" + "syscall" +) + +func main() { + configPath := flag.String("configPath", "", "Path of LibXrayInvokeRequest JSON") + flag.Parse() + if *configPath == "" { + flag.Usage() + fmt.Fprintln(os.Stderr, "missing -configPath") + os.Exit(1) + } + + if err := runXray(*configPath); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + defer func() { + if err := stopXray(); err != nil { + fmt.Fprintf(os.Stderr, "stopXray failed: %v\n", err) + } + }() + + runtime.GC() + debug.FreeOSMemory() + + osSignals := make(chan os.Signal, 1) + signal.Notify(osSignals, os.Interrupt, syscall.SIGTERM) + <-osSignals +} diff --git a/desktop_bin/run.go b/desktop_bin/run.go new file mode 100644 index 00000000..2c34b594 --- /dev/null +++ b/desktop_bin/run.go @@ -0,0 +1,65 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "os" + + libXray "github.com/xtls/libxray" +) + +type invokeResponse struct { + Success bool `json:"success"` + Err string `json:"error"` +} + +func runXray(configPath string) error { + requestBytes, err := os.ReadFile(configPath) + if err != nil { + return err + } + + var request libXray.LibXrayInvokeRequest + if err := json.Unmarshal(requestBytes, &request); err != nil { + return err + } + if request.Method != libXray.LibXrayMethodRunXray { + return fmt.Errorf("unsupported method %q: desktop_bin only supports runXray", request.Method) + } + + responseText := libXray.Invoke(string(requestBytes)) + var response invokeResponse + if err := json.Unmarshal([]byte(responseText), &response); err != nil { + return err + } + if !response.Success { + if response.Err == "" { + response.Err = "runXray failed" + } + return errors.New(response.Err) + } + return nil +} + +func stopXray() error { + requestBytes, err := json.Marshal(&libXray.LibXrayInvokeRequest{ + APIVersion: 1, + Method: libXray.LibXrayMethodStopXray, + }) + if err != nil { + return err + } + responseText := libXray.Invoke(string(requestBytes)) + var response invokeResponse + if err := json.Unmarshal([]byte(responseText), &response); err != nil { + return err + } + if !response.Success { + if response.Err == "" { + response.Err = "stopXray failed" + } + return errors.New(response.Err) + } + return nil +} diff --git a/download_geo/main.go b/download_geo/main.go index b202c126..d625ecb3 100644 --- a/download_geo/main.go +++ b/download_geo/main.go @@ -66,6 +66,7 @@ func makeLoadGeoDataRequest(datDir string, name string, geoType string) (string, payload, err := json.Marshal(&libXray.CountGeoDataRequest{ Name: name, GeoType: geoType, + DatDir: datDir, }) if err != nil { return "", err @@ -73,11 +74,7 @@ func makeLoadGeoDataRequest(datDir string, name string, geoType string) (string, request := libXray.LibXrayInvokeRequest{ APIVersion: 1, Method: libXray.LibXrayMethodCountGeoData, - Env: &libXray.LibXrayEnvJson{ - AssetLocation: datDir, - CertLocation: datDir, - }, - Payload: payload, + Payload: payload, } data, err := json.Marshal(&request) if err != nil { diff --git a/invoke.go b/invoke.go index 6f04737a..50bd9317 100644 --- a/invoke.go +++ b/invoke.go @@ -56,37 +56,27 @@ func Invoke(requestJSON string) string { } } -func validateAPIVersion(version int) error { - if version == 0 || version == 1 { - return nil - } - return errors.New("unsupported apiVersion") -} - func applyEnv(env *LibXrayEnvJson) { if env == nil { return } - setEnvIfNotEmpty(platform.ConfigLocation, env.ConfigLocation) - setEnvIfNotEmpty(platform.ConfdirLocation, env.ConfdirLocation) setEnvIfNotEmpty(platform.AssetLocation, env.AssetLocation) setEnvIfNotEmpty(platform.CertLocation, env.CertLocation) - setEnvIfNotEmpty(platform.UseReadV, env.UseReadV) - setEnvIfNotEmpty(platform.UseFreedomSplice, env.UseFreedomSplice) - setEnvIfNotEmpty(platform.UseVmessPadding, env.UseVmessPadding) - setEnvIfNotEmpty(platform.UseCone, env.UseCone) - setEnvIfNotEmpty(platform.UseStrictJSON, env.UseStrictJSON) - setEnvIfNotEmpty(platform.BufferSize, env.BufferSize) - setEnvIfNotEmpty(platform.BrowserDialerAddress, env.BrowserDialerAddress) - setEnvIfNotEmpty(platform.XUDPLog, env.XUDPLog) - setEnvIfNotEmpty(platform.XUDPBaseKey, env.XUDPBaseKey) setEnvIfNotEmpty(platform.TunFdKey, env.TunFd) } func setEnvIfNotEmpty(key string, value string) { - if value != "" { - _ = os.Setenv(key, value) + if value == "" { + return } + _ = os.Setenv(key, value) +} + +func validateAPIVersion(version int) error { + if version == 0 || version == 1 { + return nil + } + return errors.New("unsupported apiVersion") } func decodePayload[T any](payload json.RawMessage) (T, error) { @@ -158,11 +148,10 @@ func invokeCountGeoData(payload json.RawMessage) string { if err != nil { return encodeInvokeNoDataResponse(err) } - datDir := platform.NewEnvFlag(platform.AssetLocation).GetValue(func() string { return "" }) - if datDir == "" { - return encodeInvokeNoDataResponse(errors.New("missing xray.location.asset")) + if request.DatDir == "" { + return encodeInvokeNoDataResponse(errors.New("missing datDir")) } - err = geo.CountGeoData(datDir, request.Name, request.GeoType) + err = geo.CountGeoData(request.DatDir, request.Name, request.GeoType) return encodeInvokeNoDataResponse(err) } @@ -173,6 +162,9 @@ func invokePing(payload json.RawMessage) string { } delay, err := xray.Ping(request.ConfigPath, request.Timeout, request.URL, request.Proxy) if err != nil { + if delay == nodep.PingDelayError || delay == nodep.PingDelayTimeout { + return encodeInvokeResponse(&PingResponse{Delay: delay}, err) + } return encodeInvokeResponse(nil, err) } return encodeInvokeResponse(&PingResponse{Delay: delay}, nil) diff --git a/invoke_model.go b/invoke_model.go index 5f369a94..68c9efff 100644 --- a/invoke_model.go +++ b/invoke_model.go @@ -27,20 +27,9 @@ type LibXrayInvokeRequest struct { } type LibXrayEnvJson struct { - ConfigLocation string `json:"xray.location.config,omitempty"` - ConfdirLocation string `json:"xray.location.confdir,omitempty"` - AssetLocation string `json:"xray.location.asset,omitempty"` - CertLocation string `json:"xray.location.cert,omitempty"` - UseReadV string `json:"xray.buf.readv,omitempty"` - UseFreedomSplice string `json:"xray.buf.splice,omitempty"` - UseVmessPadding string `json:"xray.vmess.padding,omitempty"` - UseCone string `json:"xray.cone.disabled,omitempty"` - UseStrictJSON string `json:"xray.json.strict,omitempty"` - BufferSize string `json:"xray.ray.buffer.size,omitempty"` - BrowserDialerAddress string `json:"xray.browser.dialer,omitempty"` - XUDPLog string `json:"xray.xudp.show,omitempty"` - XUDPBaseKey string `json:"xray.xudp.basekey,omitempty"` - TunFd string `json:"xray.tun.fd,omitempty"` + AssetLocation string `json:"xray.location.asset,omitempty"` + CertLocation string `json:"xray.location.cert,omitempty"` + TunFd string `json:"xray.tun.fd,omitempty"` } type GetFreePortsRequest struct { @@ -66,6 +55,7 @@ type ConvertXrayJsonToShareLinksResponse struct { type CountGeoDataRequest struct { Name string `json:"name,omitempty"` GeoType string `json:"geoType,omitempty"` + DatDir string `json:"datDir,omitempty"` } type PingRequest struct { diff --git a/invoke_test.go b/invoke_test.go index b810f22d..90d6b3a2 100644 --- a/invoke_test.go +++ b/invoke_test.go @@ -7,7 +7,10 @@ import ( "path/filepath" "testing" + "github.com/xtls/libxray/nodep" + "github.com/xtls/xray-core/common/geodata" "github.com/xtls/xray-core/common/platform" + "google.golang.org/protobuf/proto" ) type testResponse struct { @@ -16,7 +19,7 @@ type testResponse struct { Err string `json:"error,omitempty"` } -func invokeForTest(t *testing.T, method LibXrayMethod, env *LibXrayEnvJson, payload any) testResponse { +func invokeForTest(t *testing.T, method LibXrayMethod, payload any) testResponse { t.Helper() rawPayload, err := json.Marshal(payload) if err != nil { @@ -25,7 +28,6 @@ func invokeForTest(t *testing.T, method LibXrayMethod, env *LibXrayEnvJson, payl rawRequest, err := json.Marshal(&LibXrayInvokeRequest{ APIVersion: 1, Method: method, - Env: env, Payload: rawPayload, }) if err != nil { @@ -85,6 +87,40 @@ func writeConfigToFile(t *testing.T, config any, path string) { } } +func writeGeoSiteDatForTest(t *testing.T, path string) { + t.Helper() + data, err := proto.Marshal(&geodata.GeoSiteList{ + Entry: []*geodata.GeoSite{ + { + Code: "TEST", + Domain: []*geodata.Domain{ + { + Type: geodata.Domain_Domain, + Value: "example.com", + Attribute: []*geodata.Domain_Attribute{ + { + Key: "ads", + TypedValue: &geodata.Domain_Attribute_BoolValue{ + BoolValue: true, + }, + }, + }, + }, + }, + }, + }, + }) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0o644); err != nil { + t.Fatal(err) + } +} + func testVmessPayloadBase64() string { const vmessJSON = `{"add":"127.0.0.1","port":1080,"id":"00000000-0000-0000-0000-000000000000","aid":0,"scy":"auto"}` return base64.StdEncoding.EncodeToString([]byte(vmessJSON)) @@ -171,7 +207,6 @@ func TestInvokeTestXray(t *testing.T) { response := invokeForTest( t, LibXrayMethodTestXray, - &LibXrayEnvJson{AssetLocation: filepath.Join(projectRoot, "dat"), CertLocation: filepath.Join(projectRoot, "dat")}, RunXrayRequest{ConfigPath: configPath}, ) if !response.Success { @@ -188,7 +223,6 @@ func TestInvokeRunXray(t *testing.T) { response := invokeForTest( t, LibXrayMethodRunXray, - &LibXrayEnvJson{AssetLocation: filepath.Join(projectRoot, "dat"), CertLocation: filepath.Join(projectRoot, "dat")}, RunXrayRequest{ConfigPath: configPath}, ) defer xrayStopForTest(t) @@ -199,7 +233,7 @@ func TestInvokeRunXray(t *testing.T) { } func TestInvokeXrayVersion(t *testing.T) { - response := invokeForTest(t, LibXrayMethodXrayVersion, nil, nil) + response := invokeForTest(t, LibXrayMethodXrayVersion, nil) if !response.Success { t.Fatalf("XrayVersion failed: %s", response.Err) } @@ -210,7 +244,7 @@ func TestInvokeXrayVersion(t *testing.T) { } func TestInvokeMapResponseShape(t *testing.T) { - response := invokeForTest(t, LibXrayMethodGetFreePorts, nil, GetFreePortsRequest{Count: 1}) + response := invokeForTest(t, LibXrayMethodGetFreePorts, GetFreePortsRequest{Count: 1}) if !response.Success { t.Fatalf("GetFreePorts failed: %s", response.Err) } @@ -219,7 +253,7 @@ func TestInvokeMapResponseShape(t *testing.T) { t.Fatalf("ports = %v", ports.Ports) } - response = invokeForTest(t, LibXrayMethodGetXrayState, nil, nil) + response = invokeForTest(t, LibXrayMethodGetXrayState, nil) if !response.Success { t.Fatalf("GetXrayState failed: %s", response.Err) } @@ -232,7 +266,6 @@ func TestInvokeMapResponseShape(t *testing.T) { response = invokeForTest( t, LibXrayMethodConvertXrayJsonToShareLinks, - nil, ConvertXrayJsonToShareLinksRequest{XrayJson: string(rawConfig)}, ) if !response.Success { @@ -244,67 +277,66 @@ func TestInvokeMapResponseShape(t *testing.T) { } } -func TestInvokeEnvOnlySetsProvidedFields(t *testing.T) { - t.Setenv(platform.AssetLocation, "initial-asset") - t.Setenv(platform.CertLocation, "initial-cert") +func TestInvokePingReturnsDelaySentinelOnXrayError(t *testing.T) { response := invokeForTest( t, - LibXrayMethodXrayVersion, - &LibXrayEnvJson{AssetLocation: "updated-asset"}, - nil, + LibXrayMethodPing, + PingRequest{ + ConfigPath: filepath.Join(t.TempDir(), "missing.json"), + Timeout: 1, + URL: "https://example.com", + }, ) - if !response.Success { - t.Fatalf("XrayVersion failed: %s", response.Err) + if response.Success { + t.Fatal("Ping should keep failure success state on Xray error") } - if got := os.Getenv(platform.AssetLocation); got != "updated-asset" { - t.Fatalf("asset env = %q", got) + if response.Err == "" { + t.Fatal("Ping failure should keep error text") } - if got := os.Getenv(platform.CertLocation); got != "initial-cert" { - t.Fatalf("cert env = %q", got) + ping := decodeDataObject[PingResponse](t, response) + if ping.Delay != nodep.PingDelayError { + t.Fatalf("delay = %d, want %d", ping.Delay, nodep.PingDelayError) } } -func TestInvokeSetsAllSupportedEnvFields(t *testing.T) { - tests := []struct { - name string - key string - value string - }{ - {"config", platform.ConfigLocation, "config-value"}, - {"confdir", platform.ConfdirLocation, "confdir-value"}, - {"asset", platform.AssetLocation, "asset-value"}, - {"cert", platform.CertLocation, "cert-value"}, - {"readv", platform.UseReadV, "readv-value"}, - {"splice", platform.UseFreedomSplice, "splice-value"}, - {"vmess padding", platform.UseVmessPadding, "vmess-padding-value"}, - {"cone", platform.UseCone, "cone-value"}, - {"strict json", platform.UseStrictJSON, "strict-json-value"}, - {"buffer size", platform.BufferSize, "buffer-size-value"}, - {"browser dialer", platform.BrowserDialerAddress, "browser-dialer-value"}, - {"xudp log", platform.XUDPLog, "xudp-log-value"}, - {"xudp base key", platform.XUDPBaseKey, "xudp-base-key-value"}, - {"tun fd", platform.TunFdKey, "123"}, - } - for _, tt := range tests { - t.Setenv(tt.key, "") - t.Run(tt.name, func(t *testing.T) { - requestJSON := `{"apiVersion":1,"method":"xrayVersion","env":{"` + tt.key + `":"` + tt.value + `"}}` - var response testResponse - if err := json.Unmarshal([]byte(Invoke(requestJSON)), &response); err != nil { - t.Fatal(err) - } - if !response.Success { - t.Fatalf("XrayVersion failed: %s", response.Err) - } - if got := os.Getenv(tt.key); got != tt.value { - t.Fatalf("%s = %q", tt.key, got) - } - }) +func TestInvokeCountGeoDataUsesPayloadDatDir(t *testing.T) { + datDir := t.TempDir() + writeGeoSiteDatForTest(t, filepath.Join(datDir, "geosite.dat")) + + response := invokeForTest( + t, + LibXrayMethodCountGeoData, + CountGeoDataRequest{ + Name: "geosite", + GeoType: "domain", + DatDir: datDir, + }, + ) + if !response.Success { + t.Fatalf("CountGeoData failed: %s", response.Err) + } + requireNoDataObject(t, response) + output, err := os.ReadFile(filepath.Join(datDir, "geosite.json")) + if err != nil { + t.Fatal(err) + } + if !json.Valid(output) { + t.Fatalf("geosite.json is not valid JSON: %s", output) + } + var list struct { + CategoryCount int `json:"categoryCount,omitempty"` + RuleCount int `json:"ruleCount,omitempty"` + } + if err := json.Unmarshal(output, &list); err != nil { + t.Fatal(err) + } + if list.CategoryCount != 1 || list.RuleCount != 1 { + t.Fatalf("unexpected geosite counts: %+v", list) } } func TestInvokeUnknownMethod(t *testing.T) { - response := invokeForTest(t, LibXrayMethod("unknown"), nil, nil) + response := invokeForTest(t, LibXrayMethod("unknown"), nil) if response.Success { t.Fatal("unknown method should fail") } @@ -316,7 +348,6 @@ func TestInvokeAPIVersion(t *testing.T) { t.Fatalf("omitted apiVersion should default to v1: %s", response.Err) } - t.Setenv(platform.AssetLocation, "initial-asset") response = invokeRawForTest(t, `{"apiVersion":2,"method":"xrayVersion","env":{"xray.location.asset":"updated-asset"}}`) if response.Success { t.Fatal("unsupported apiVersion should fail") @@ -324,13 +355,10 @@ func TestInvokeAPIVersion(t *testing.T) { if got := string(response.Data); got != "null" { t.Fatalf("data = %s, want null", got) } - if got := os.Getenv(platform.AssetLocation); got != "initial-asset" { - t.Fatalf("asset env = %q", got) - } } func TestInvokeNoDataResponseShape(t *testing.T) { - response := invokeForTest(t, LibXrayMethodStopXray, nil, nil) + response := invokeForTest(t, LibXrayMethodStopXray, nil) if !response.Success { t.Fatalf("StopXray failed: %s", response.Err) } @@ -345,7 +373,28 @@ func TestInvokeNoDataResponseShape(t *testing.T) { } } -func TestInvokeIgnoresUnknownEnvField(t *testing.T) { +func TestInvokeAppliesSupportedEnv(t *testing.T) { + restoreEnv(t, platform.AssetLocation) + restoreEnv(t, platform.CertLocation) + restoreEnv(t, platform.TunFdKey) + + requestJSON := `{"apiVersion":1,"method":"xrayVersion","env":{"xray.location.asset":"/tmp/assets","xray.location.cert":"/tmp/certs","xray.tun.fd":"123"}}` + response := invokeRawForTest(t, requestJSON) + if !response.Success { + t.Fatalf("xrayVersion failed: %s", response.Err) + } + if got := os.Getenv(platform.AssetLocation); got != "/tmp/assets" { + t.Fatalf("%s = %q", platform.AssetLocation, got) + } + if got := os.Getenv(platform.CertLocation); got != "/tmp/certs" { + t.Fatalf("%s = %q", platform.CertLocation, got) + } + if got := os.Getenv(platform.TunFdKey); got != "123" { + t.Fatalf("%s = %q", platform.TunFdKey, got) + } +} + +func TestInvokeIgnoresUnknownEnvKeys(t *testing.T) { const key = "XRAY_LIBXRAY_UNKNOWN_ENV_TEST" _ = os.Unsetenv(key) t.Cleanup(func() { _ = os.Unsetenv(key) }) @@ -355,16 +404,29 @@ func TestInvokeIgnoresUnknownEnvField(t *testing.T) { t.Fatal(err) } if !response.Success { - t.Fatalf("unknown env field should be ignored: %s", response.Err) + t.Fatalf("unknown env should be ignored: %s", response.Err) } if _, found := os.LookupEnv(key); found { - t.Fatal("unknown env field should not be set") + t.Fatal("unknown env should not be set") } } +func restoreEnv(t *testing.T, key string) { + t.Helper() + oldValue, found := os.LookupEnv(key) + _ = os.Unsetenv(key) + t.Cleanup(func() { + if found { + _ = os.Setenv(key, oldValue) + } else { + _ = os.Unsetenv(key) + } + }) +} + func xrayStopForTest(t *testing.T) { t.Helper() - response := invokeForTest(t, LibXrayMethodStopXray, nil, nil) + response := invokeForTest(t, LibXrayMethodStopXray, nil) if !response.Success { t.Fatalf("StopXray failed: %s", response.Err) } diff --git a/readme/README.zh_CN.md b/readme/README.zh_CN.md index 5adda226..71ac6d76 100644 --- a/readme/README.zh_CN.md +++ b/readme/README.zh_CN.md @@ -18,7 +18,9 @@ 默认情况下,编译脚本不会 clone [Xray-core](https://github.com/XTLS/Xray-core),而是通过 Go modules 将 Xray-core 固定到 tag `v26.6.27`(Go 会记录为对应的 pseudo-version)。 传入可选参数 `local` 时,会通过 Go module `replace` 改用已有的本地仓库 `../Xray-core`。 -Linux 和 Windows 构建只输出 libXray 动态库。需要独立 Xray 可执行文件的应用应使用 Xray-core 官方发布的二进制。 +Linux 和 Windows 构建还会输出一个 desktop wrapper 可执行文件: +`bin/xray` 或 `bin/xray.exe`。wrapper 的 `-configPath` 参数指向一个 +`method` 为 `runXray` 的 `LibXrayInvokeRequest` JSON 文件。 ### 使用方式 @@ -99,7 +101,6 @@ char* CGoInvoke(char* requestJSON); "apiVersion": 1, "method": "runXray", "env": { - "xray.location.config": "/path/to/config.json", "xray.location.asset": "/path/to/dat", "xray.location.cert": "/path/to/dat", "xray.tun.fd": "123" @@ -120,32 +121,14 @@ char* CGoInvoke(char* requestJSON); } ``` -`env` 是可选字段,只支持 libXray 显式建模的 Xray-core 环境变量: - -| JSON key | 含义 | -| --- | --- | -| `xray.location.config` | Xray 配置文件路径 | -| `xray.location.confdir` | Xray 配置目录路径 | -| `xray.location.asset` | 保存 `geosite.dat`、`geoip.dat` 和自定义 GeoData 的目录 | -| `xray.location.cert` | Xray-core 使用的证书目录 | -| `xray.buf.readv` | Xray-core readv buffer 开关 | -| `xray.buf.splice` | Xray-core splice buffer 开关 | -| `xray.vmess.padding` | VMess padding 开关 | -| `xray.cone.disabled` | Cone 行为开关 | -| `xray.json.strict` | 严格 JSON 解析开关 | -| `xray.ray.buffer.size` | Ray buffer size | -| `xray.browser.dialer` | Browser dialer 地址 | -| `xray.xudp.show` | XUDP 日志显示开关 | -| `xray.xudp.basekey` | XUDP base key | -| `xray.tun.fd` | Android、iOS、macOS Packet Tunnel 使用的 TUN 文件描述符 | - 设计决定: -1. `env` 在 Go 和 Dart 侧都是固定字段 model,不是自由 map。 -2. 未知 `env` key 会被忽略,不会写入进程环境变量。 -3. `env` 只设置已建模的非空字段,缺失字段不会 unset。 -4. libXray 不会在 method 结束后 restore 旧环境变量。调用方必须在每次依赖环境变量的请求中显式传入对应字段。这样可以避免并发调用时,一个请求恢复旧值覆盖另一个请求的新值。 -5. `SetTunFd` 已删除。请在 `runXray` 请求的 `env` 对象中传入 `xray.tun.fd`。 +1. `Invoke.env` 只支持固定的 Xray-core runtime 环境项: + `xray.location.asset`、`xray.location.cert`、`xray.tun.fd`。 +2. 非空 `env` 字段会在 method 执行前写入进程环境变量。缺失字段不会 unset,也不会 restore 旧值。 +3. `xray.json.strict`、`xray.location.config`、`xray.location.confdir` 属于加载前进程环境变量,不能通过 `Invoke.env` 传入。 +4. `SetTunFd` 已删除。如果 fd 只能在运行时获得,请在请求的 `env` 对象中设置 `"xray.tun.fd"`。 +5. `countGeoData` 不依赖 Xray 配置,因此通过 method payload 的 `datDir` 传入数据目录。 支持的 method: From 3519bfdeb4474b2488ae6f0c4a80a1c7a8bfd84d Mon Sep 17 00:00:00 2001 From: yiguodev <147401898+yiguodev@users.noreply.github.com> Date: Fri, 10 Jul 2026 18:11:03 +0800 Subject: [PATCH 02/38] Remove Invoke env and desktop wrapper (#134) * Remove invoke env and desktop wrapper * Limit Invoke JSON size * Document Invoke size limit * Clarify Invoke size and env behavior --- .github/workflows/build.yml | 2 - README.md | 29 ++++------ build/app/build.py | 29 +--------- build/app/linux.py | 2 - build/app/windows.py | 2 - desktop_bin/main.go | 38 ------------- desktop_bin/run.go | 65 ---------------------- go.mod | 25 +++++---- go.sum | 48 ++++++++-------- invoke.go | 50 ++++++++++------- invoke_model.go | 7 --- invoke_test.go | 107 +++++++++++++++++++++++------------- readme/README.zh_CN.md | 20 ++----- 13 files changed, 152 insertions(+), 272 deletions(-) delete mode 100644 desktop_bin/main.go delete mode 100644 desktop_bin/run.go diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d23b54cb..85493c55 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -147,13 +147,11 @@ 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/README.md b/README.md index d8cc0a90..7b14c801 100644 --- a/README.md +++ b/README.md @@ -40,11 +40,8 @@ Compile script. It is recommended to always use this script to compile libXray. depends on git and go. -By default, the build script does not clone [Xray-core](https://github.com/XTLS/Xray-core). It uses Go modules and pins Xray-core to tag `v26.6.27` (recorded by Go as the matching pseudo-version). +By default, the build script does not clone [Xray-core](https://github.com/XTLS/Xray-core). It uses Go modules and pins Xray-core to main commit `d5bc58dc` through its pseudo-version. Pass the optional `local` argument to use an existing local checkout at `../Xray-core` through a Go module `replace`. -Linux and Windows builds also produce a small desktop wrapper executable: -`bin/xray` or `bin/xray.exe`. The wrapper accepts `-configPath` pointing to a -`LibXrayInvokeRequest` JSON file whose method is `runXray`. ### Usage @@ -132,11 +129,6 @@ The request is a JSON object: { "apiVersion": 1, "method": "runXray", - "env": { - "xray.location.asset": "/path/to/dat", - "xray.location.cert": "/path/to/dat", - "xray.tun.fd": "123" - }, "payload": { "configPath": "/path/to/config.json" } @@ -155,17 +147,16 @@ The response is a JSON object: Design notes: -1. `Invoke.env` supports only fixed Xray-core runtime environment keys: - `xray.location.asset`, `xray.location.cert`, and `xray.tun.fd`. -2. Non-empty `env` fields are set on the process before the method runs. - Missing fields are not unset and old values are not restored. -3. `xray.json.strict`, `xray.location.config`, and `xray.location.confdir` are - load-stage process environment variables and cannot be supplied through - `Invoke.env`. -4. `SetTunFd` has been removed. Set the `"xray.tun.fd"` key in the request - `env` object when the fd is only known at runtime. -5. `countGeoData` is not backed by an Xray config, so its `datDir` is passed in +1. A top-level `env` field is ignored and has no effect. Xray-core runtime + environment options belong in the root `env` object of the Xray config. +2. `SetTunFd` has been removed. When the fd is only known at runtime, write + `xray.tun.fd` into the Xray config root `env` object before calling + `runXray`. +3. `countGeoData` is not backed by an Xray config, so its `datDir` is passed in the method payload. +4. The complete UTF-8 encoded Invoke request and response JSON envelopes are + limited to 16 MiB. If either limit is exceeded, Invoke returns a failure + response with `success: false`, `data: null`, and a size-limit error. Supported methods: diff --git a/build/app/build.py b/build/app/build.py index be2c62bb..cc2ea739 100644 --- a/build/app/build.py +++ b/build/app/build.py @@ -4,17 +4,14 @@ import subprocess from app.cmd import ( - create_dir_if_not_exists, delete_file_if_exists, delete_dir_if_exists, ) LIBXRAY_MOD_NAME = "github.com/xtls/libxray" XRAY_CORE_MOD_NAME = "github.com/xtls/xray-core" -DEFAULT_XRAY_CORE_TAG = "v26.6.27" -# Xray-core CalVer tags cannot be used directly as Go module versions because -# its module path does not include /v26. This pseudo-version points to the tag above. -DEFAULT_XRAY_CORE_VERSION = "v1.260327.1-0.20260627131803-45cf2898ab12" +# This pseudo-version pins the Xray-core main commit that added root env config. +DEFAULT_XRAY_CORE_VERSION = "v1.260327.1-0.20260710025649-d5bc58dc6b76" LOCAL_XRAY_CORE_DIR_NAME = "Xray-core" @@ -143,28 +140,6 @@ def before_build(self): def build(self): pass - def build_desktop_bin(self, bin_file: str): - bin_dir = os.path.join(self.lib_dir, "bin") - create_dir_if_not_exists(bin_dir) - output_file = os.path.join(bin_dir, bin_file) - run_env = os.environ.copy() - run_env["CGO_ENABLED"] = "0" - - cmd = [ - "go", - "build", - "-trimpath", - "-ldflags", - "-s -w", - f"-o={output_file}", - "./desktop_bin", - ] - os.chdir(self.lib_dir) - print(cmd) - ret = subprocess.run(cmd, env=run_env) - if ret.returncode != 0: - raise Exception("build_desktop_bin failed") - def after_build(self): pass diff --git a/build/app/linux.py b/build/app/linux.py index 181c1279..d0d69ee5 100644 --- a/build/app/linux.py +++ b/build/app/linux.py @@ -13,7 +13,6 @@ 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,7 +22,6 @@ def build(self): self.before_build() self.build_linux() self.after_build() - self.build_desktop_bin(self.bin_file) self.revert_go_env() def build_linux(self): diff --git a/build/app/windows.py b/build/app/windows.py index 9cb70a45..7b0353ae 100644 --- a/build/app/windows.py +++ b/build/app/windows.py @@ -13,7 +13,6 @@ 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,7 +22,6 @@ def build(self): self.before_build() self.build_windows() self.after_build() - self.build_desktop_bin(self.bin_file) self.revert_go_env() def build_windows(self): diff --git a/desktop_bin/main.go b/desktop_bin/main.go deleted file mode 100644 index dafade96..00000000 --- a/desktop_bin/main.go +++ /dev/null @@ -1,38 +0,0 @@ -package main - -import ( - "flag" - "fmt" - "os" - "os/signal" - "runtime" - "runtime/debug" - "syscall" -) - -func main() { - configPath := flag.String("configPath", "", "Path of LibXrayInvokeRequest JSON") - flag.Parse() - if *configPath == "" { - flag.Usage() - fmt.Fprintln(os.Stderr, "missing -configPath") - os.Exit(1) - } - - if err := runXray(*configPath); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - defer func() { - if err := stopXray(); err != nil { - fmt.Fprintf(os.Stderr, "stopXray failed: %v\n", err) - } - }() - - runtime.GC() - debug.FreeOSMemory() - - osSignals := make(chan os.Signal, 1) - signal.Notify(osSignals, os.Interrupt, syscall.SIGTERM) - <-osSignals -} diff --git a/desktop_bin/run.go b/desktop_bin/run.go deleted file mode 100644 index 2c34b594..00000000 --- a/desktop_bin/run.go +++ /dev/null @@ -1,65 +0,0 @@ -package main - -import ( - "encoding/json" - "errors" - "fmt" - "os" - - libXray "github.com/xtls/libxray" -) - -type invokeResponse struct { - Success bool `json:"success"` - Err string `json:"error"` -} - -func runXray(configPath string) error { - requestBytes, err := os.ReadFile(configPath) - if err != nil { - return err - } - - var request libXray.LibXrayInvokeRequest - if err := json.Unmarshal(requestBytes, &request); err != nil { - return err - } - if request.Method != libXray.LibXrayMethodRunXray { - return fmt.Errorf("unsupported method %q: desktop_bin only supports runXray", request.Method) - } - - responseText := libXray.Invoke(string(requestBytes)) - var response invokeResponse - if err := json.Unmarshal([]byte(responseText), &response); err != nil { - return err - } - if !response.Success { - if response.Err == "" { - response.Err = "runXray failed" - } - return errors.New(response.Err) - } - return nil -} - -func stopXray() error { - requestBytes, err := json.Marshal(&libXray.LibXrayInvokeRequest{ - APIVersion: 1, - Method: libXray.LibXrayMethodStopXray, - }) - if err != nil { - return err - } - responseText := libXray.Invoke(string(requestBytes)) - var response invokeResponse - if err := json.Unmarshal([]byte(responseText), &response); err != nil { - return err - } - if !response.Success { - if response.Err == "" { - response.Err = "stopXray failed" - } - return errors.New(response.Err) - } - return nil -} diff --git a/go.mod b/go.mod index df915815..f6e1e9f3 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.26.3 require ( github.com/stretchr/testify v1.11.1 - github.com/xtls/xray-core v1.260327.1-0.20260627131803-45cf2898ab12 + github.com/xtls/xray-core v1.260327.1-0.20260710025649-d5bc58dc6b76 google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v3 v3.0.1 ) @@ -16,17 +16,18 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344 // indirect github.com/google/btree v1.1.2 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/juju/ratelimit v1.0.2 // indirect github.com/klauspost/compress v1.17.4 // indirect - github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/klauspost/cpuid/v2 v2.4.0 // indirect github.com/miekg/dns v1.1.72 // indirect github.com/pelletier/go-toml v1.9.5 // indirect github.com/pion/dtls/v3 v3.1.4 // indirect github.com/pion/logging v0.2.4 // indirect github.com/pion/stun/v3 v3.1.6 // indirect github.com/pion/transport/v4 v4.0.2 // indirect - github.com/pires/go-proxyproto v0.12.0 // indirect + github.com/pires/go-proxyproto v0.15.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af // indirect @@ -38,20 +39,20 @@ require ( github.com/wlynxg/anet v0.0.5 // indirect github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f // indirect go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect - golang.org/x/crypto v0.53.0 // indirect + golang.org/x/crypto v0.54.0 // indirect golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect - golang.org/x/mod v0.36.0 // indirect - golang.org/x/net v0.56.0 // indirect - golang.org/x/sync v0.21.0 // indirect - golang.org/x/sys v0.46.0 // indirect - golang.org/x/text v0.38.0 // indirect + golang.org/x/mod v0.37.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.45.0 // indirect + golang.org/x/tools v0.47.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect golang.zx2c4.com/wireguard/windows v1.0.1 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect - google.golang.org/grpc v1.81.1 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect + google.golang.org/grpc v1.82.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect lukechampine.com/blake3 v1.4.1 // indirect diff --git a/go.sum b/go.sum index c52062fb..726000d8 100644 --- a/go.sum +++ b/go.sum @@ -30,8 +30,8 @@ github.com/juju/ratelimit v1.0.2 h1:sRxmtRiajbvrcLQT7S+JbqU0ntsb9W2yhSdNN8tWfaI= github.com/juju/ratelimit v1.0.2/go.mod h1:qapgC/Gy+xNh9UxzV13HGGl/6UXNN+ct+vwSgWNm/qk= github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4= github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= -github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= -github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw= +github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -48,8 +48,8 @@ github.com/pion/stun/v3 v3.1.6 h1:WnhsD0eHCiwCfKNkVx0VJJwr2Y3eV4Ueih3KJ+dfZy8= github.com/pion/stun/v3 v3.1.6/go.mod h1:zRUghXSQU32Lx5orJsz3uYMkIihweXb3mu5gIns02fs= github.com/pion/transport/v4 v4.0.2 h1:ifYlPqNwsy6aKQ9y8yzxXlHae5431ZrH2avkD/Rn6Tk= github.com/pion/transport/v4 v4.0.2/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM= -github.com/pires/go-proxyproto v0.12.0 h1:TTCxD66dU898tahivkqc3hoceZp7P44FnorWyo9d5vM= -github.com/pires/go-proxyproto v0.12.0/go.mod h1:qUvfqUMEoX7T8g0q7TQLDnhMjdTrxnG0hvpMn+7ePNI= +github.com/pires/go-proxyproto v0.15.0 h1:dTshmNbFm/D+0+sbrxUuddPOZ5Y0B7c5NhtsBkm6LqI= +github.com/pires/go-proxyproto v0.15.0/go.mod h1:OXsCrKwrK2tXS9YrI5tkHx5xaQlO8FH3lFW76orFh24= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= @@ -74,8 +74,8 @@ github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f h1:iy2JRioxmUpoJ3SzbFPyTxHZMbR/rSHP7dOOgYaq1O8= github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f/go.mod h1:DsJblcWDGt76+FVqBVwbwRhxyyNJsGV48gJLch0OOWI= -github.com/xtls/xray-core v1.260327.1-0.20260627131803-45cf2898ab12 h1:0RZBYuLmkxxlMrSsCNRrdNWHhW5zHTfkC2vAknzZ5P4= -github.com/xtls/xray-core v1.260327.1-0.20260627131803-45cf2898ab12/go.mod h1:5J11wfaLGhI6HNOi90zVI6E87c1ykA8grPfUeHZYbhA= +github.com/xtls/xray-core v1.260327.1-0.20260710025649-d5bc58dc6b76 h1:uRUAvUX3lemVMm0vts6WwdQvtYjvwrTs4uzI1zDZVEo= +github.com/xtls/xray-core v1.260327.1-0.20260710025649-d5bc58dc6b76/go.mod h1:Jts8yHqPCpvsdL5CW5xMd8H9d2fkg1cILeBNqEwRXNw= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= @@ -92,26 +92,26 @@ go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M= go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= -golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= -golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM= golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= -golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= -golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= -golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= -golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= -golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= -golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= -golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= -golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= -golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= -golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A= @@ -120,10 +120,10 @@ golang.zx2c4.com/wireguard/windows v1.0.1 h1:eOxiDVbywPC+ZQqvdCK7x+ZwWXKbYv50TtH golang.zx2c4.com/wireguard/windows v1.0.1/go.mod h1:+fbT3FFdX4zzYDLwJh5+HPEcNN/3HyNdzhNSVsQM+zs= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= -google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU= +google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/invoke.go b/invoke.go index 50bd9317..a785a1da 100644 --- a/invoke.go +++ b/invoke.go @@ -3,13 +3,12 @@ package libXray import ( "encoding/json" "errors" - "os" + "fmt" "github.com/xtls/libxray/geo" "github.com/xtls/libxray/nodep" "github.com/xtls/libxray/share" "github.com/xtls/libxray/xray" - "github.com/xtls/xray-core/common/platform" ) type invokeResponse struct { @@ -18,7 +17,15 @@ type invokeResponse struct { Err string `json:"error"` } +const ( + maxInvokeJSONSizeMiB = 16 + maxInvokeJSONBytes = maxInvokeJSONSizeMiB * 1024 * 1024 +) + func Invoke(requestJSON string) string { + if len(requestJSON) > maxInvokeJSONBytes { + return encodeInvokeResponse(nil, errors.New(invokeJSONSizeLimitMessage("request"))) + } var request LibXrayInvokeRequest if err := json.Unmarshal([]byte(requestJSON), &request); err != nil { return encodeInvokeResponse(nil, err) @@ -26,7 +33,6 @@ func Invoke(requestJSON string) string { if err := validateAPIVersion(request.APIVersion); err != nil { return encodeInvokeResponse(nil, err) } - applyEnv(request.Env) switch request.Method { case LibXrayMethodGetFreePorts: @@ -55,23 +61,6 @@ func Invoke(requestJSON string) string { return encodeInvokeResponse(nil, errors.New("unknown method")) } } - -func applyEnv(env *LibXrayEnvJson) { - if env == nil { - return - } - setEnvIfNotEmpty(platform.AssetLocation, env.AssetLocation) - setEnvIfNotEmpty(platform.CertLocation, env.CertLocation) - setEnvIfNotEmpty(platform.TunFdKey, env.TunFd) -} - -func setEnvIfNotEmpty(key string, value string) { - if value == "" { - return - } - _ = os.Setenv(key, value) -} - func validateAPIVersion(version int) error { if version == 0 || version == 1 { return nil @@ -98,7 +87,26 @@ func encodeInvokeResponse(data any, err error) string { } raw, err := json.Marshal(&response) if err != nil { - return `{"success":false,"error":"failed to encode response"}` + return `{"success":false,"data":null,"error":"failed to encode response"}` + } + if len(raw) > maxInvokeJSONBytes { + return encodeInvokeFailure(invokeJSONSizeLimitMessage("response")) + } + return string(raw) +} + +func invokeJSONSizeLimitMessage(kind string) string { + return fmt.Sprintf( + "invoke %s exceeds the %d MiB size limit", + kind, + maxInvokeJSONSizeMiB, + ) +} + +func encodeInvokeFailure(message string) string { + raw, err := json.Marshal(&invokeResponse{Err: message}) + if err != nil { + return `{"success":false,"data":null,"error":"failed to encode response"}` } return string(raw) } diff --git a/invoke_model.go b/invoke_model.go index 68c9efff..66d2732d 100644 --- a/invoke_model.go +++ b/invoke_model.go @@ -22,16 +22,9 @@ const ( type LibXrayInvokeRequest struct { APIVersion int `json:"apiVersion,omitempty"` Method LibXrayMethod `json:"method,omitempty"` - Env *LibXrayEnvJson `json:"env,omitempty"` Payload json.RawMessage `json:"payload,omitempty"` } -type LibXrayEnvJson struct { - AssetLocation string `json:"xray.location.asset,omitempty"` - CertLocation string `json:"xray.location.cert,omitempty"` - TunFd string `json:"xray.tun.fd,omitempty"` -} - type GetFreePortsRequest struct { Count int `json:"count,omitempty"` } diff --git a/invoke_test.go b/invoke_test.go index 90d6b3a2..74329339 100644 --- a/invoke_test.go +++ b/invoke_test.go @@ -5,11 +5,11 @@ import ( "encoding/json" "os" "path/filepath" + "strings" "testing" "github.com/xtls/libxray/nodep" "github.com/xtls/xray-core/common/geodata" - "github.com/xtls/xray-core/common/platform" "google.golang.org/protobuf/proto" ) @@ -232,6 +232,36 @@ func TestInvokeRunXray(t *testing.T) { requireNoDataObject(t, response) } +func TestInvokeRunXrayAppliesConfigEnv(t *testing.T) { + const key = "XRAY_LIBXRAY_CONFIG_ENV_TEST" + t.Setenv(key, "") + + rawConfig, err := json.Marshal(testXrayConfig(t)) + if err != nil { + t.Fatal(err) + } + var config map[string]any + if err := json.Unmarshal(rawConfig, &config); err != nil { + t.Fatal(err) + } + config["env"] = map[string]string{key: "configured"} + + configPath := filepath.Join(t.TempDir(), "xray.json") + writeConfigToFile(t, config, configPath) + response := invokeForTest( + t, + LibXrayMethodRunXray, + RunXrayRequest{ConfigPath: configPath}, + ) + defer xrayStopForTest(t) + if !response.Success { + t.Fatalf("RunXray failed: %s", response.Err) + } + if got := os.Getenv(key); got != "configured" { + t.Fatalf("config env = %q, want configured", got) + } +} + func TestInvokeXrayVersion(t *testing.T) { response := invokeForTest(t, LibXrayMethodXrayVersion, nil) if !response.Success { @@ -342,6 +372,41 @@ func TestInvokeUnknownMethod(t *testing.T) { } } +func TestInvokeRejectsOversizedRequest(t *testing.T) { + response := invokeRawForTest(t, strings.Repeat(" ", maxInvokeJSONBytes+1)) + if response.Success { + t.Fatal("oversized request should fail") + } + if response.Err != "invoke request exceeds the 16 MiB size limit" { + t.Fatalf("error = %q", response.Err) + } + if got := string(response.Data); got != "null" { + t.Fatalf("data = %s, want null", got) + } +} + +func TestInvokeRejectsOversizedResponse(t *testing.T) { + responseText := encodeInvokeResponse( + struct { + Text string `json:"text"` + }{Text: strings.Repeat("x", maxInvokeJSONBytes)}, + nil, + ) + var response testResponse + if err := json.Unmarshal([]byte(responseText), &response); err != nil { + t.Fatal(err) + } + if response.Success { + t.Fatal("oversized response should fail") + } + if response.Err != "invoke response exceeds the 16 MiB size limit" { + t.Fatalf("error = %q", response.Err) + } + if got := string(response.Data); got != "null" { + t.Fatalf("data = %s, want null", got) + } +} + func TestInvokeAPIVersion(t *testing.T) { response := invokeRawForTest(t, `{"method":"xrayVersion"}`) if !response.Success { @@ -373,28 +438,7 @@ func TestInvokeNoDataResponseShape(t *testing.T) { } } -func TestInvokeAppliesSupportedEnv(t *testing.T) { - restoreEnv(t, platform.AssetLocation) - restoreEnv(t, platform.CertLocation) - restoreEnv(t, platform.TunFdKey) - - requestJSON := `{"apiVersion":1,"method":"xrayVersion","env":{"xray.location.asset":"/tmp/assets","xray.location.cert":"/tmp/certs","xray.tun.fd":"123"}}` - response := invokeRawForTest(t, requestJSON) - if !response.Success { - t.Fatalf("xrayVersion failed: %s", response.Err) - } - if got := os.Getenv(platform.AssetLocation); got != "/tmp/assets" { - t.Fatalf("%s = %q", platform.AssetLocation, got) - } - if got := os.Getenv(platform.CertLocation); got != "/tmp/certs" { - t.Fatalf("%s = %q", platform.CertLocation, got) - } - if got := os.Getenv(platform.TunFdKey); got != "123" { - t.Fatalf("%s = %q", platform.TunFdKey, got) - } -} - -func TestInvokeIgnoresUnknownEnvKeys(t *testing.T) { +func TestInvokeIgnoresTopLevelEnv(t *testing.T) { const key = "XRAY_LIBXRAY_UNKNOWN_ENV_TEST" _ = os.Unsetenv(key) t.Cleanup(func() { _ = os.Unsetenv(key) }) @@ -404,26 +448,13 @@ func TestInvokeIgnoresUnknownEnvKeys(t *testing.T) { t.Fatal(err) } if !response.Success { - t.Fatalf("unknown env should be ignored: %s", response.Err) + t.Fatalf("top-level env should be ignored: %s", response.Err) } if _, found := os.LookupEnv(key); found { - t.Fatal("unknown env should not be set") + t.Fatal("top-level env should not be set") } } -func restoreEnv(t *testing.T, key string) { - t.Helper() - oldValue, found := os.LookupEnv(key) - _ = os.Unsetenv(key) - t.Cleanup(func() { - if found { - _ = os.Setenv(key, oldValue) - } else { - _ = os.Unsetenv(key) - } - }) -} - func xrayStopForTest(t *testing.T) { t.Helper() response := invokeForTest(t, LibXrayMethodStopXray, nil) diff --git a/readme/README.zh_CN.md b/readme/README.zh_CN.md index 71ac6d76..cbda2a92 100644 --- a/readme/README.zh_CN.md +++ b/readme/README.zh_CN.md @@ -16,11 +16,8 @@ 依赖 git 和 go。 -默认情况下,编译脚本不会 clone [Xray-core](https://github.com/XTLS/Xray-core),而是通过 Go modules 将 Xray-core 固定到 tag `v26.6.27`(Go 会记录为对应的 pseudo-version)。 +默认情况下,编译脚本不会 clone [Xray-core](https://github.com/XTLS/Xray-core),而是通过 Go modules 的 pseudo-version 将 Xray-core 固定到 main 分支提交 `d5bc58dc`。 传入可选参数 `local` 时,会通过 Go module `replace` 改用已有的本地仓库 `../Xray-core`。 -Linux 和 Windows 构建还会输出一个 desktop wrapper 可执行文件: -`bin/xray` 或 `bin/xray.exe`。wrapper 的 `-configPath` 参数指向一个 -`method` 为 `runXray` 的 `LibXrayInvokeRequest` JSON 文件。 ### 使用方式 @@ -100,11 +97,6 @@ char* CGoInvoke(char* requestJSON); { "apiVersion": 1, "method": "runXray", - "env": { - "xray.location.asset": "/path/to/dat", - "xray.location.cert": "/path/to/dat", - "xray.tun.fd": "123" - }, "payload": { "configPath": "/path/to/config.json" } @@ -123,12 +115,10 @@ char* CGoInvoke(char* requestJSON); 设计决定: -1. `Invoke.env` 只支持固定的 Xray-core runtime 环境项: - `xray.location.asset`、`xray.location.cert`、`xray.tun.fd`。 -2. 非空 `env` 字段会在 method 执行前写入进程环境变量。缺失字段不会 unset,也不会 restore 旧值。 -3. `xray.json.strict`、`xray.location.config`、`xray.location.confdir` 属于加载前进程环境变量,不能通过 `Invoke.env` 传入。 -4. `SetTunFd` 已删除。如果 fd 只能在运行时获得,请在请求的 `env` 对象中设置 `"xray.tun.fd"`。 -5. `countGeoData` 不依赖 Xray 配置,因此通过 method payload 的 `datDir` 传入数据目录。 +1. 顶层 `env` 字段会被忽略且不会生效。Xray-core 运行时环境项应写入 Xray 配置根 `env` 对象。 +2. `SetTunFd` 已删除。如果 fd 只能在运行时获得,请在调用 `runXray` 前把 `xray.tun.fd` 写入 Xray 配置根 `env` 对象。 +3. `countGeoData` 不依赖 Xray 配置,因此通过 method payload 的 `datDir` 传入数据目录。 +4. 完整的 UTF-8 编码 Invoke 请求和响应 JSON 包体限制为 16 MiB。任一方向超过限制时,Invoke 将返回 `success: false`、`data: null` 和对应的大小限制错误。 支持的 method: From d72d529edf86000df77edf4c0a3b7fafe6e896a6 Mon Sep 17 00:00:00 2001 From: yiguodev <147401898+yiguodev@users.noreply.github.com> Date: Sat, 11 Jul 2026 10:20:27 +0800 Subject: [PATCH 03/38] Harden Xray lifecycle and build tooling (#135) * Harden Xray lifecycle and C ABI builds * Use latest gomobile for builds * Use stable toolchains in workflows --- .github/workflows/build.yml | 12 ++- .github/workflows/libxray-test.yml | 3 +- .github/workflows/validate.yml | 45 ++++++++++ README.md | 4 + build/app/android.py | 49 ++++++----- build/app/apple_go.py | 129 +++++++-------------------- build/app/apple_gomobile.py | 39 +++++---- build/app/build.py | 136 ++++++++++++++--------------- 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 +++++++++++++++++++++++++ 15 files changed, 392 insertions(+), 251 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..1211799a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -11,9 +11,6 @@ concurrency: cancel-in-progress: false env: - GO_VERSION: "stable" - XCODE_VERSION: "latest-stable" - 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 new file mode 100644 index 00000000..6fd42472 --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,45 @@ +name: Validate libXray + +on: + push: + branches: + - main + pull_request: + +concurrency: + group: validate-libxray-${{ github.ref }} + cancel-in-progress: true + +jobs: + go: + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-go@v6 + with: + go-version: stable + 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 +172,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 294fb37343205b9b0cb7b7b1b423d3d4b60d9998 Mon Sep 17 00:00:00 2001 From: yiguodev <147401898+yiguodev@users.noreply.github.com> Date: Sun, 12 Jul 2026 11:43:44 +0800 Subject: [PATCH 04/38] Update Xray-core to v26.7.11 (#136) --- README.md | 2 +- build/app/build.py | 4 ++-- go.mod | 2 +- go.sum | 4 ++-- readme/README.zh_CN.md | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 255b47f0..fad94379 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ Compile script. It is recommended to always use this script to compile libXray. depends on git and go. -By default, the build script does not clone [Xray-core](https://github.com/XTLS/Xray-core). It uses Go modules and pins Xray-core to main commit `d5bc58dc` through its pseudo-version. +By default, the build script does not clone [Xray-core](https://github.com/XTLS/Xray-core). It uses Go modules and pins Xray-core to release tag `v26.7.11` through its pseudo-version. Pass the optional `local` argument to use an existing local checkout at `../Xray-core` through a Go module `replace`. ### Usage diff --git a/build/app/build.py b/build/app/build.py index 88fb9f24..f10471da 100644 --- a/build/app/build.py +++ b/build/app/build.py @@ -8,8 +8,8 @@ LIBXRAY_MOD_NAME = "github.com/xtls/libxray" 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" +# Go modules resolve the Xray-core v26.7.11 release tag through this version. +DEFAULT_XRAY_CORE_VERSION = "v1.260327.1-0.20260711155151-50231eaff98c" LOCAL_XRAY_CORE_DIR_NAME = "Xray-core" diff --git a/go.mod b/go.mod index f6e1e9f3..09946bb2 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.26.3 require ( github.com/stretchr/testify v1.11.1 - github.com/xtls/xray-core v1.260327.1-0.20260710025649-d5bc58dc6b76 + github.com/xtls/xray-core v1.260327.1-0.20260711155151-50231eaff98c google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index 726000d8..65df90a0 100644 --- a/go.sum +++ b/go.sum @@ -74,8 +74,8 @@ github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f h1:iy2JRioxmUpoJ3SzbFPyTxHZMbR/rSHP7dOOgYaq1O8= github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f/go.mod h1:DsJblcWDGt76+FVqBVwbwRhxyyNJsGV48gJLch0OOWI= -github.com/xtls/xray-core v1.260327.1-0.20260710025649-d5bc58dc6b76 h1:uRUAvUX3lemVMm0vts6WwdQvtYjvwrTs4uzI1zDZVEo= -github.com/xtls/xray-core v1.260327.1-0.20260710025649-d5bc58dc6b76/go.mod h1:Jts8yHqPCpvsdL5CW5xMd8H9d2fkg1cILeBNqEwRXNw= +github.com/xtls/xray-core v1.260327.1-0.20260711155151-50231eaff98c h1:SbB1ez0bqZllbzaVj0PC+Vje3dRA8m/7jW1ussjDSgM= +github.com/xtls/xray-core v1.260327.1-0.20260711155151-50231eaff98c/go.mod h1:Jts8yHqPCpvsdL5CW5xMd8H9d2fkg1cILeBNqEwRXNw= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= diff --git a/readme/README.zh_CN.md b/readme/README.zh_CN.md index a1cab762..320e0558 100644 --- a/readme/README.zh_CN.md +++ b/readme/README.zh_CN.md @@ -16,7 +16,7 @@ 依赖 git 和 go。 -默认情况下,编译脚本不会 clone [Xray-core](https://github.com/XTLS/Xray-core),而是通过 Go modules 的 pseudo-version 将 Xray-core 固定到 main 分支提交 `d5bc58dc`。 +默认情况下,编译脚本不会 clone [Xray-core](https://github.com/XTLS/Xray-core),而是通过 Go modules 的 pseudo-version 将 Xray-core 固定到发布版本 `v26.7.11`。 传入可选参数 `local` 时,会通过 Go module `replace` 改用已有的本地仓库 `../Xray-core`。 ### 使用方式 From 405b9d80d47af718c5c687634a9b1805b48e8006 Mon Sep 17 00:00:00 2001 From: Tamim Hossain <132823494+codewithtamim@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:21:47 +0600 Subject: [PATCH 05/38] add /proc/net UID fallback for Android < API 30 (#137) * add /proc/net UID fallback for Android < API 30 * fix matching: split hex and port per PCAPdroid logic --- README.md | 20 +++ android_wrapper.go | 29 ++-- controller/controller_android.go | 32 ++-- controller/proc_resolver_android.go | 223 ++++++++++++++++++++++++++++ go.mod | 7 +- go.sum | 6 + 6 files changed, 288 insertions(+), 29 deletions(-) create mode 100644 controller/proc_resolver_android.go diff --git a/README.md b/README.md index fad94379..f6c2cca0 100644 --- a/README.md +++ b/README.md @@ -180,8 +180,28 @@ getXrayState ## controller +### Socket protect + Used to solve the socket protect problem on Android. +### Process finder (per-app routing) + +`ConnectivityManager.getConnectionOwnerUid()` is API 30+. On older Android +libXray falls back to parsing `/proc/net/{tcp,udp}{,6}` in pure Go. + +Usage (Java/Kotlin): + +```java +ProcessFinder finder = new ProcessFinder() { + @Override + public long findProcessByConnection(String network, String srcIP, long srcPort, + String destIP, long destPort) { + return -1; // return UID or -1 + } +}; +LibXray.registerProcessFinder(finder, Build.VERSION.SDK_INT); +``` + ## geo ### count diff --git a/android_wrapper.go b/android_wrapper.go index 3000f2fc..88dfe24a 100644 --- a/android_wrapper.go +++ b/android_wrapper.go @@ -8,18 +8,10 @@ type DialerController interface { ProtectFd(int) bool } -// ProcessFinder is an interface for Android process finding functionality. -// Apps should implement FindProcessByConnection() -// and pass the implementation to RegisterProcessFinder() before starting the core. +// ProcessFinder -> implemented by apps to resolve UID from connection details. +// See RegisterProcessFinder. type ProcessFinder interface { - // FindProcessByConnection finds the UID of the process that owns the given connection. - // - // network: Protocol type: "tcp" or "udp" - // srcIP: Source IP address - // srcPort: Source port - // destIP: Destination IP address - // destPort: Destination port - // Returns the UID of the owning process, or -1 if not found. + // FindProcessByConnection -> returns UID owning the connection, or -1. FindProcessByConnection(network, srcIP string, srcPort int, destIP string, destPort int) int } @@ -35,17 +27,18 @@ func RegisterListenerController(controller DialerController) { }) } -// RegisterProcessFinder registers an Android process finder with Xray-core, -// enabling per-app routing based on UID. Must be called before starting the -// core for process-based routing rules to work. -// Pass nil to unregister a previously registered finder. -func RegisterProcessFinder(finder ProcessFinder) { +// RegisterProcessFinder -> registers process finder for per-app routing. +// Pass nil to unregister. +// sdkVersion = Build.VERSION.SDK_INT. +// On API < 30, /proc/net/* parsing is used automatically; the Java callback +// is only called on API 30+. +func RegisterProcessFinder(finder ProcessFinder, sdkVersion int) { if finder == nil { - c.RegisterProcessFinder(nil) + c.RegisterProcessFinder(nil, 0) return } c.RegisterProcessFinder(func(network, srcIP string, srcPort int, destIP string, destPort int) int { return finder.FindProcessByConnection(network, srcIP, srcPort, destIP, destPort) - }) + }, sdkVersion) } diff --git a/controller/controller_android.go b/controller/controller_android.go index 82dc0b67..19573767 100644 --- a/controller/controller_android.go +++ b/controller/controller_android.go @@ -10,27 +10,34 @@ import ( xinternet "github.com/xtls/xray-core/transport/internet" ) -// Give a callback before connection beginning. Useful for Android development. -// It depends on xray:api:beta +// RegisterDialerController -> callback before connection begins. +// Depends on xray:api:beta func RegisterDialerController(controller func(fd uintptr)) { xinternet.RegisterDialerController(func(network, address string, conn syscall.RawConn) error { return conn.Control(controller) }) } -// Give a callback before listener beginning. Useful for Android development. -// It depends on xray:api:beta +// RegisterListenerController -> callback before listener begins. +// Depends on xray:api:beta func RegisterListenerController(controller func(fd uintptr)) { xinternet.RegisterListenerController(func(network, address string, conn syscall.RawConn) error { return conn.Control(controller) }) } -// RegisterProcessFinder registers an Android process finder with Xray-core, -// enabling per-app routing based on UID. Must be called before starting the -// core for process-based routing rules to work. -// Pass nil to unregister a previously registered finder. -func RegisterProcessFinder(finder func(network, srcIP string, srcPort int, destIP string, destPort int) int) { +// sdkThresholdGetConnectionOwner -> min API level for getConnectionOwnerUid(). +// Below this we use /proc/net/* parsing. +const sdkThresholdGetConnectionOwner = 30 + +var androidSdkVersion int + +// RegisterProcessFinder -> registers process finder for per-app routing. +// Pass nil to unregister. sdkVersion = Build.VERSION.SDK_INT. +// When SDK < 30, falls back to /proc/net/* parsing (pure Go). +func RegisterProcessFinder(finder func(network, srcIP string, srcPort int, destIP string, destPort int) int, sdkVersion int) { + androidSdkVersion = sdkVersion + if finder == nil { corenet.RegisterAndroidProcessFinder(nil) return @@ -41,6 +48,13 @@ func RegisterProcessFinder(finder func(network, srcIP string, srcPort int, destI return 0, "", "", fmt.Errorf("process finder: no destination for %s %s:%d", network, srcIP, srcPort) } + if androidSdkVersion > 0 && androidSdkVersion < sdkThresholdGetConnectionOwner { + uid, err := resolveUidFromProc(network, srcIP, int(srcPort), destIP, int(destPort)) + if err == nil { + return uid, fmt.Sprintf("%d", uid), "", nil + } + } + uid := finder(network, srcIP, int(srcPort), destIP, int(destPort)) return uid, fmt.Sprintf("%d", uid), "", nil }) diff --git a/controller/proc_resolver_android.go b/controller/proc_resolver_android.go new file mode 100644 index 00000000..928c12f3 --- /dev/null +++ b/controller/proc_resolver_android.go @@ -0,0 +1,223 @@ +//go:build android + +package controller + +import ( + "bufio" + "encoding/hex" + "fmt" + "net" + "os" + "strconv" + "strings" +) + +// /proc/net/* UID resolver. Used when getConnectionOwnerUid() (API 30+) is +// unavailable. UID is read directly from the connection line - no inode->PID +// chain needed. Matches PCAPdroid uid_resolver.c: +// +// https://github.com/emanuele-f/PCAPdroid/blob/master/app/src/main/jni/common/uid_resolver.c +// +// /proc/net/{tcp,udp} field layout (after strings.Fields): +// +// [0] sl [1] local_address [2] rem_address [3] st +// [4] tx_queue:rx_queue [5] tr:tm->when [6] retrnsmt +// [7] uid [8] timeout [9] inode + +// resolveUidFromProc -> tries IPv4 first, falls back to IPv4-mapped IPv6 +// (matching PCAPdroid get_uid_slow). +func resolveUidFromProc(network, srcIP string, srcPort int, destIP string, destPort int) (int, error) { + ip := net.ParseIP(srcIP) + if ip == nil { + return -1, fmt.Errorf("bad src IP %s", srcIP) + } + + if ip4 := ip.To4(); ip4 != nil { + uid, err := resolve4(network, ip4, srcPort, destIP, destPort) + if err == nil { + return uid, nil + } + // PCAPdroid: sprintf("0000000000000000FFFF0000%08X", ip4) + // Catches IPv4 conns shown in tcp6/udp6. + mapped := make([]byte, 16) + copy(mapped[10:], []byte{0xFF, 0xFF}) + copy(mapped[12:], ip4) + return resolve6(network, mapped, srcPort, destIP, destPort) + } + + ip16 := ip.To16() + if ip16 != nil { + return resolve6(network, ip16, srcPort, destIP, destPort) + } + + return -1, fmt.Errorf("bad IP family for %s", srcIP) +} + +func resolve4(network string, ip4 []byte, srcPort int, destIP string, destPort int) (int, error) { + procFile := procFilePath(network, false) + if procFile == "" { + return -1, fmt.Errorf("unsupported network %s", network) + } + targetHex, _ := formatHexAddr4(ip4, srcPort) + destHex, _ := formatDestHex(destIP, destPort, false) + return findUidInProcFile(procFile, targetHex, destHex) +} + +func resolve6(network string, ip16 []byte, srcPort int, destIP string, destPort int) (int, error) { + procFile := procFilePath(network, true) + if procFile == "" { + return -1, fmt.Errorf("unsupported network %s", network) + } + targetHex, _ := formatHexAddr6(ip16, srcPort) + destHex, _ := formatDestHex(destIP, destPort, true) + return findUidInProcFile(procFile, targetHex, destHex) +} + +func procFilePath(network string, v6 bool) string { + switch network { + case "tcp": + if v6 { + return "/proc/net/tcp6" + } + return "/proc/net/tcp" + case "udp": + if v6 { + return "/proc/net/udp6" + } + return "/proc/net/udp" + default: + return "" + } +} + +// formatDestHex -> format dest IP:port for matching. Returns "" if dest +// info is missing (match any remote). +func formatDestHex(destIP string, destPort int, v6 bool) (string, error) { + ip := net.ParseIP(destIP) + if ip == nil || destPort <= 0 { + return "", nil + } + if v6 { + if ip16 := ip.To16(); ip16 != nil { + return formatHexAddr6(ip16, destPort) + } + if ip4 := ip.To4(); ip4 != nil { + mapped := make([]byte, 16) + copy(mapped[10:], []byte{0xFF, 0xFF}) + copy(mapped[12:], ip4) + return formatHexAddr6(mapped, destPort) + } + return "", nil + } + if ip4 := ip.To4(); ip4 != nil { + return formatHexAddr4(ip4, destPort) + } + return "", nil +} + +// /proc/net/* stores IPs per 32-bit word in little-endian byte order. +// PCAPdroid casts in6_addr to uint32_t[4] on LE hardware; we replicate +// that here. + +func formatHexAddr4(ip4 []byte, port int) (string, error) { + b := make([]byte, 4) + copy(b, ip4) + for i, j := 0, 3; i < j; i, j = i+1, j-1 { + b[i], b[j] = b[j], b[i] + } + return fmt.Sprintf("%s:%04X", strings.ToUpper(hex.EncodeToString(b)), port), nil +} + +func formatHexAddr6(ip16 []byte, port int) (string, error) { + words := make([]string, 4) + for w := 0; w < 4; w++ { + off := w * 4 + b := []byte{ip16[off+3], ip16[off+2], ip16[off+1], ip16[off+0]} + words[w] = strings.ToUpper(hex.EncodeToString(b)) + } + return fmt.Sprintf("%s:%04X", strings.Join(words, ""), port), nil +} + +// allZeros -> true if s is all '0' characters. +func allZeros(s string) bool { + for _, c := range s { + if c != '0' { + return false + } + } + return len(s) > 0 +} + +// findUidInProcFile -> scans /proc/net/{tcp,udp,..} for a line matching +// (targetHex, destHex). Matching per PCAPdroid get_uid_proc: +// +// sport == src_port +// (dport == dst_port || dport == 0) +// (dhex == conn_dhex || dhex == zero) +// (shex == conn_shex || shex == zero) +// +// where shex/dhex/sport/dport are from /proc and +// conn_shex/conn_dhex/src_port/dst_port are our connection args. +func findUidInProcFile(filePath, targetHex, destHex string) (int, error) { + f, err := os.Open(filePath) + if err != nil { + return -1, err + } + defer f.Close() + + s := bufio.NewScanner(f) + first := true + for s.Scan() { + if first { + first = false + continue + } + + fields := strings.Fields(s.Text()) + if len(fields) < 8 { + continue + } + + // Split hex:port into parts for independent matching. + loc := strings.SplitN(fields[1], ":", 2) + tgt := strings.SplitN(targetHex, ":", 2) + if len(loc) != 2 || len(tgt) != 2 { + continue + } + + // sport == src_port + if loc[1] != tgt[1] { + continue + } + // shex == conn_shex || shex == zero + if loc[0] != tgt[0] && !allZeros(loc[0]) { + continue + } + + // Match remote when we have a dest filter. + if destHex != "" { + rem := strings.SplitN(fields[2], ":", 2) + dst := strings.SplitN(destHex, ":", 2) + if len(rem) != 2 || len(dst) != 2 { + continue + } + + // dport == dst_port || dport == 0 + if rem[1] != dst[1] && rem[1] != "0000" { + continue + } + // dhex == conn_dhex || dhex == zero + if rem[0] != dst[0] && !allZeros(rem[0]) { + continue + } + } + + uid, err := strconv.Atoi(fields[7]) + if err != nil { + continue + } + return uid, nil + } + + return -1, fmt.Errorf("not found in %s", filePath) +} diff --git a/go.mod b/go.mod index 09946bb2..3a71b73e 100644 --- a/go.mod +++ b/go.mod @@ -41,13 +41,14 @@ require ( go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect golang.org/x/crypto v0.54.0 // indirect golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect - golang.org/x/mod v0.37.0 // indirect + golang.org/x/mobile v0.0.0-20260709172247-6129f5bee9d5 // indirect + 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.47.0 // indirect + golang.org/x/tools v0.48.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect golang.zx2c4.com/wireguard/windows v1.0.1 // indirect @@ -57,3 +58,5 @@ require ( gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect lukechampine.com/blake3 v1.4.1 // indirect ) + +tool golang.org/x/mobile/cmd/gobind diff --git a/go.sum b/go.sum index 65df90a0..962ff921 100644 --- a/go.sum +++ b/go.sum @@ -96,8 +96,12 @@ golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM= golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= +golang.org/x/mobile v0.0.0-20260709172247-6129f5bee9d5 h1:Mn1OzFmF0ZKX/ZayHz/UdnWHufPp1wlD9lZ5U8LRDFY= +golang.org/x/mobile v0.0.0-20260709172247-6129f5bee9d5/go.mod h1:YX+n47s+53POxN3dx9cIGxG3hGUm/lD64hvrRJFbcSA= golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= @@ -112,6 +116,8 @@ golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A= From e04dacaf67628ab2ecf1808e52e3b8930f9316b7 Mon Sep 17 00:00:00 2001 From: yiguodev <147401898+yiguodev@users.noreply.github.com> Date: Sun, 19 Jul 2026 16:25:45 +0800 Subject: [PATCH 06/38] Update native platform builds (#138) --- .github/workflows/build.yml | 24 ++++++++++++++++++++++++ README.md | 9 +++------ build/app/apple_go.py | 5 ++--- readme/README.zh_CN.md | 9 +++------ 4 files changed, 32 insertions(+), 15 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1211799a..a887d5be 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -33,7 +33,13 @@ jobs: - runner: windows-2022 target: windows arch: x64 + goarch: amd64 artifact: windows-x64 + - runner: windows-11-arm + target: windows + arch: arm64 + goarch: arm64 + artifact: windows-arm64 - runner: macos-26 target: apple artifact: apple-cgo @@ -124,9 +130,27 @@ jobs: - name: Build Windows if: matrix.target == 'windows' + env: + GOARCH: ${{ matrix.goarch }} run: | python build/main.py windows + - name: Verify Windows architecture + 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) + } + - name: Build Apple (macOS/iOS) if: matrix.target == 'apple' run: | diff --git a/README.md b/README.md index f6c2cca0..9e13d08b 100644 --- a/README.md +++ b/README.md @@ -101,13 +101,10 @@ depend on gcc and g++. ### Windows -depend on LLVM MinGW. +Depends on gcc and g++ in `PATH`. -you can use winget to install [LLVM MinGW](https://github.com/mstorsjo/llvm-mingw). - -```shell -winget install MartinStorsjo.LLVM-MinGW.UCRT -``` +Native amd64 and arm64 builds are supported. The release workflow builds each +architecture on its matching GitHub-hosted Windows runner. ## API diff --git a/build/app/apple_go.py b/build/app/apple_go.py index 52fadb3d..38ff8c3a 100644 --- a/build/app/apple_go.py +++ b/build/app/apple_go.py @@ -54,21 +54,20 @@ def __init__(self, build_dir: str, use_local_xray_core: bool = False): ), ] - # keep same with flutter self.macos_targets = [ AppleTarget( "darwin", "amd64", "x86_64", "macosx", - "10.14", + "12.0", ), AppleTarget( "darwin", "arm64", "arm64", "macosx", - "10.14", + "12.0", ), ] diff --git a/readme/README.zh_CN.md b/readme/README.zh_CN.md index 320e0558..2209662d 100644 --- a/readme/README.zh_CN.md +++ b/readme/README.zh_CN.md @@ -69,13 +69,10 @@ python3 build/main.py windows local ### Windows -依赖 LLVM MinGW 。 +依赖 `PATH` 中的 gcc 和 g++。 -你可使用 winget 安装 [LLVM MinGW](https://github.com/mstorsjo/llvm-mingw)。 - -```shell -winget install MartinStorsjo.LLVM-MinGW.UCRT -``` +支持原生 amd64 和 arm64 构建。Release workflow 会在对应架构的 GitHub +Windows runner 上分别构建产物。 ## API From cd6ccaf49c9d3286ecb4aa6974d17d1c25038285 Mon Sep 17 00:00:00 2001 From: yiguodev <147401898+yiguodev@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:08:46 +0800 Subject: [PATCH 07/38] Add Android VPN-aware DNS resolver (#139) --- README.md | 20 ++++++++++ android_wrapper.go | 23 +++++++++++- dns/dns_android.go | 41 +++++++++++++++++++++ dns/resolver.go | 66 +++++++++++++++++++++++++++++++++ dns/resolver_test.go | 84 ++++++++++++++++++++++++++++++++++++++++++ readme/README.zh_CN.md | 19 ++++++++++ 6 files changed, 252 insertions(+), 1 deletion(-) create mode 100644 dns/dns_android.go create mode 100644 dns/resolver.go create mode 100644 dns/resolver_test.go 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 From 6f806fc0a4420f80ed0fe59ad72c43aef1c6d8ee Mon Sep 17 00:00:00 2001 From: yiguodev <147401898+yiguodev@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:47:09 +0800 Subject: [PATCH 08/38] Validate imported outbounds with Xray-core (#140) --- README.md | 3 ++ invoke_test.go | 42 +++++++++++++++++++ readme/README.zh_CN.md | 1 + share/parse_share.go | 6 ++- share/parse_share_test.go | 58 +++++++++++++++---------- share/validate_outbound.go | 86 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 174 insertions(+), 22 deletions(-) create mode 100644 share/validate_outbound.go diff --git a/README.md b/README.md index f4427494..35cd72d9 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,9 @@ Design notes: 4. The complete UTF-8 encoded Invoke request and response JSON envelopes are limited to 16 MiB. If either limit is exceeded, Invoke returns a failure response with `success: false`, `data: null`, and a size-limit error. +5. `convertShareLinksToXrayJson` validates each parsed outbound with the current + Xray-core config builder. Invalid outbounds are omitted, and the method fails + if none remain. Validation does not create or start an Xray instance. Supported methods: diff --git a/invoke_test.go b/invoke_test.go index 74329339..1c94f0fc 100644 --- a/invoke_test.go +++ b/invoke_test.go @@ -10,6 +10,7 @@ import ( "github.com/xtls/libxray/nodep" "github.com/xtls/xray-core/common/geodata" + "github.com/xtls/xray-core/infra/conf" "google.golang.org/protobuf/proto" ) @@ -307,6 +308,47 @@ func TestInvokeMapResponseShape(t *testing.T) { } } +func TestInvokeConvertShareLinksFiltersBuildInvalidOutbounds(t *testing.T) { + const validName = "Valid" + links := "vless://2418d087-648k-4990-86e8-19dca1d006d3@invalid.example:443?encryption=none&security=tls&sni=invalid.example&fp=chrome\n" + + "vless://12345678-abcd-abcd-abcd-123456789abc@valid.example:443?encryption=none&security=tls&sni=valid.example&fp=chrome#" + validName + + response := invokeForTest( + t, + LibXrayMethodConvertShareLinksToXrayJson, + ConvertShareLinksToXrayJsonRequest{Text: links}, + ) + if !response.Success { + t.Fatalf("ConvertShareLinksToXrayJson failed: %s", response.Err) + } + config := decodeDataObject[conf.Config](t, response) + if len(config.OutboundConfigs) != 1 { + t.Fatalf("outbounds = %d, want 1", len(config.OutboundConfigs)) + } + if config.OutboundConfigs[0].SendThrough == nil || *config.OutboundConfigs[0].SendThrough != validName { + t.Fatalf("sendThrough = %v, want %q", config.OutboundConfigs[0].SendThrough, validName) + } +} + +func TestInvokeConvertShareLinksFailsWhenAllOutboundsAreBuildInvalid(t *testing.T) { + response := invokeForTest( + t, + LibXrayMethodConvertShareLinksToXrayJson, + ConvertShareLinksToXrayJsonRequest{ + Text: "vless://2418d087-648k-4990-86e8-19dca1d006d3@invalid.example:443?encryption=none&security=tls&sni=invalid.example&fp=chrome", + }, + ) + if response.Success { + t.Fatal("ConvertShareLinksToXrayJson should fail when all outbounds are invalid") + } + if !strings.Contains(response.Err, "no valid outbound found") { + t.Fatalf("error = %q", response.Err) + } + if got := string(response.Data); got != "null" { + t.Fatalf("data = %s, want null", got) + } +} + func TestInvokePingReturnsDelaySentinelOnXrayError(t *testing.T) { response := invokeForTest( t, diff --git a/readme/README.zh_CN.md b/readme/README.zh_CN.md index 263ee5d9..bf826b45 100644 --- a/readme/README.zh_CN.md +++ b/readme/README.zh_CN.md @@ -120,6 +120,7 @@ void CGoFree(char* value); 2. `SetTunFd` 已删除。如果 fd 只能在运行时获得,请在调用 `runXray` 前把 `xray.tun.fd` 写入 Xray 配置根 `env` 对象。 3. `countGeoData` 不依赖 Xray 配置,因此通过 method payload 的 `datDir` 传入数据目录。 4. 完整的 UTF-8 编码 Invoke 请求和响应 JSON 包体限制为 16 MiB。任一方向超过限制时,Invoke 将返回 `success: false`、`data: null` 和对应的大小限制错误。 +5. `convertShareLinksToXrayJson` 会使用当前 Xray-core 配置构建器校验每个已解析的 outbound。无效 outbound 会被忽略;如果没有剩余的有效 outbound,该方法返回失败。校验不会创建或启动 Xray instance。 支持的 method: diff --git a/share/parse_share.go b/share/parse_share.go index 55436a01..f57e33b9 100644 --- a/share/parse_share.go +++ b/share/parse_share.go @@ -49,7 +49,11 @@ func decodeBase64Text(text string) (string, error) { // - one base64 blob that decodes to Xray JSON, share lines, or Clash YAML // - Clash / Clash.Meta YAML (proxies:) func ConvertShareLinksToXrayJson(links string) (*conf.Config, error) { - return convertShareLinksToXrayJson(links, true) + config, err := convertShareLinksToXrayJson(links, true) + if err != nil { + return nil, err + } + return filterBuildableOutbounds(config) } func convertShareLinksToXrayJson(links string, allowBase64 bool) (*conf.Config, error) { diff --git a/share/parse_share_test.go b/share/parse_share_test.go index 748b0210..c2984d88 100644 --- a/share/parse_share_test.go +++ b/share/parse_share_test.go @@ -184,6 +184,28 @@ func TestConvertShareLinksToXrayJson_XrayJSONNoOutbounds(t *testing.T) { assert.Contains(t, err.Error(), "outbound") } +func TestConvertShareLinksToXrayJson_FiltersBuildInvalidOutbounds(t *testing.T) { + links := "vless://2418d087-648k-4990-86e8-19dca1d006d3@invalid-id.example:443?encryption=none&security=tls&sni=invalid-id.example&fp=chrome\n" + + "vless://" + testShareUUID + "@invalid-reality.example:443?encryption=none&security=reality&sni=invalid-reality.example&pbk=invalid&fp=chrome\n" + + "vless://" + testShareUUID + "@valid.example:443?encryption=none&security=tls&sni=valid.example&fp=chrome#Valid" + + config, err := ConvertShareLinksToXrayJson(links) + require.NoError(t, err) + require.Len(t, config.OutboundConfigs, 1) + require.NotNil(t, config.OutboundConfigs[0].SendThrough) + assert.Equal(t, "Valid", *config.OutboundConfigs[0].SendThrough) +} + +func TestConvertShareLinksToXrayJson_AllBuildInvalidOutbounds(t *testing.T) { + _, err := ConvertShareLinksToXrayJson( + "vless://2418d087-648k-4990-86e8-19dca1d006d3@invalid.example:443?encryption=none&security=tls&sni=invalid.example&fp=chrome", + ) + + require.Error(t, err) + assert.Contains(t, err.Error(), "no valid outbound found") + assert.Contains(t, err.Error(), "invalid byte") +} + func TestConvertShareLinksToXrayJson_Base64EncodedLines(t *testing.T) { lines := "trojan://secret@trojan.example.com:443?sni=trojan.example.com\n" + "ss://" + ssUserB64("aes-128-gcm", "pwd") + "@ss.example.com:8388#ssn" @@ -197,7 +219,7 @@ func TestConvertShareLinksToXrayJson_Base64EncodedLines(t *testing.T) { } func TestConvertShareLinksToXrayJson_Base64URLSafeBlob(t *testing.T) { - inner := "vless://" + testShareUUID + "@v.example.com:443?encryption=none&security=none" + inner := "vless://" + testShareUUID + "@10.0.0.1:443?encryption=none&security=none" b := base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString([]byte(inner)) cfg, err := ConvertShareLinksToXrayJson(b) require.NoError(t, err) @@ -220,7 +242,7 @@ func TestConvertShareLinksToXrayJson_Shadowsocks(t *testing.T) { } func TestConvertShareLinksToXrayJson_VlessWSAndTLS(t *testing.T) { - link := "vless://" + testShareUUID + "@edge.example:443?encryption=none&type=ws&path=%2Fws&host=cdn.edge&security=tls&sni=edge.example&alpn=h2%2Ch3&fp=chrome&insecure=1" + link := "vless://" + testShareUUID + "@edge.example:443?encryption=none&type=ws&path=%2Fws&host=cdn.edge&security=tls&sni=edge.example&alpn=h2%2Ch3&fp=chrome&vcn=edge.example" cfg, err := ConvertShareLinksToXrayJson(link) require.NoError(t, err) require.Len(t, cfg.OutboundConfigs, 1) @@ -233,15 +255,15 @@ func TestConvertShareLinksToXrayJson_VlessWSAndTLS(t *testing.T) { require.NotNil(t, ss.TLSSettings) assert.Equal(t, "edge.example", ss.TLSSettings.ServerName) assert.Equal(t, "chrome", ss.TLSSettings.Fingerprint) - assert.True(t, ss.TLSSettings.AllowInsecure) + assert.Equal(t, "edge.example", ss.TLSSettings.VerifyPeerCertByName) require.NotNil(t, ss.TLSSettings.ALPN) assert.Contains(t, []string(*ss.TLSSettings.ALPN), "h2") } func TestConvertShareLinksToXrayJson_VlessReality(t *testing.T) { - pbk := "ZXYAbCdEfGhIjKlMnOpQrStUvWxYz0123456789ABCD" + pbk := "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" link := "vless://" + testShareUUID + "@reality.example:443?encryption=none&security=reality&type=tcp&sni=reality.example&pbk=" + - pbk + "&sid=abcd&fp=qq&pqv=pqv1&spx=%2F" + pbk + "&sid=abcd&fp=chrome&spx=%2F" cfg, err := ConvertShareLinksToXrayJson(link) require.NoError(t, err) ss := cfg.OutboundConfigs[0].StreamSetting @@ -250,8 +272,7 @@ func TestConvertShareLinksToXrayJson_VlessReality(t *testing.T) { require.NotNil(t, ss.REALITYSettings) assert.Equal(t, pbk, ss.REALITYSettings.PublicKey) assert.Equal(t, "abcd", ss.REALITYSettings.ShortId) - assert.Equal(t, "qq", ss.REALITYSettings.Fingerprint) - assert.Equal(t, "pqv1", ss.REALITYSettings.Mldsa65Verify) + assert.Equal(t, "chrome", ss.REALITYSettings.Fingerprint) assert.Equal(t, "/", ss.REALITYSettings.SpiderX) } @@ -313,12 +334,9 @@ func TestConvertShareLinksToXrayJson_VmessBase64QR(t *testing.T) { func TestConvertShareLinksToXrayJson_TransportKcpGrpcHttpUpgradeXhttp(t *testing.T) { t.Run("kcp", func(t *testing.T) { link := "vless://" + testShareUUID + "@k.example:443?encryption=none&type=kcp&headerType=srtp&seed=myseed" - cfg, err := ConvertShareLinksToXrayJson(link) - require.NoError(t, err) - ss := cfg.OutboundConfigs[0].StreamSetting - require.NotNil(t, ss.KCPSettings) - require.NotNil(t, ss.KCPSettings.Seed) - assert.Equal(t, "myseed", *ss.KCPSettings.Seed) + _, err := ConvertShareLinksToXrayJson(link) + require.Error(t, err) + assert.Contains(t, err.Error(), "mkcp header & seed has been removed") }) t.Run("grpc", func(t *testing.T) { @@ -350,20 +368,21 @@ func TestConvertShareLinksToXrayJson_TransportKcpGrpcHttpUpgradeXhttp(t *testing require.NoError(t, err) x := cfg.OutboundConfigs[0].StreamSetting.XHTTPSettings require.NotNil(t, x) + assert.Nil(t, cfg.OutboundConfigs[0].StreamSetting.SplitHTTPSettings) assert.Equal(t, "stream-up", x.Mode) require.NotNil(t, x.Extra) }) } func TestConvertShareLinksToXrayJson_FinalMaskQuery(t *testing.T) { - fm := `{"udp":[{"type":"test-mask"}]}` + fm := `{"udp":[{"type":"noise","settings":{}}]}` link := "vless://" + testShareUUID + "@fm.example:443?encryption=none&type=tcp&fm=" + url.QueryEscape(fm) cfg, err := ConvertShareLinksToXrayJson(link) require.NoError(t, err) ss := cfg.OutboundConfigs[0].StreamSetting require.NotNil(t, ss.FinalMask) require.Len(t, ss.FinalMask.Udp, 1) - assert.Equal(t, "test-mask", ss.FinalMask.Udp[0].Type) + assert.Equal(t, "noise", ss.FinalMask.Udp[0].Type) } func TestConvertShareLinksToXrayJson_Hysteria2InvalidHop(t *testing.T) { @@ -477,12 +496,9 @@ func TestConvertShareLinksToXrayJson_VmessQRGrpcAndKcp(t *testing.T) { t.Run("kcp", func(t *testing.T) { qr := `{"ps":"k","add":"kcp.host","port":"8391","id":"` + testShareUUID + `","net":"kcp","path":"seedval","type":"wireguard"}` link := "vmess://" + base64.StdEncoding.EncodeToString([]byte(qr)) - cfg, err := ConvertShareLinksToXrayJson(link) - require.NoError(t, err) - ks := cfg.OutboundConfigs[0].StreamSetting.KCPSettings - require.NotNil(t, ks) - require.NotNil(t, ks.Seed) - assert.Equal(t, "seedval", *ks.Seed) + _, err := ConvertShareLinksToXrayJson(link) + require.Error(t, err) + assert.Contains(t, err.Error(), "mkcp header & seed has been removed") }) } diff --git a/share/validate_outbound.go b/share/validate_outbound.go new file mode 100644 index 00000000..4f89d292 --- /dev/null +++ b/share/validate_outbound.go @@ -0,0 +1,86 @@ +package share + +import ( + "bytes" + "encoding/json" + "fmt" + "reflect" + + "github.com/xtls/xray-core/infra/conf" +) + +func filterBuildableOutbounds(config *conf.Config) (*conf.Config, error) { + raw, err := json.Marshal(config.OutboundConfigs) + if err != nil { + return nil, fmt.Errorf("failed to copy outbounds for validation: %w", err) + } + + var validationOutbounds []conf.OutboundDetourConfig + if err := json.Unmarshal(raw, &validationOutbounds); err != nil { + return nil, fmt.Errorf("failed to copy outbounds for validation: %w", err) + } + restoreNilRawMessages(reflect.ValueOf(&validationOutbounds)) + + validOutbounds := make([]conf.OutboundDetourConfig, 0, len(config.OutboundConfigs)) + var firstBuildError error + for index := range validationOutbounds { + // Share conversion stores the display name in sendThrough because Xray + // has no outbound name field. It is metadata here, not a bind address. + validationOutbounds[index].SendThrough = nil + if _, err := validationOutbounds[index].Build(); err != nil { + if firstBuildError == nil { + firstBuildError = err + } + continue + } + validOutbounds = append(validOutbounds, config.OutboundConfigs[index]) + } + if len(validOutbounds) == 0 { + if firstBuildError != nil { + return nil, fmt.Errorf("no valid outbound found: %w", firstBuildError) + } + return nil, fmt.Errorf("no valid outbound found") + } + + config.OutboundConfigs = validOutbounds + return config, nil +} + +var rawMessageType = reflect.TypeOf(json.RawMessage{}) + +func restoreNilRawMessages(value reflect.Value) { + if !value.IsValid() { + return + } + if value.Kind() == reflect.Pointer || value.Kind() == reflect.Interface { + if !value.IsNil() { + restoreNilRawMessages(value.Elem()) + } + return + } + if value.Type() == rawMessageType { + if value.CanSet() && bytes.Equal(bytes.TrimSpace(value.Bytes()), []byte("null")) { + value.SetZero() + } + return + } + + switch value.Kind() { + case reflect.Struct: + for index := range value.NumField() { + restoreNilRawMessages(value.Field(index)) + } + case reflect.Slice, reflect.Array: + for index := range value.Len() { + restoreNilRawMessages(value.Index(index)) + } + case reflect.Map: + iterator := value.MapRange() + for iterator.Next() { + item := reflect.New(iterator.Value().Type()).Elem() + item.Set(iterator.Value()) + restoreNilRawMessages(item) + value.SetMapIndex(iterator.Key(), item) + } + } +} From e663ac14f53fec69a0d19ce2050a16ab1ce24a53 Mon Sep 17 00:00:00 2001 From: yiguodev <147401898+yiguodev@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:31:37 +0800 Subject: [PATCH 09/38] Add concurrent batch ping and improve runtime safety (#141) * Add concurrent batch ping support * Remove ping batch config IDs * Update Xray-core to v26.7.28 * Support SIP002 plain user info * Harden runtime error handling --- README.md | 49 +++- android_wrapper.go | 8 +- build/app/build.py | 4 +- controller/controller.go | 19 ++ controller/controller_android.go | 8 +- controller/controller_test.go | 56 +++++ go.mod | 4 +- go.sum | 16 +- invoke.go | 36 +++ invoke_model.go | 22 ++ invoke_test.go | 80 ++++++ memory/memory_ios.go | 14 +- nodep/measure.go | 11 +- readme/README.zh_CN.md | 39 ++- share/generate_share.go | 93 +++++-- share/generate_share_test.go | 63 +++++ share/parse_share.go | 37 ++- share/parse_share_test.go | 31 +++ xray/ping.go | 1 + xray/ping_batch.go | 413 +++++++++++++++++++++++++++++++ xray/ping_batch_test.go | 363 +++++++++++++++++++++++++++ 21 files changed, 1303 insertions(+), 64 deletions(-) create mode 100644 controller/controller_test.go create mode 100644 xray/ping_batch.go create mode 100644 xray/ping_batch_test.go diff --git a/README.md b/README.md index 35cd72d9..8b57b6d2 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ Compile script. It is recommended to always use this script to compile libXray. depends on git and go. -By default, the build script does not clone [Xray-core](https://github.com/XTLS/Xray-core). It uses Go modules and pins Xray-core to release tag `v26.7.11` through its pseudo-version. +By default, the build script does not clone [Xray-core](https://github.com/XTLS/Xray-core). It uses Go modules and pins Xray-core to release tag `v26.7.28` through its pseudo-version. Pass the optional `local` argument to use an existing local checkout at `../Xray-core` through a Go module `replace`. ### Usage @@ -161,6 +161,14 @@ Design notes: 5. `convertShareLinksToXrayJson` validates each parsed outbound with the current Xray-core config builder. Invalid outbounds are omitted, and the method fails if none remain. Validation does not create or start an Xray instance. +6. Xray-core keeps its system dialer DNS client and outbound manager in + process-wide state. Creating another Xray instance through `ping`, + `pingBatch`, `testXray`, or the exported Go APIs while `runXray` or + `runXrayFromJson` is active may replace that state and affect the running + instance. Closing the temporary instance does not restore the previous + state. libXray does not serialize, isolate, or restore concurrent instances; + callers that require overlapping instances must place them in separate + processes. Supported methods: @@ -170,6 +178,7 @@ convertShareLinksToXrayJson convertXrayJsonToShareLinks countGeoData ping +pingBatch testXray runXray runXrayFromJson @@ -282,6 +291,44 @@ Some tools used to parse shared links. Latency testing. +### pingBatch + +Tests multiple outbound configurations concurrently in one temporary Xray +instance. Each config file is parsed only for its `outbounds`; all other root +fields are ignored. The target outbound is selected by `outboundTag`, then by +the `proxy` tag, and finally by the first outbound. + +```json +{ + "apiVersion": 1, + "method": "pingBatch", + "payload": { + "configs": [ + { + "configPath": "/path/to/node-1.json" + }, + { + "configPath": "/path/to/full-config.json", + "outboundTag": "media" + } + ], + "timeout": 5, + "url": "https://cp.cloudflare.com/" + } +} +``` + +Each request accepts at most five configurations and tests all accepted +configurations concurrently. Requests containing more than five configurations +fail before any configuration is tested. + +The top-level response succeeds when the batch itself was accepted. Each item +has its own result; `delay` is `10000` for an error and `11000` for a timeout. +The result array has the same length and order as the input config array. +Outbound dependencies referenced by +`streamSettings.sockopt.dialerProxy` or `proxySettings.tag` are included +automatically. + ### metrics Refer to the following configuration: diff --git a/android_wrapper.go b/android_wrapper.go index fcaae8fe..9cfae632 100644 --- a/android_wrapper.go +++ b/android_wrapper.go @@ -37,14 +37,14 @@ type ProcessFinder interface { } func RegisterDialerController(controller DialerController) { - c.RegisterDialerController(func(fd uintptr) { - controller.ProtectFd(int(fd)) + c.RegisterDialerController(func(fd uintptr) bool { + return controller.ProtectFd(int(fd)) }) } func RegisterListenerController(controller DialerController) { - c.RegisterListenerController(func(fd uintptr) { - controller.ProtectFd(int(fd)) + c.RegisterListenerController(func(fd uintptr) bool { + return controller.ProtectFd(int(fd)) }) } diff --git a/build/app/build.py b/build/app/build.py index f10471da..e1779bf4 100644 --- a/build/app/build.py +++ b/build/app/build.py @@ -8,8 +8,8 @@ LIBXRAY_MOD_NAME = "github.com/xtls/libxray" XRAY_CORE_MOD_NAME = "github.com/xtls/xray-core" -# Go modules resolve the Xray-core v26.7.11 release tag through this version. -DEFAULT_XRAY_CORE_VERSION = "v1.260327.1-0.20260711155151-50231eaff98c" +# Go modules resolve the Xray-core v26.7.28 release tag through this version. +DEFAULT_XRAY_CORE_VERSION = "v1.260327.1-0.20260728075948-5ca6f4b7d4dc" LOCAL_XRAY_CORE_DIR_NAME = "Xray-core" diff --git a/controller/controller.go b/controller/controller.go index b0b429f8..841304bf 100644 --- a/controller/controller.go +++ b/controller/controller.go @@ -1 +1,20 @@ package controller + +import ( + "errors" + "syscall" +) + +var errProtectSocket = errors.New("protect Android VPN socket failed") + +func protectSocket(conn syscall.RawConn, controller func(fd uintptr) bool) error { + var protectErr error + if err := conn.Control(func(fd uintptr) { + if !controller(fd) { + protectErr = errProtectSocket + } + }); err != nil { + return err + } + return protectErr +} diff --git a/controller/controller_android.go b/controller/controller_android.go index 19573767..b7f3d396 100644 --- a/controller/controller_android.go +++ b/controller/controller_android.go @@ -12,17 +12,17 @@ import ( // RegisterDialerController -> callback before connection begins. // Depends on xray:api:beta -func RegisterDialerController(controller func(fd uintptr)) { +func RegisterDialerController(controller func(fd uintptr) bool) { xinternet.RegisterDialerController(func(network, address string, conn syscall.RawConn) error { - return conn.Control(controller) + return protectSocket(conn, controller) }) } // RegisterListenerController -> callback before listener begins. // Depends on xray:api:beta -func RegisterListenerController(controller func(fd uintptr)) { +func RegisterListenerController(controller func(fd uintptr) bool) { xinternet.RegisterListenerController(func(network, address string, conn syscall.RawConn) error { - return conn.Control(controller) + return protectSocket(conn, controller) }) } diff --git a/controller/controller_test.go b/controller/controller_test.go new file mode 100644 index 00000000..942c7167 --- /dev/null +++ b/controller/controller_test.go @@ -0,0 +1,56 @@ +package controller + +import ( + "errors" + "testing" +) + +type rawConnStub struct { + fd uintptr + controlErr error +} + +func (conn rawConnStub) Control(callback func(fd uintptr)) error { + if conn.controlErr != nil { + return conn.controlErr + } + callback(conn.fd) + return nil +} + +func (rawConnStub) Read(func(fd uintptr) (done bool)) error { + return nil +} + +func (rawConnStub) Write(func(fd uintptr) (done bool)) error { + return nil +} + +func TestProtectSocketPropagatesControllerFailure(t *testing.T) { + err := protectSocket(rawConnStub{fd: 42}, func(fd uintptr) bool { + return fd != 42 + }) + if !errors.Is(err, errProtectSocket) { + t.Fatalf("protectSocket() error = %v, want %v", err, errProtectSocket) + } +} + +func TestProtectSocketPropagatesRawConnFailure(t *testing.T) { + controlErr := errors.New("control failed") + err := protectSocket( + rawConnStub{controlErr: controlErr}, + func(uintptr) bool { return true }, + ) + if !errors.Is(err, controlErr) { + t.Fatalf("protectSocket() error = %v, want %v", err, controlErr) + } +} + +func TestProtectSocketSucceeds(t *testing.T) { + err := protectSocket(rawConnStub{fd: 42}, func(fd uintptr) bool { + return fd == 42 + }) + if err != nil { + t.Fatalf("protectSocket() error = %v", err) + } +} diff --git a/go.mod b/go.mod index 3a71b73e..ef69942a 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.26.3 require ( github.com/stretchr/testify v1.11.1 - github.com/xtls/xray-core v1.260327.1-0.20260711155151-50231eaff98c + github.com/xtls/xray-core v1.260327.1-0.20260728075948-5ca6f4b7d4dc google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v3 v3.0.1 ) @@ -53,7 +53,7 @@ require ( golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect golang.zx2c4.com/wireguard/windows v1.0.1 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect - google.golang.org/grpc v1.82.0 // indirect + google.golang.org/grpc v1.82.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect lukechampine.com/blake3 v1.4.1 // indirect diff --git a/go.sum b/go.sum index 962ff921..6c9ce215 100644 --- a/go.sum +++ b/go.sum @@ -74,8 +74,8 @@ github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f h1:iy2JRioxmUpoJ3SzbFPyTxHZMbR/rSHP7dOOgYaq1O8= github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f/go.mod h1:DsJblcWDGt76+FVqBVwbwRhxyyNJsGV48gJLch0OOWI= -github.com/xtls/xray-core v1.260327.1-0.20260711155151-50231eaff98c h1:SbB1ez0bqZllbzaVj0PC+Vje3dRA8m/7jW1ussjDSgM= -github.com/xtls/xray-core v1.260327.1-0.20260711155151-50231eaff98c/go.mod h1:Jts8yHqPCpvsdL5CW5xMd8H9d2fkg1cILeBNqEwRXNw= +github.com/xtls/xray-core v1.260327.1-0.20260728075948-5ca6f4b7d4dc h1:fkOkmgHWbF2Q8MdV9VxrsyxRz4OndcrUXUkh1ANBTg0= +github.com/xtls/xray-core v1.260327.1-0.20260728075948-5ca6f4b7d4dc/go.mod h1:wukQoBGnQ6GaLTGuKwv8rCTgf80QxPj+6iznDZHQEWo= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= @@ -98,8 +98,6 @@ golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJ golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= golang.org/x/mobile v0.0.0-20260709172247-6129f5bee9d5 h1:Mn1OzFmF0ZKX/ZayHz/UdnWHufPp1wlD9lZ5U8LRDFY= golang.org/x/mobile v0.0.0-20260709172247-6129f5bee9d5/go.mod h1:YX+n47s+53POxN3dx9cIGxG3hGUm/lD64hvrRJFbcSA= -golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= -golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= @@ -114,10 +112,12 @@ golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= -golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= -golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= +golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A= @@ -128,8 +128,8 @@ gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU= -google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= +google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/invoke.go b/invoke.go index a785a1da..9e1ec1a8 100644 --- a/invoke.go +++ b/invoke.go @@ -45,6 +45,8 @@ func Invoke(requestJSON string) string { return invokeCountGeoData(request.Payload) case LibXrayMethodPing: return invokePing(request.Payload) + case LibXrayMethodPingBatch: + return invokePingBatch(request.Payload) case LibXrayMethodTestXray: return invokeTestXray(request.Payload) case LibXrayMethodRunXray: @@ -178,6 +180,40 @@ func invokePing(payload json.RawMessage) string { return encodeInvokeResponse(&PingResponse{Delay: delay}, nil) } +func invokePingBatch(payload json.RawMessage) string { + request, err := decodePayload[PingBatchRequest](payload) + if err != nil { + return encodeInvokeResponse(nil, err) + } + + configs := make([]xray.PingBatchItem, len(request.Configs)) + for i, config := range request.Configs { + configs[i] = xray.PingBatchItem{ + ConfigPath: config.ConfigPath, + OutboundTag: config.OutboundTag, + } + } + + results, err := xray.PingBatch( + configs, + request.Timeout, + request.URL, + ) + if err != nil { + return encodeInvokeResponse(nil, err) + } + + responseResults := make([]PingBatchItemResponse, len(results)) + for i, result := range results { + responseResults[i] = PingBatchItemResponse{ + Success: result.Success, + Delay: result.Delay, + Error: result.Error, + } + } + return encodeInvokeResponse(&PingBatchResponse{Results: responseResults}, nil) +} + func invokeTestXray(payload json.RawMessage) string { request, err := decodePayload[RunXrayRequest](payload) if err != nil { diff --git a/invoke_model.go b/invoke_model.go index 66d2732d..a0cd3168 100644 --- a/invoke_model.go +++ b/invoke_model.go @@ -11,6 +11,7 @@ const ( LibXrayMethodConvertXrayJsonToShareLinks LibXrayMethod = "convertXrayJsonToShareLinks" LibXrayMethodCountGeoData LibXrayMethod = "countGeoData" LibXrayMethodPing LibXrayMethod = "ping" + LibXrayMethodPingBatch LibXrayMethod = "pingBatch" LibXrayMethodTestXray LibXrayMethod = "testXray" LibXrayMethodRunXray LibXrayMethod = "runXray" LibXrayMethodRunXrayFromJson LibXrayMethod = "runXrayFromJson" @@ -62,6 +63,27 @@ type PingResponse struct { Delay int64 `json:"delay,omitempty"` } +type PingBatchRequest struct { + Configs []PingBatchItemRequest `json:"configs,omitempty"` + Timeout int `json:"timeout,omitempty"` + URL string `json:"url,omitempty"` +} + +type PingBatchItemRequest struct { + ConfigPath string `json:"configPath,omitempty"` + OutboundTag string `json:"outboundTag,omitempty"` +} + +type PingBatchResponse struct { + Results []PingBatchItemResponse `json:"results,omitempty"` +} + +type PingBatchItemResponse struct { + Success bool `json:"success"` + Delay int64 `json:"delay,omitempty"` + Error string `json:"error,omitempty"` +} + type RunXrayRequest struct { ConfigPath string `json:"configPath,omitempty"` } diff --git a/invoke_test.go b/invoke_test.go index 1c94f0fc..edc35f24 100644 --- a/invoke_test.go +++ b/invoke_test.go @@ -371,6 +371,86 @@ func TestInvokePingReturnsDelaySentinelOnXrayError(t *testing.T) { } } +func TestInvokePingBatchReturnsPerItemFailures(t *testing.T) { + response := invokeForTest( + t, + LibXrayMethodPingBatch, + PingBatchRequest{ + Configs: []PingBatchItemRequest{ + { + ConfigPath: filepath.Join(t.TempDir(), "missing.json"), + }, + }, + Timeout: 1, + URL: "https://example.com", + }, + ) + if !response.Success { + t.Fatalf("PingBatch failed: %s", response.Err) + } + batch := decodeDataObject[PingBatchResponse](t, response) + if len(batch.Results) != 1 { + t.Fatalf("results = %d, want 1", len(batch.Results)) + } + result := batch.Results[0] + if result.Success { + t.Fatal("missing config unexpectedly succeeded") + } + if result.Delay != nodep.PingDelayError { + t.Fatalf("delay = %d, want %d", result.Delay, nodep.PingDelayError) + } + if result.Error == "" { + t.Fatal("missing config has no error") + } +} + +func TestInvokePingBatchRejectsInvalidRequest(t *testing.T) { + response := invokeForTest( + t, + LibXrayMethodPingBatch, + PingBatchRequest{ + Timeout: 1, + URL: "https://example.com", + }, + ) + if response.Success { + t.Fatal("PingBatch should reject an empty config list") + } + if !strings.Contains(response.Err, "configs are empty") { + t.Fatalf("error = %q", response.Err) + } + if got := string(response.Data); got != "null" { + t.Fatalf("data = %s, want null", got) + } +} + +func TestInvokePingBatchRejectsMoreThanFiveConfigs(t *testing.T) { + configs := make([]PingBatchItemRequest, 6) + for i := range configs { + configs[i] = PingBatchItemRequest{ + ConfigPath: "unused.json", + } + } + response := invokeForTest( + t, + LibXrayMethodPingBatch, + PingBatchRequest{ + Configs: configs, + Timeout: 1, + URL: "https://example.com", + }, + ) + if response.Success { + t.Fatal("PingBatch should reject more than five configs") + } + if !strings.Contains(response.Err, "more than 5 configs") { + t.Fatalf("error = %q", response.Err) + } + if got := string(response.Data); got != "null" { + t.Fatalf("data = %s, want null", got) + } +} + func TestInvokeCountGeoDataUsesPayloadDatDir(t *testing.T) { datDir := t.TempDir() writeGeoSiteDatForTest(t, filepath.Join(datDir, "geosite.dat")) diff --git a/memory/memory_ios.go b/memory/memory_ios.go index 5d6b5d67..3294e520 100644 --- a/memory/memory_ios.go +++ b/memory/memory_ios.go @@ -4,7 +4,7 @@ package memory import ( "runtime/debug" - + "sync" "time" ) @@ -14,6 +14,8 @@ const ( maxMemory = 30 * 1024 * 1024 ) +var initForceFreeOnce sync.Once + func forceFree(interval time.Duration) { go func() { for { @@ -24,8 +26,10 @@ func forceFree(interval time.Duration) { } func InitForceFree() { - debug.SetGCPercent(10) - debug.SetMemoryLimit(maxMemory) - duration := time.Duration(interval) * time.Second - forceFree(duration) + initForceFreeOnce.Do(func() { + debug.SetGCPercent(10) + debug.SetMemoryLimit(maxMemory) + duration := time.Duration(interval) * time.Second + forceFree(duration) + }) } diff --git a/nodep/measure.go b/nodep/measure.go index 648c9c16..112fde74 100644 --- a/nodep/measure.go +++ b/nodep/measure.go @@ -51,16 +51,19 @@ func CoreHTTPClient(timeout time.Duration, proxy string) (*http.Client, error) { func PingHTTPRequest(c *http.Client, url string, timeout int) (int64, error) { start := time.Now() - req, _ := http.NewRequest("HEAD", url, nil) - _, err := c.Do(req) + req, err := http.NewRequest("HEAD", url, nil) + if err != nil { + return PingDelayError, err + } + response, err := c.Do(req) delay := time.Since(start).Milliseconds() if err != nil { precision := delay - int64(timeout)*1000 if math.Abs(float64(precision)) < 50 { return PingDelayTimeout, err - } else { - return PingDelayError, err } + return PingDelayError, err } + response.Body.Close() return delay, nil } diff --git a/readme/README.zh_CN.md b/readme/README.zh_CN.md index bf826b45..d21bdb78 100644 --- a/readme/README.zh_CN.md +++ b/readme/README.zh_CN.md @@ -16,7 +16,7 @@ 依赖 git 和 go。 -默认情况下,编译脚本不会 clone [Xray-core](https://github.com/XTLS/Xray-core),而是通过 Go modules 的 pseudo-version 将 Xray-core 固定到发布版本 `v26.7.11`。 +默认情况下,编译脚本不会 clone [Xray-core](https://github.com/XTLS/Xray-core),而是通过 Go modules 的 pseudo-version 将 Xray-core 固定到发布版本 `v26.7.28`。 传入可选参数 `local` 时,会通过 Go module `replace` 改用已有的本地仓库 `../Xray-core`。 ### 使用方式 @@ -121,6 +121,7 @@ void CGoFree(char* value); 3. `countGeoData` 不依赖 Xray 配置,因此通过 method payload 的 `datDir` 传入数据目录。 4. 完整的 UTF-8 编码 Invoke 请求和响应 JSON 包体限制为 16 MiB。任一方向超过限制时,Invoke 将返回 `success: false`、`data: null` 和对应的大小限制错误。 5. `convertShareLinksToXrayJson` 会使用当前 Xray-core 配置构建器校验每个已解析的 outbound。无效 outbound 会被忽略;如果没有剩余的有效 outbound,该方法返回失败。校验不会创建或启动 Xray instance。 +6. Xray-core 的系统拨号 DNS client 和 outbound manager 属于进程级状态。当 `runXray` 或 `runXrayFromJson` 正在运行时,通过 `ping`、`pingBatch`、`testXray` 或导出的 Go API 创建另一个 Xray instance,可能覆盖这些状态并影响正在运行的 instance。关闭临时 instance 不会恢复之前的状态。libXray 不对并发 instance 进行串行化、隔离或状态恢复;调用方如需同时运行多个 instance,必须将它们放在不同进程中。 支持的 method: @@ -130,6 +131,7 @@ convertShareLinksToXrayJson convertXrayJsonToShareLinks countGeoData ping +pingBatch testXray runXray runXrayFromJson @@ -221,6 +223,41 @@ libXray 使用 `sendThrough` 来存储节点名称。 延迟测试。 +### pingBatch + +在一个临时 Xray instance 内并发测试多份 outbound 配置。每份配置文件只解析 +`outbounds`,其他根字段全部忽略。目标 outbound 依次按 `outboundTag`、`proxy` +tag、首个 outbound 选择。 + +```json +{ + "apiVersion": 1, + "method": "pingBatch", + "payload": { + "configs": [ + { + "configPath": "/path/to/node-1.json" + }, + { + "configPath": "/path/to/full-config.json", + "outboundTag": "media" + } + ], + "timeout": 5, + "url": "https://cp.cloudflare.com/" + } +} +``` + +每次请求最多接受 5 份配置,并发测试该请求中所有已接受的配置。超过 5 份配置的 +请求会在开始测试前直接失败。 + +批次请求本身被接受时,顶层 response 为成功;每个配置通过自己的结果表示成功或 +失败。`delay` 为 `10000` 表示错误,`11000` 表示超时。结果数组与输入配置数组 +长度相同且顺序一致。 +通过 `streamSettings.sockopt.dialerProxy` 或 `proxySettings.tag` 引用的 +outbound 依赖会被自动包含。 + ### metrics 统计。 diff --git a/share/generate_share.go b/share/generate_share.go index d695ab94..340654e0 100644 --- a/share/generate_share.go +++ b/share/generate_share.go @@ -1,6 +1,7 @@ package share import ( + "bytes" "encoding/base64" "encoding/json" "fmt" @@ -14,10 +15,8 @@ import ( // Convert XrayJson to share links. // VMess will generate VMessAEAD link. func ConvertXrayJsonToShareLinks(xrayBytes []byte) (string, error) { - var xray *conf.Config - - err := json.Unmarshal(xrayBytes, &xray) - if err != nil { + var xray conf.Config + if err := json.Unmarshal(xrayBytes, &xray); err != nil { return "", err } @@ -29,8 +28,12 @@ func ConvertXrayJsonToShareLinks(xrayBytes []byte) (string, error) { links := make([]string, 0, len(outbounds)) for _, outbound := range outbounds { link, err := shareLink(outbound) - if err == nil { - links = append(links, link.String()) + if err != nil || link == nil { + continue + } + text := link.String() + if text != "" { + links = append(links, text) } } if len(links) == 0 { @@ -74,15 +77,38 @@ func shareLink(proxy conf.OutboundDetourConfig) (*url.URL, error) { if err != nil { return nil, err } + default: + return nil, fmt.Errorf("unsupported outbound protocol %q", proxy.Protocol) } streamSettingsQuery(proxy, shareUrl) return shareUrl, nil } +func decodeOutboundSettings[T any]( + proxy conf.OutboundDetourConfig, +) (*T, error) { + if proxy.Settings == nil { + return nil, fmt.Errorf("missing %s outbound settings", proxy.Protocol) + } + raw := bytes.TrimSpace(*proxy.Settings) + if len(raw) == 0 || bytes.Equal(raw, []byte("null")) { + return nil, fmt.Errorf("missing %s outbound settings", proxy.Protocol) + } + + var settings T + if err := json.Unmarshal(raw, &settings); err != nil { + return nil, fmt.Errorf( + "invalid %s outbound settings: %w", + proxy.Protocol, + err, + ) + } + return &settings, nil +} + func shadowsocksLink(proxy conf.OutboundDetourConfig, link *url.URL) error { - var settings *conf.ShadowsocksClientConfig - err := json.Unmarshal(*proxy.Settings, &settings) + settings, err := decodeOutboundSettings[conf.ShadowsocksClientConfig](proxy) if err != nil { return err } @@ -91,16 +117,37 @@ func shadowsocksLink(proxy conf.OutboundDetourConfig, link *url.URL) error { link.Scheme = "ss" link.Host = fmt.Sprintf("%s:%d", settings.Address, settings.Port) - password := fmt.Sprintf("%s:%s", settings.Cipher, settings.Password) - username := base64.StdEncoding.EncodeToString([]byte(password)) - link.User = url.User(username) + if isShadowsocksAEAD2022(settings.Cipher) { + userInfo := escapeShadowsocksUserInfo(settings.Cipher) + ":" + + escapeShadowsocksUserInfo(settings.Password) + link.Opaque = "//" + userInfo + "@" + link.Host + link.Host = "" + } else { + password := fmt.Sprintf("%s:%s", settings.Cipher, settings.Password) + username := base64.StdEncoding.EncodeToString([]byte(password)) + link.User = url.User(username) + } return nil } +func isShadowsocksAEAD2022(cipher string) bool { + switch strings.ToLower(cipher) { + case "2022-blake3-aes-128-gcm", + "2022-blake3-aes-256-gcm", + "2022-blake3-chacha20-poly1305": + return true + default: + return false + } +} + +func escapeShadowsocksUserInfo(value string) string { + return strings.ReplaceAll(url.QueryEscape(value), "+", "%20") +} + func vmessLink(proxy conf.OutboundDetourConfig, link *url.URL) error { - var settings *conf.VMessOutboundConfig - err := json.Unmarshal(*proxy.Settings, &settings) + settings, err := decodeOutboundSettings[conf.VMessOutboundConfig](proxy) if err != nil { return err } @@ -118,8 +165,7 @@ func vmessLink(proxy conf.OutboundDetourConfig, link *url.URL) error { } func vlessLink(proxy conf.OutboundDetourConfig, link *url.URL) error { - var settings *conf.VLessOutboundConfig - err := json.Unmarshal(*proxy.Settings, &settings) + settings, err := decodeOutboundSettings[conf.VLessOutboundConfig](proxy) if err != nil { return err } @@ -140,8 +186,7 @@ func vlessLink(proxy conf.OutboundDetourConfig, link *url.URL) error { } func socksLink(proxy conf.OutboundDetourConfig, link *url.URL) error { - var settings *conf.SocksClientConfig - err := json.Unmarshal(*proxy.Settings, &settings) + settings, err := decodeOutboundSettings[conf.SocksClientConfig](proxy) if err != nil { return err } @@ -158,8 +203,7 @@ func socksLink(proxy conf.OutboundDetourConfig, link *url.URL) error { } func trojanLink(proxy conf.OutboundDetourConfig, link *url.URL) error { - var settings *conf.TrojanClientConfig - err := json.Unmarshal(*proxy.Settings, &settings) + settings, err := decodeOutboundSettings[conf.TrojanClientConfig](proxy) if err != nil { return err } @@ -174,8 +218,7 @@ func trojanLink(proxy conf.OutboundDetourConfig, link *url.URL) error { } func hysteriaLink(proxy conf.OutboundDetourConfig, link *url.URL) error { - var settings *conf.HysteriaClientConfig - err := json.Unmarshal(*proxy.Settings, &settings) + settings, err := decodeOutboundSettings[conf.HysteriaClientConfig](proxy) if err != nil { return err } @@ -257,7 +300,7 @@ func streamSettingsQuery(proxy conf.OutboundDetourConfig, link *url.URL) { if streamSettings.FinalMask != nil && len(streamSettings.FinalMask.Udp) > 0 { mask := streamSettings.FinalMask.Udp[0] if mask.Settings != nil { - var obfs *conf.Salamander + var obfs conf.Salamander err := json.Unmarshal(*mask.Settings, &obfs) if err == nil { query = addQuery(query, "obfs", "salamander") @@ -286,7 +329,7 @@ func streamSettingsQuery(proxy conf.OutboundDetourConfig, link *url.URL) { if headerConfig == nil { break } - var header *XrayRawSettingsHeader + var header XrayRawSettingsHeader err := json.Unmarshal(headerConfig, &header) if err != nil { break @@ -323,7 +366,7 @@ func streamSettingsQuery(proxy conf.OutboundDetourConfig, link *url.URL) { if headerConfig == nil { break } - var header *XrayFakeHeader + var header XrayFakeHeader err := json.Unmarshal(headerConfig, &header) if err != nil { break @@ -393,7 +436,7 @@ func streamSettingsQuery(proxy conf.OutboundDetourConfig, link *url.URL) { } extra := streamSettings.XHTTPSettings.Extra if extra != nil { - var extraConfig *conf.SplitHTTPConfig + var extraConfig conf.SplitHTTPConfig err := json.Unmarshal(extra, &extraConfig) if err == nil { extraBytes, err := json.Marshal(extraConfig) diff --git a/share/generate_share_test.go b/share/generate_share_test.go index 0435d8e2..0b9c844e 100644 --- a/share/generate_share_test.go +++ b/share/generate_share_test.go @@ -173,12 +173,75 @@ func TestConvertXrayJsonToShareLinks_RoundTripProtocols(t *testing.T) { } } +func TestGenerate_ShadowsocksAEAD2022PlainUserInfo(t *testing.T) { + const original = "ss://2022-blake3-aes-256-gcm:" + + "YctPZ6U7xPPcU%2Bgp3u%2B0tx%2FtRizJN9K8y%2BuKlW2qjlI%3D" + + "@192.168.100.1:8888#Example3" + + config, err := ConvertShareLinksToXrayJson(original) + require.NoError(t, err) + require.Len(t, config.OutboundConfigs, 1) + + generated, err := shareLink(config.OutboundConfigs[0]) + require.NoError(t, err) + assert.Equal(t, original, generated.String()) +} + +func TestGenerate_ShadowsocksLegacyBase64UserInfo(t *testing.T) { + original := "ss://" + ssUserB64("aes-128-gcm", "password") + + "@ss.example.com:8388#Legacy" + + config, err := ConvertShareLinksToXrayJson(original) + require.NoError(t, err) + require.Len(t, config.OutboundConfigs, 1) + + generated, err := shareLink(config.OutboundConfigs[0]) + require.NoError(t, err) + assert.Equal(t, original, generated.String()) +} + func TestConvertXrayJsonToShareLinks_Errors(t *testing.T) { _, err := ConvertXrayJsonToShareLinks([]byte(`{"outbounds":[]}`)) require.Error(t, err) _, err = ConvertXrayJsonToShareLinks([]byte(`{`)) require.Error(t, err) + + _, err = ConvertXrayJsonToShareLinks([]byte(`null`)) + require.Error(t, err) + + _, err = ConvertXrayJsonToShareLinks([]byte(`{ + "outbounds": [{"protocol": "shadowsocks"}] + }`)) + require.Error(t, err) + + _, err = ConvertXrayJsonToShareLinks([]byte(`{ + "outbounds": [{"protocol": "shadowsocks", "settings": null}] + }`)) + require.Error(t, err) + + _, err = ConvertXrayJsonToShareLinks([]byte(`{ + "outbounds": [{"protocol": "freedom", "tag": "direct"}] + }`)) + require.Error(t, err) +} + +func TestConvertXrayJsonToShareLinksSkipsUnsupportedOutbounds(t *testing.T) { + links, err := ConvertXrayJsonToShareLinks([]byte(`{ + "outbounds": [ + { + "protocol": "trojan", + "settings": { + "address": "example.com", + "port": 443, + "password": "password" + } + }, + {"protocol": "freedom", "tag": "direct"} + ] + }`)) + require.NoError(t, err) + assert.Equal(t, "trojan://password@example.com:443#trojan", links) } func TestConvertXrayJsonToShareLinks_PrefersTagWhenSendThroughEmpty(t *testing.T) { diff --git a/share/parse_share.go b/share/parse_share.go index f57e33b9..9cfcb005 100644 --- a/share/parse_share.go +++ b/share/parse_share.go @@ -202,17 +202,12 @@ func (proxy xrayShareLink) shadowsocksOutbound() (*conf.OutboundDetourConfig, er } settings.Port = uint16(port) - user := proxy.link.User.String() - passwordText, err := decodeBase64Text(user) + cipher, password, err := parseShadowsocksUserInfo(proxy.link.User) if err != nil { return nil, err } - pwConfig := strings.SplitN(passwordText, ":", 2) - if len(pwConfig) != 2 { - return nil, fmt.Errorf("unsupported shadowsocks link password: %s", passwordText) - } - settings.Cipher = pwConfig[0] - settings.Password = pwConfig[1] + settings.Cipher = cipher + settings.Password = password settingsRawMessage, err := convertJsonToRawMessage(settings) if err != nil { @@ -228,6 +223,32 @@ func (proxy xrayShareLink) shadowsocksOutbound() (*conf.OutboundDetourConfig, er return outbound, nil } +func parseShadowsocksUserInfo(user *url.Userinfo) (string, string, error) { + if user == nil { + return "", "", fmt.Errorf("missing shadowsocks user info") + } + + // SIP002 plain user info uses method:password. net/url decodes the + // percent-encoded method and password independently. + if password, ok := user.Password(); ok { + cipher := user.Username() + if cipher == "" { + return "", "", fmt.Errorf("missing shadowsocks cipher") + } + return cipher, password, nil + } + + decoded, err := decodeBase64Text(user.String()) + if err != nil { + return "", "", err + } + cipher, password, ok := strings.Cut(decoded, ":") + if !ok || cipher == "" { + return "", "", fmt.Errorf("unsupported shadowsocks user info") + } + return cipher, password, nil +} + func (proxy xrayShareLink) vmessOutbound() (*conf.OutboundDetourConfig, error) { text := strings.ReplaceAll(proxy.rawText, "vmess://", "") if base64Text, err := decodeBase64Text(text); err == nil { diff --git a/share/parse_share_test.go b/share/parse_share_test.go index c2984d88..f97e2584 100644 --- a/share/parse_share_test.go +++ b/share/parse_share_test.go @@ -241,6 +241,37 @@ func TestConvertShareLinksToXrayJson_Shadowsocks(t *testing.T) { assert.Equal(t, uint16(8388), s.Port) } +func TestConvertShareLinksToXrayJson_ShadowsocksPlainUserInfo(t *testing.T) { + const password = "YctPZ6U7xPPcU+gp3u+0tx/tRizJN9K8y+uKlW2qjlI=" + link := "ss://2022-blake3-aes-256-gcm:" + + url.QueryEscape(password) + + "@192.168.100.1:8888#Example3" + + cfg, err := ConvertShareLinksToXrayJson(link) + require.NoError(t, err) + require.Len(t, cfg.OutboundConfigs, 1) + + ob := cfg.OutboundConfigs[0] + assert.Equal(t, "shadowsocks", ob.Protocol) + var settings conf.ShadowsocksClientConfig + require.NoError(t, json.Unmarshal(*ob.Settings, &settings)) + assert.Equal(t, "2022-blake3-aes-256-gcm", settings.Cipher) + assert.Equal(t, password, settings.Password) + assert.Equal(t, uint16(8888), settings.Port) +} + +func TestParseShadowsocksUserInfo_PlainPercentEncoding(t *testing.T) { + link, err := url.Parse( + "ss://aes-128-gcm:p%40ss%3Aword%2Fwith%2Bsymbols@ss.example.com:8388", + ) + require.NoError(t, err) + + cipher, password, err := parseShadowsocksUserInfo(link.User) + require.NoError(t, err) + assert.Equal(t, "aes-128-gcm", cipher) + assert.Equal(t, "p@ss:word/with+symbols", password) +} + func TestConvertShareLinksToXrayJson_VlessWSAndTLS(t *testing.T) { link := "vless://" + testShareUUID + "@edge.example:443?encryption=none&type=ws&path=%2Fws&host=cdn.edge&security=tls&sni=edge.example&alpn=h2%2Ch3&fp=chrome&vcn=edge.example" cfg, err := ConvertShareLinksToXrayJson(link) diff --git a/xray/ping.go b/xray/ping.go index 0aaaae07..f667b80d 100644 --- a/xray/ping.go +++ b/xray/ping.go @@ -16,6 +16,7 @@ func Ping(configPath string, timeout int, url string, proxy string) (int64, erro } if err := server.Start(); err != nil { + _ = server.Close() return nodep.PingDelayError, err } defer server.Close() diff --git a/xray/ping_batch.go b/xray/ping_batch.go new file mode 100644 index 00000000..947498d7 --- /dev/null +++ b/xray/ping_batch.go @@ -0,0 +1,413 @@ +package xray + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os" + "sync" + "time" + + "github.com/xtls/libxray/nodep" + xrayNet "github.com/xtls/xray-core/common/net" + "github.com/xtls/xray-core/common/session" + "github.com/xtls/xray-core/core" + "github.com/xtls/xray-core/infra/conf" + confJSON "github.com/xtls/xray-core/infra/conf/json" +) + +const ( + maxPingBatchConfigs = 5 +) + +type PingBatchItem struct { + ConfigPath string + OutboundTag string +} + +type PingBatchResult struct { + Success bool + Delay int64 + Error string +} + +type pingOutboundConfig struct { + Outbounds []conf.OutboundDetourConfig `json:"outbounds"` +} + +type preparedPingItem struct { + resultIndex int + outboundTag string +} + +func PingBatch( + items []PingBatchItem, + timeout int, + targetURL string, +) ([]PingBatchResult, error) { + if err := validatePingBatchRequest(items, timeout, targetURL); err != nil { + return nil, err + } + + results := make([]PingBatchResult, len(items)) + prepared := make([]preparedPingItem, 0, len(items)) + mergedOutbounds := make([]conf.OutboundDetourConfig, 0, len(items)) + + for index, item := range items { + outbounds, err := readPingOutbounds(item.ConfigPath) + if err != nil { + results[index] = failedPingBatchResult(nodep.PingDelayError, err) + continue + } + + namespaced, outboundTag, err := preparePingOutbounds( + outbounds, + item.OutboundTag, + index, + ) + if err != nil { + results[index] = failedPingBatchResult(nodep.PingDelayError, err) + continue + } + prepared = append(prepared, preparedPingItem{ + resultIndex: index, + outboundTag: outboundTag, + }) + mergedOutbounds = append(mergedOutbounds, namespaced...) + } + + if len(prepared) == 0 { + return results, nil + } + + server, err := startPingBatchServer(mergedOutbounds) + if err != nil { + return nil, err + } + defer server.Close() + + workerCount := len(prepared) + jobs := make(chan preparedPingItem) + var workers sync.WaitGroup + workers.Add(workerCount) + for range workerCount { + go func() { + defer workers.Done() + for item := range jobs { + delay, err := measureOutboundDelay( + server, + item.outboundTag, + timeout, + targetURL, + ) + if err != nil { + results[item.resultIndex] = failedPingBatchResult( + delay, + err, + ) + continue + } + results[item.resultIndex] = PingBatchResult{ + Success: true, + Delay: delay, + } + } + }() + } + + for _, item := range prepared { + jobs <- item + } + close(jobs) + workers.Wait() + + return results, nil +} + +func validatePingBatchRequest( + items []PingBatchItem, + timeout int, + targetURL string, +) error { + if len(items) == 0 { + return errors.New("ping batch configs are empty") + } + if len(items) > maxPingBatchConfigs { + return fmt.Errorf( + "ping batch contains more than %d configs", + maxPingBatchConfigs, + ) + } + if timeout <= 0 { + return errors.New("ping batch timeout must be greater than zero") + } + + parsedURL, err := url.ParseRequestURI(targetURL) + if err != nil || parsedURL.Host == "" || + (parsedURL.Scheme != "http" && parsedURL.Scheme != "https") { + return errors.New("ping batch URL must be an absolute HTTP or HTTPS URL") + } + return nil +} + +func readPingOutbounds(path string) ([]conf.OutboundDetourConfig, error) { + if path == "" { + return nil, errors.New("ping config path is empty") + } + + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + + var config pingOutboundConfig + decoder := json.NewDecoder(&confJSON.Reader{Reader: file}) + if err := decoder.Decode(&config); err != nil { + return nil, err + } + if err := ensureJSONEOF(decoder); err != nil { + return nil, err + } + if len(config.Outbounds) == 0 { + return nil, errors.New("ping config has no outbounds") + } + return config.Outbounds, nil +} + +func ensureJSONEOF(decoder *json.Decoder) error { + var trailing any + if err := decoder.Decode(&trailing); err == nil { + return errors.New("ping config contains multiple JSON values") + } else if !errors.Is(err, io.EOF) { + return err + } + return nil +} + +func preparePingOutbounds( + outbounds []conf.OutboundDetourConfig, + requestedTag string, + itemIndex int, +) ([]conf.OutboundDetourConfig, string, error) { + tagIndexes := make(map[string]int, len(outbounds)) + duplicateTags := make(map[string]struct{}) + for index, outbound := range outbounds { + if outbound.Tag == "" { + continue + } + if _, found := tagIndexes[outbound.Tag]; found { + duplicateTags[outbound.Tag] = struct{}{} + continue + } + tagIndexes[outbound.Tag] = index + } + + targetIndex := 0 + if requestedTag != "" { + if _, duplicate := duplicateTags[requestedTag]; duplicate { + return nil, "", fmt.Errorf("duplicate outbound tag %q", requestedTag) + } + var found bool + targetIndex, found = tagIndexes[requestedTag] + if !found { + return nil, "", fmt.Errorf("outbound tag %q not found", requestedTag) + } + } else if proxyIndex, found := tagIndexes["proxy"]; found { + if _, duplicate := duplicateTags["proxy"]; duplicate { + return nil, "", errors.New(`duplicate outbound tag "proxy"`) + } + targetIndex = proxyIndex + } + + selectedIndexes, err := collectPingOutboundDependencies( + outbounds, + tagIndexes, + duplicateTags, + targetIndex, + ) + if err != nil { + return nil, "", err + } + + namespacedTags := make(map[int]string, len(selectedIndexes)) + for order, index := range selectedIndexes { + namespacedTags[index] = fmt.Sprintf( + "__libxray_ping_%d_%d", + itemIndex, + order, + ) + } + + prepared := make([]conf.OutboundDetourConfig, 0, len(selectedIndexes)) + for _, index := range selectedIndexes { + outbound := outbounds[index] + outbound.Tag = namespacedTags[index] + + if outbound.StreamSetting != nil && + outbound.StreamSetting.SocketSettings != nil { + dialerProxy := outbound.StreamSetting.SocketSettings.DialerProxy + if dialerProxy != "" { + dependencyIndex := tagIndexes[dialerProxy] + outbound.StreamSetting.SocketSettings.DialerProxy = + namespacedTags[dependencyIndex] + } + } + if outbound.ProxySettings != nil && outbound.ProxySettings.Tag != "" { + dependencyIndex := tagIndexes[outbound.ProxySettings.Tag] + outbound.ProxySettings.Tag = namespacedTags[dependencyIndex] + } + + if _, err := outbound.Build(); err != nil { + return nil, "", fmt.Errorf( + "failed to build outbound %q: %w", + outbounds[index].Tag, + err, + ) + } + prepared = append(prepared, outbound) + } + return prepared, namespacedTags[targetIndex], nil +} + +func collectPingOutboundDependencies( + outbounds []conf.OutboundDetourConfig, + tagIndexes map[string]int, + duplicateTags map[string]struct{}, + targetIndex int, +) ([]int, error) { + const ( + unvisited = iota + visiting + visited + ) + states := make([]int, len(outbounds)) + selected := make([]int, 0, len(outbounds)) + + var visit func(int) error + visit = func(index int) error { + switch states[index] { + case visiting: + return fmt.Errorf( + "outbound dependency cycle detected at tag %q", + outbounds[index].Tag, + ) + case visited: + return nil + } + states[index] = visiting + selected = append(selected, index) + + for _, dependencyTag := range pingOutboundDependencyTags(outbounds[index]) { + if _, duplicate := duplicateTags[dependencyTag]; duplicate { + return fmt.Errorf( + "outbound %q depends on duplicate outbound tag %q", + outbounds[index].Tag, + dependencyTag, + ) + } + dependencyIndex, found := tagIndexes[dependencyTag] + if !found { + return fmt.Errorf( + "outbound %q depends on missing outbound %q", + outbounds[index].Tag, + dependencyTag, + ) + } + if err := visit(dependencyIndex); err != nil { + return err + } + } + states[index] = visited + return nil + } + + if err := visit(targetIndex); err != nil { + return nil, err + } + return selected, nil +} + +func pingOutboundDependencyTags(outbound conf.OutboundDetourConfig) []string { + dependencies := make([]string, 0, 2) + if outbound.StreamSetting != nil && + outbound.StreamSetting.SocketSettings != nil && + outbound.StreamSetting.SocketSettings.DialerProxy != "" { + dependencies = append( + dependencies, + outbound.StreamSetting.SocketSettings.DialerProxy, + ) + } + if outbound.ProxySettings != nil && outbound.ProxySettings.Tag != "" { + dependencies = append(dependencies, outbound.ProxySettings.Tag) + } + return dependencies +} + +func startPingBatchServer( + outbounds []conf.OutboundDetourConfig, +) (*core.Instance, error) { + config, err := (&conf.Config{OutboundConfigs: outbounds}).Build() + if err != nil { + return nil, fmt.Errorf("failed to build ping batch config: %w", err) + } + server, err := core.New(config) + if err != nil { + return nil, fmt.Errorf("failed to create ping batch instance: %w", err) + } + if err := server.Start(); err != nil { + _ = server.Close() + return nil, fmt.Errorf("failed to start ping batch instance: %w", err) + } + return server, nil +} + +func measureOutboundDelay( + server *core.Instance, + outboundTag string, + timeout int, + targetURL string, +) (int64, error) { + httpTimeout := time.Second * time.Duration(timeout) + transport := &http.Transport{ + DisableKeepAlives: true, + DialContext: func( + ctx context.Context, + network string, + address string, + ) (net.Conn, error) { + if network != "tcp" && network != "tcp4" && network != "tcp6" { + return nil, fmt.Errorf("unsupported ping network %q", network) + } + destination, err := xrayNet.ParseDestination("tcp:" + address) + if err != nil { + return nil, err + } + ctx = session.SetForcedOutboundTagToContext(ctx, outboundTag) + return core.Dial(ctx, server, destination) + }, + } + defer transport.CloseIdleConnections() + + client := &http.Client{ + Transport: transport, + Timeout: httpTimeout, + } + return nodep.PingHTTPRequest(client, targetURL, timeout) +} + +func failedPingBatchResult(delay int64, err error) PingBatchResult { + if delay != nodep.PingDelayTimeout { + delay = nodep.PingDelayError + } + return PingBatchResult{ + Success: false, + Delay: delay, + Error: err.Error(), + } +} diff --git a/xray/ping_batch_test.go b/xray/ping_batch_test.go new file mode 100644 index 00000000..933ae8f9 --- /dev/null +++ b/xray/ping_batch_test.go @@ -0,0 +1,363 @@ +package xray + +import ( + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/xtls/xray-core/infra/conf" +) + +func writePingBatchConfig(t *testing.T, name string, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), name) + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + return path +} + +func TestReadPingOutboundsIgnoresOtherRootFields(t *testing.T) { + path := writePingBatchConfig(t, "config.json", `{ + // These fields are deliberately invalid for the full Xray schema. + "inbounds": "ignored", + "routing": 42, + "dns": false, + "outbounds": [ + {"protocol": "freedom", "tag": "proxy"} + ] + }`) + + outbounds, err := readPingOutbounds(path) + if err != nil { + t.Fatal(err) + } + if len(outbounds) != 1 { + t.Fatalf("outbounds = %d, want 1", len(outbounds)) + } + if outbounds[0].Tag != "proxy" { + t.Fatalf("tag = %q, want proxy", outbounds[0].Tag) + } +} + +func TestPreparePingOutboundsPreservesRealityClientConfig(t *testing.T) { + path := writePingBatchConfig(t, "reality.json", `{ + "outbounds": [{ + "tag": "proxy", + "protocol": "vless", + "settings": { + "address": "example.com", + "port": 443, + "id": "41b20a0c-b56f-4bb8-97e1-e5ceb0ab1ba4", + "encryption": "none" + }, + "streamSettings": { + "network": "xhttp", + "xhttpSettings": { + "host": "example.com", + "path": "/xhttp", + "mode": "auto" + }, + "security": "reality", + "realitySettings": { + "fingerprint": "chrome", + "serverName": "example.com", + "password": "itSUyWpOsw4n3_Rc-hK0r_ryzTBGRqHdrPJI-OFuAwM" + } + } + }] + }`) + + outbounds, err := readPingOutbounds(path) + if err != nil { + t.Fatal(err) + } + prepared, targetTag, err := preparePingOutbounds(outbounds, "proxy", 0) + if err != nil { + t.Fatal(err) + } + if len(prepared) != 1 { + t.Fatalf("outbounds = %d, want 1", len(prepared)) + } + if targetTag != "__libxray_ping_0_0" { + t.Fatalf("target tag = %q", targetTag) + } +} + +func TestPreparePingOutboundsSelectsAndRewritesDependencies(t *testing.T) { + outbounds := []conf.OutboundDetourConfig{ + { + Protocol: "blackhole", + Tag: "unused", + }, + { + Protocol: "not-a-protocol", + Tag: "unused", + }, + { + Protocol: "freedom", + Tag: "proxy", + StreamSetting: &conf.StreamConfig{ + SocketSettings: &conf.SocketConfig{DialerProxy: "fragment"}, + }, + }, + { + Protocol: "freedom", + Tag: "fragment", + }, + } + + prepared, targetTag, err := preparePingOutbounds(outbounds, "", 7) + if err != nil { + t.Fatal(err) + } + if len(prepared) != 2 { + t.Fatalf("prepared outbounds = %d, want 2", len(prepared)) + } + if targetTag != "__libxray_ping_7_0" { + t.Fatalf("target tag = %q", targetTag) + } + if prepared[0].Tag != targetTag { + t.Fatalf("root tag = %q, want %q", prepared[0].Tag, targetTag) + } + if prepared[1].Tag != "__libxray_ping_7_1" { + t.Fatalf("dependency tag = %q", prepared[1].Tag) + } + if got := prepared[0].StreamSetting.SocketSettings.DialerProxy; got != prepared[1].Tag { + t.Fatalf("dialerProxy = %q, want %q", got, prepared[1].Tag) + } + for _, outbound := range prepared { + if outbound.Tag == "unused" { + t.Fatal("unrelated outbound was included") + } + } +} + +func TestPreparePingOutboundsRewritesProxySettings(t *testing.T) { + outbounds := []conf.OutboundDetourConfig{ + { + Protocol: "freedom", + Tag: "proxy", + ProxySettings: &conf.ProxyConfig{Tag: "transport"}, + }, + { + Protocol: "freedom", + Tag: "transport", + }, + } + + prepared, _, err := preparePingOutbounds(outbounds, "proxy", 1) + if err != nil { + t.Fatal(err) + } + if got, want := prepared[0].ProxySettings.Tag, prepared[1].Tag; got != want { + t.Fatalf("proxySettings.tag = %q, want %q", got, want) + } +} + +func TestPreparePingOutboundsRejectsDependencyCycle(t *testing.T) { + outbounds := []conf.OutboundDetourConfig{ + { + Protocol: "freedom", + Tag: "proxy", + StreamSetting: &conf.StreamConfig{ + SocketSettings: &conf.SocketConfig{DialerProxy: "next"}, + }, + }, + { + Protocol: "freedom", + Tag: "next", + StreamSetting: &conf.StreamConfig{ + SocketSettings: &conf.SocketConfig{DialerProxy: "proxy"}, + }, + }, + } + + _, _, err := preparePingOutbounds(outbounds, "", 0) + if err == nil || !strings.Contains(err.Error(), "cycle") { + t.Fatalf("error = %v, want dependency cycle", err) + } +} + +func TestPingBatchRunsRequestsConcurrently(t *testing.T) { + requestStarted := make(chan struct{}, 2) + releaseRequests := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func( + response http.ResponseWriter, + request *http.Request, + ) { + requestStarted <- struct{}{} + <-releaseRequests + response.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + config := `{"outbounds":[{"protocol":"freedom","tag":"proxy"}]}` + firstPath := writePingBatchConfig(t, "first.json", config) + secondPath := writePingBatchConfig(t, "second.json", config) + + type batchResult struct { + results []PingBatchResult + err error + } + done := make(chan batchResult, 1) + go func() { + results, err := PingBatch( + []PingBatchItem{ + {ConfigPath: firstPath}, + {ConfigPath: secondPath}, + }, + 2, + server.URL, + ) + done <- batchResult{results: results, err: err} + }() + + for range 2 { + select { + case <-requestStarted: + case <-time.After(2 * time.Second): + t.Fatal("requests did not run concurrently") + } + } + close(releaseRequests) + + result := <-done + if result.err != nil { + t.Fatal(result.err) + } + if len(result.results) != 2 { + t.Fatalf("results = %d, want 2", len(result.results)) + } + for index := range result.results { + if !result.results[index].Success { + t.Fatalf( + "result %d failed: %s", + index, + result.results[index].Error, + ) + } + } +} + +func TestPingBatchKeepsPerItemConfigErrorsInInputOrder(t *testing.T) { + results, err := PingBatch( + []PingBatchItem{ + {ConfigPath: filepath.Join(t.TempDir(), "missing.json")}, + { + ConfigPath: writePingBatchConfig( + t, + "invalid.json", + `{"outbounds":[]}`, + ), + }, + }, + 1, + "https://example.com", + ) + if err != nil { + t.Fatal(err) + } + if len(results) != 2 { + t.Fatalf("results = %d, want 2", len(results)) + } + for index, result := range results { + if result.Success { + t.Fatalf("result %d unexpectedly succeeded", index) + } + if result.Delay != 10000 { + t.Fatalf("result %d delay = %d, want 10000", index, result.Delay) + } + if result.Error == "" { + t.Fatalf("result %d has no error", index) + } + } + if !strings.Contains(results[0].Error, "missing.json") { + t.Fatalf("first result error = %q, want missing config error", results[0].Error) + } + if results[1].Error != "ping config has no outbounds" { + t.Fatalf("second result error = %q, want empty outbounds error", results[1].Error) + } +} + +func TestValidatePingBatchRequest(t *testing.T) { + tests := []struct { + name string + items []PingBatchItem + timeout int + targetURL string + errorText string + }{ + { + name: "empty configs", + timeout: 1, + targetURL: "https://example.com", + errorText: "configs are empty", + }, + { + name: "invalid URL", + items: []PingBatchItem{{}}, + timeout: 1, + targetURL: "example.com", + errorText: "absolute HTTP", + }, + { + name: "too many configs", + items: []PingBatchItem{ + {}, + {}, + {}, + {}, + {}, + {}, + }, + timeout: 1, + targetURL: "https://example.com", + errorText: "more than 5 configs", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := validatePingBatchRequest( + test.items, + test.timeout, + test.targetURL, + ) + if err == nil || !strings.Contains(err.Error(), test.errorText) { + t.Fatalf("error = %v, want %q", err, test.errorText) + } + }) + } +} + +func TestValidatePingBatchRequestAcceptsFiveConfigs(t *testing.T) { + err := validatePingBatchRequest( + []PingBatchItem{ + {}, + {}, + {}, + {}, + {}, + }, + 1, + "https://example.com", + ) + if err != nil { + t.Fatal(err) + } +} + +func ExamplePingBatch() { + results, _ := PingBatch( + []PingBatchItem{{ConfigPath: "config.json"}}, + 5, + "https://cp.cloudflare.com/", + ) + fmt.Println(len(results)) +} From 80263da83e96b2972455b0a94b13ee1a10e51391 Mon Sep 17 00:00:00 2001 From: yiguodev <147401898+yiguodev@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:53:46 +0800 Subject: [PATCH 10/38] Fix Windows ARM64 build toolchain (#142) --- .github/workflows/build.yml | 56 +++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a887d5be..fb4ec44f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -84,6 +84,62 @@ jobs: - name: Download Go modules run: go mod download + # ========================= + # Windows ARM64 toolchain + # ========================= + - name: Install LLVM-MinGW for Windows ARM64 + if: matrix.target == 'windows' && matrix.arch == 'arm64' + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + run: | + $headers = @{ + Accept = "application/vnd.github+json" + Authorization = "Bearer $env:GH_TOKEN" + "X-GitHub-Api-Version" = "2022-11-28" + } + $release = Invoke-RestMethod ` + -Headers $headers ` + -Uri "https://api.github.com/repos/mstorsjo/llvm-mingw/releases/latest" + $asset = $release.assets | + Where-Object { $_.name -match "^llvm-mingw-.+-ucrt-aarch64\.zip$" } | + Select-Object -First 1 + if ($null -eq $asset) { + throw "The latest LLVM-MinGW release has no ARM64 UCRT archive" + } + + $archivePath = Join-Path $env:RUNNER_TEMP $asset.name + $extractPath = Join-Path $env:RUNNER_TEMP "llvm-mingw" + Write-Host "Downloading LLVM-MinGW $($release.tag_name): $($asset.name)" + Invoke-WebRequest -Uri $asset.browser_download_url -OutFile $archivePath + Expand-Archive -LiteralPath $archivePath -DestinationPath $extractPath + + $binPath = Get-ChildItem -LiteralPath $extractPath -Directory | + ForEach-Object { Join-Path $_.FullName "bin" } | + Where-Object { Test-Path (Join-Path $_ "gcc.exe") } | + Select-Object -First 1 + if ($null -eq $binPath) { + throw "LLVM-MinGW bin directory was not found in $extractPath" + } + $binPath | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + + - name: Verify Windows ARM64 CGo toolchain + if: matrix.target == 'windows' && matrix.arch == 'arm64' + shell: pwsh + run: | + where.exe gcc.exe + where.exe g++.exe + + $gccTarget = (& gcc.exe -dumpmachine).Trim() + $gxxTarget = (& g++.exe -dumpmachine).Trim() + $expectedTarget = '^aarch64-w64-(windows-gnu|mingw32)$' + if ($gccTarget -notmatch $expectedTarget) { + throw "Unexpected gcc target: $gccTarget" + } + if ($gxxTarget -notmatch $expectedTarget) { + throw "Unexpected g++ target: $gxxTarget" + } + # ========================= # Apple toolchain # ========================= From d0ab60ae4dd91cf119c878152d12103e6f84b78a Mon Sep 17 00:00:00 2001 From: yiguodev <147401898+yiguodev@users.noreply.github.com> Date: Thu, 6 Aug 2026 20:03:24 +0800 Subject: [PATCH 11/38] Add age-encrypted subscriptions and JSON-based Xray APIs (#143) * Refactor Xray JSON invoke APIs * Add age-encrypted subscription support * Document hybrid age key generation --- .github/workflows/build.yml | 1 - AGENTS.md | 212 ++++++++++++++++++++++++++++++++++++ README.md | 109 ++++++++++++++---- download_geo/main.go | 2 +- go.mod | 4 + go.sum | 8 ++ invoke.go | 61 +++++------ invoke_model.go | 47 +++++--- invoke_test.go | 204 ++++++++++++++++++++++++---------- readme/README.zh_CN.md | 97 +++++++++++++---- share/age.go | 122 +++++++++++++++++++++ share/age_test.go | 179 ++++++++++++++++++++++++++++++ xray/ping.go | 30 ----- xray/ping_batch.go | 20 ++-- xray/ping_batch_test.go | 46 +++----- xray/validation.go | 6 +- xray/xray.go | 46 +------- xray/xray_test.go | 18 +-- 18 files changed, 931 insertions(+), 281 deletions(-) create mode 100644 AGENTS.md create mode 100644 share/age.go create mode 100644 share/age_test.go delete mode 100644 xray/ping.go diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index fb4ec44f..49c04183 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -241,7 +241,6 @@ jobs: cp -R LibXray.xcframework "dist/${{ matrix.artifact }}/" ;; esac - # ========================= # Upload artifacts # ========================= diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..a996452a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,212 @@ +# Project Overview + +libXray is a Go wrapper around Xray-core for mobile and desktop applications. +It exposes one structured JSON entrypoint, provides share-link and GeoData +utilities, and builds native artifacts for Android, Apple platforms, Linux, and +Windows. + +The public Invoke contract is intentionally small. Platform applications should +construct typed request models, serialize them at the native boundary, and call +`Invoke` or `CGoInvoke`. Do not add platform-specific application behavior to +the generic API. + +# Repository Layout + +| Path | Purpose | +| --- | --- | +| `invoke.go` | Invoke request validation, method dispatch, and response encoding. | +| `invoke_model.go` | Public method enum and typed request/response models. | +| `xray/` | Xray instance lifecycle, configuration validation, and batch latency testing. | +| `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. | +| `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. | +| `android_wrapper.go` | Android-only gomobile interfaces and controller registration. | +| `build/` | Cross-platform build scripts and artifact assembly. | +| `.github/workflows/` | CI builds and release artifact publication. | +| `README.md` | English integration documentation. | +| `readme/README.zh_CN.md` | Chinese integration documentation. | + +# Invoke API Contract + +The current API version is `2`. Requests using an omitted or different +`apiVersion` are rejected. + +```json +{ + "apiVersion": 2, + "method": "runXray", + "payload": { + "xrayJson": "{\"outbounds\":[...]}" + } +} +``` + +Every response uses the same envelope: + +```json +{ + "success": true, + "data": {}, + "error": "" +} +``` + +`data` must be a JSON object for successful methods that return data, `{}` for +successful methods without data, or `null` for failures without structured +failure data. Do not return scalar values directly from `data`. + +Supported methods: + +- `getFreePorts` +- `convertShareLinksToXrayJson` +- `convertXrayJsonToShareLinks` +- `generateAgeKeyPair` +- `countGeoData` +- `pingBatch` +- `testXray` +- `runXray` +- `stopXray` +- `xrayVersion` +- `getXrayState` + +Age-encrypted subscription support is part of the share boundary. libXray owns +native key generation, in-memory armor decryption, and parsing. Integrating +applications own HTTP headers, persistence of both generated keys, and refresh +behavior. Never log age secret keys, decrypted subscription text, or complete +Invoke requests containing those values. + +`pingBatch`, `testXray`, and `runXray` receive serialized Xray configuration +text through `xrayJson`. They must not accept or read an application-provided +configuration file path. `countGeoData` is the exception because it operates on +GeoData files directly and receives `datDir` in its payload. + +The complete UTF-8 Invoke request and response envelopes are limited to 16 MiB. +`pingBatch` accepts at most five configurations. It parses only `outbounds`, +ignores other root fields, and includes outbound dependencies referenced by +`streamSettings.sockopt.dialerProxy` or `proxySettings.tag`. + +# Runtime Semantics + +`runXray` manages one package-level Xray instance. A second `runXray` call fails +until `stopXray` closes the current instance. + +`testXray` and `pingBatch` create temporary Xray instances. Xray-core contains +process-wide state, including the system dialer DNS client and outbound manager. +Running temporary instances while another Xray instance is active may replace +that state, and closing a temporary instance does not restore it. libXray does +not serialize or isolate these calls. Integrators that require independent +concurrent instances must place them in separate processes. + +Xray runtime environment values belong in the root `env` object of `xrayJson`. +A top-level `env` field on the Invoke request is ignored. Missing root env fields +are governed by Xray-core behavior. + +# Platform Integration + +## C ABI + +`cgo_bridge/main.go` exports: + +```c +char* CGoInvoke(char* requestJSON); +void CGoFree(char* value); +``` + +`CGoInvoke` returns C-allocated memory. Every non-null response must be released +exactly once with `CGoFree`. Do not release it with a platform allocator or from +Go directly. + +## Android + +Android uses gomobile and produces `libXray.aar` plus +`libXray-sources.jar`. Android-only APIs include socket protection, process +lookup registration, `SetDNS`, and `ResetDNS`. + +`SetDNS` changes Go's process-wide resolver and requires a protected IP endpoint +such as `8.8.8.8:53`. Call `ResetDNS` after the managed Xray instance stops. +Keep Android-only code behind the `android` build tag. + +## Apple Platforms + +The CGo build produces `LibXray.xcframework` for iOS, iOS Simulator, macOS, +tvOS, and tvOS Simulator. Swift callers use `CGoInvoke` and `CGoFree`; the Xray +configuration and runtime TUN fd are supplied by the application through the +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. + +# Building + +Build scripts use the Xray-core version pinned by `go.mod` by default: + +```shell +python3 build/main.py android +python3 build/main.py apple go +python3 build/main.py linux +python build/main.py windows +``` + +Apple also has a gomobile build path: + +```shell +python3 build/main.py apple gomobile +``` + +To test an adjacent Xray-core checkout, place it at `../Xray-core` and append +`local`: + +```shell +python3 build/main.py android local +python3 build/main.py apple go local +``` + +The build scripts temporarily adjust the Go module graph and restore `go.mod` +and `go.sum` when the build finishes. Generated native artifacts, downloaded +GeoData, and intermediate build directories are ignored by Git. Do not edit +generated headers, archives, frameworks, AARs, JARs, DLLs, or shared libraries +manually. + +# Development Rules + +1. Keep `Invoke` as the single cross-platform API entrypoint. Platform-only + controller APIs must remain isolated by build tags. +2. Define request and response fields as typed Go models in `invoke_model.go`. + Do not pass unstructured maps into package business logic. +3. Treat method names, JSON keys, response shapes, and `apiVersion` as a public + wire contract. Breaking changes require an API version increment and + synchronized integration documentation. +4. Xray configuration APIs accept `xrayJson` text, not file paths. File access + remains limited to APIs whose purpose is operating on files. +5. Keep the English and Chinese README API sections synchronized. +6. Preserve per-item ordering in `pingBatch`; one invalid configuration should + produce an item failure without discarding other accepted items. +7. Close every temporary Xray instance on success and error paths. Do not add + hidden serialization or state restoration that changes existing runtime + semantics. +8. Do not modify the adjacent Xray-core checkout as part of a libXray change + unless the task explicitly requires it. +9. Use `gofmt` for Go source and keep changes narrowly scoped to the owning + package. + +# Validation + +Run the checks appropriate to the change scope: + +```shell +gofmt -w +go test ./... -count=1 +git diff --check +``` + +Changes to build scripts or platform bridges should also build the affected +artifact. Changes to the Invoke wire contract must include dispatch/model tests, +unknown or removed method tests where relevant, response-shape tests, and +synchronized consumer model updates in downstream applications. diff --git a/README.md b/README.md index 8b57b6d2..29b38fb1 100644 --- a/README.md +++ b/README.md @@ -128,10 +128,10 @@ The request is a JSON object: ```json { - "apiVersion": 1, + "apiVersion": 2, "method": "runXray", "payload": { - "configPath": "/path/to/config.json" + "xrayJson": "{\"outbounds\":[...]}" } } ``` @@ -148,23 +148,28 @@ The response is a JSON object: Design notes: -1. A top-level `env` field is ignored and has no effect. Xray-core runtime +1. Invoke currently accepts only `apiVersion: 2`. Xray configurations are + passed as UTF-8 JSON text in `xrayJson`; libXray does not read configuration + file paths. +2. A top-level `env` field is ignored and has no effect. Xray-core runtime environment options belong in the root `env` object of the Xray config. -2. `SetTunFd` has been removed. When the fd is only known at runtime, write +3. `SetTunFd` has been removed. When the fd is only known at runtime, write `xray.tun.fd` into the Xray config root `env` object before calling `runXray`. -3. `countGeoData` is not backed by an Xray config, so its `datDir` is passed in +4. `countGeoData` is not backed by an Xray config, so its `datDir` is passed in the method payload. -4. The complete UTF-8 encoded Invoke request and response JSON envelopes are +5. The complete UTF-8 encoded Invoke request and response JSON envelopes are limited to 16 MiB. If either limit is exceeded, Invoke returns a failure response with `success: false`, `data: null`, and a size-limit error. -5. `convertShareLinksToXrayJson` validates each parsed outbound with the current +6. `convertShareLinksToXrayJson` validates each parsed outbound with the current Xray-core config builder. Invalid outbounds are omitted, and the method fails if none remain. Validation does not create or start an Xray instance. -6. Xray-core keeps its system dialer DNS client and outbound manager in - process-wide state. Creating another Xray instance through `ping`, - `pingBatch`, `testXray`, or the exported Go APIs while `runXray` or - `runXrayFromJson` is active may replace that state and affect the running + Its optional `age.secretKey` decrypts official age ASCII armor in memory + before the existing parser runs. Plaintext input remains unchanged. +7. Xray-core keeps its system dialer DNS client and outbound manager in + process-wide state. Creating another Xray instance through `pingBatch`, + `testXray`, or the exported Go APIs while `runXray` is active may replace + that state and affect the running instance. Closing the temporary instance does not restore the previous state. libXray does not serialize, isolate, or restore concurrent instances; callers that require overlapping instances must place them in separate @@ -176,12 +181,11 @@ Supported methods: getFreePorts convertShareLinksToXrayJson convertXrayJsonToShareLinks +generateAgeKeyPair countGeoData -ping pingBatch testXray runXray -runXrayFromJson stopXray xrayVersion getXrayState @@ -277,6 +281,47 @@ convert VMessAEAD/VLESS sharing protocol to Xray Json. convert VMessQRCode to Xray Json. +### age-encrypted subscriptions + +`convertShareLinksToXrayJson` accepts an optional native age secret key. Only +X25519 (`AGE-SECRET-KEY-1...`) and ML-KEM-768 + X25519 hybrid +(`AGE-SECRET-KEY-PQ-1...`) identities are accepted. Recognized age armor is +decrypted in memory and limited to 16 MiB of plaintext. + +```json +{ + "apiVersion": 2, + "method": "convertShareLinksToXrayJson", + "payload": { + "text": "-----BEGIN AGE ENCRYPTED FILE-----\n...", + "age": { + "secretKey": "AGE-SECRET-KEY-1..." + } + } +} +``` + +Generate a new keypair with `keyType` set to `x25519` or `hybrid`. An omitted +`keyType` defaults to `x25519`. The `hybrid` option matches Mihomo +`age keygen-pq` and produces an `AGE-SECRET-KEY-PQ-1...` identity with an +`age1pq1...` recipient. + +```json +{ + "apiVersion": 2, + "method": "generateAgeKeyPair", + "payload": { + "keyType": "x25519" + } +} +``` + +The response contains both `secretKey` and `publicKey`. The integrating +application must persist the pair and send only `publicKey` as +`X-Age-Public-Key`. libXray does not perform the subscription HTTP request, +persist keys, or add headers. Applications must never send the secret key over +HTTP or write decrypted subscription text to disk. + ### vmess convert VMessQRCode to Xray Json. @@ -287,28 +332,24 @@ Some tools used to parse shared links. ## xray -### ping - -Latency testing. - ### pingBatch Tests multiple outbound configurations concurrently in one temporary Xray -instance. Each config file is parsed only for its `outbounds`; all other root -fields are ignored. The target outbound is selected by `outboundTag`, then by -the `proxy` tag, and finally by the first outbound. +instance. Each `xrayJson` string is parsed only for its `outbounds`; all other +root fields are ignored. The target outbound is selected by `outboundTag`, then +by the `proxy` tag, and finally by the first outbound. ```json { - "apiVersion": 1, + "apiVersion": 2, "method": "pingBatch", "payload": { "configs": [ { - "configPath": "/path/to/node-1.json" + "xrayJson": "{\"outbounds\":[...]}" }, { - "configPath": "/path/to/full-config.json", + "xrayJson": "{\"outbounds\":[...]}", "outboundTag": "media" } ], @@ -329,6 +370,26 @@ Outbound dependencies referenced by `streamSettings.sockopt.dialerProxy` or `proxySettings.tag` are included automatically. +### testXray + +Validates an Xray configuration from the supplied JSON text without reading a +configuration file: + +```json +{ + "apiVersion": 2, + "method": "testXray", + "payload": { + "xrayJson": "{\"outbounds\":[...]}" + } +} +``` + +### runXray + +Starts the managed Xray instance from the supplied JSON text. Use `stopXray` +to stop that instance. `runXrayFromJson` is no longer a separate method. + ### metrics Refer to the following configuration: @@ -379,6 +440,8 @@ Start and stop Xray instances. [FreePort](https://github.com/phayes/freeport) +[MetaCubeX age](https://github.com/MetaCubeX/age) (BSD 3-Clause) + # License This repository is based on the MIT License. diff --git a/download_geo/main.go b/download_geo/main.go index d625ecb3..07ee3a5d 100644 --- a/download_geo/main.go +++ b/download_geo/main.go @@ -72,7 +72,7 @@ func makeLoadGeoDataRequest(datDir string, name string, geoType string) (string, return "", err } request := libXray.LibXrayInvokeRequest{ - APIVersion: 1, + APIVersion: libXray.LibXrayAPIVersion, Method: libXray.LibXrayMethodCountGeoData, Payload: payload, } diff --git a/go.mod b/go.mod index ef69942a..3cb04fc5 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/xtls/libxray go 1.26.3 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 google.golang.org/protobuf v1.36.11 @@ -21,6 +22,9 @@ require ( github.com/juju/ratelimit v1.0.2 // indirect github.com/klauspost/compress v1.17.4 // indirect github.com/klauspost/cpuid/v2 v2.4.0 // indirect + github.com/metacubex/hkdf v0.1.0 // indirect + github.com/metacubex/hpke v0.1.0 // indirect + github.com/metacubex/mlkem v0.1.0 // indirect github.com/miekg/dns v1.1.72 // indirect github.com/pelletier/go-toml v1.9.5 // indirect github.com/pion/dtls/v3 v3.1.4 // indirect diff --git a/go.sum b/go.sum index 6c9ce215..98dee9c7 100644 --- a/go.sum +++ b/go.sum @@ -36,6 +36,14 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/metacubex/age v0.0.0-20260603010618-28d156b4ea78 h1:LqWr0vb9zDNuQS+jJd4fnRYk/SEI7KJ7TDe/L4WFK48= +github.com/metacubex/age v0.0.0-20260603010618-28d156b4ea78/go.mod h1:BTBG/iVY7rg3qq5WdVCg0GFk58CSvCDSbjy8I7kEx/c= +github.com/metacubex/hkdf v0.1.0 h1:fPA6VzXK8cU1foc/TOmGCDmSa7pZbxlnqhl3RNsthaA= +github.com/metacubex/hkdf v0.1.0/go.mod h1:3seEfds3smgTAXqUGn+tgEJH3uXdsUjOiduG/2EtvZ4= +github.com/metacubex/hpke v0.1.0 h1:gu2jUNhraehWi0P/z5HX2md3d7L1FhPQE6/Q0E9r9xQ= +github.com/metacubex/hpke v0.1.0/go.mod h1:vfDm6gfgrwlXUxKDkWbcE44hXtmc1uxLDm2BcR11b3U= +github.com/metacubex/mlkem v0.1.0 h1:wFClitonSFcmipzzQvax75beLQU+D7JuC+VK1RzSL8I= +github.com/metacubex/mlkem v0.1.0/go.mod h1:amhaXZVeYNShuy9BILcR7P0gbeo/QLZsnqCdL8U2PDQ= github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= diff --git a/invoke.go b/invoke.go index 9e1ec1a8..4abcf6cc 100644 --- a/invoke.go +++ b/invoke.go @@ -41,18 +41,16 @@ func Invoke(requestJSON string) string { return invokeConvertShareLinksToXrayJson(request.Payload) case LibXrayMethodConvertXrayJsonToShareLinks: return invokeConvertXrayJsonToShareLinks(request.Payload) + case LibXrayMethodGenerateAgeKeyPair: + return invokeGenerateAgeKeyPair(request.Payload) case LibXrayMethodCountGeoData: return invokeCountGeoData(request.Payload) - case LibXrayMethodPing: - return invokePing(request.Payload) case LibXrayMethodPingBatch: return invokePingBatch(request.Payload) case LibXrayMethodTestXray: return invokeTestXray(request.Payload) case LibXrayMethodRunXray: return invokeRunXray(request.Payload) - case LibXrayMethodRunXrayFromJson: - return invokeRunXrayFromJSON(request.Payload) case LibXrayMethodStopXray: return encodeInvokeNoDataResponse(xray.StopXray()) case LibXrayMethodXrayVersion: @@ -64,7 +62,7 @@ func Invoke(requestJSON string) string { } } func validateAPIVersion(version int) error { - if version == 0 || version == 1 { + if version == LibXrayAPIVersion { return nil } return errors.New("unsupported apiVersion") @@ -137,10 +135,29 @@ func invokeConvertShareLinksToXrayJson(payload json.RawMessage) string { if err != nil { return encodeInvokeResponse(nil, err) } - xrayJson, err := share.ConvertShareLinksToXrayJson(request.Text) + secretKey := "" + if request.Age != nil { + secretKey = request.Age.SecretKey + } + xrayJson, err := share.ConvertShareLinksToXrayJsonWithAge(request.Text, secretKey) return encodeInvokeResponse(xrayJson, err) } +func invokeGenerateAgeKeyPair(payload json.RawMessage) string { + request, err := decodePayload[GenerateAgeKeyPairRequest](payload) + if err != nil { + return encodeInvokeResponse(nil, err) + } + pair, err := share.GenerateAgeKeyPair(share.AgeKeyType(request.KeyType)) + if err != nil { + return encodeInvokeResponse(nil, err) + } + return encodeInvokeResponse(&GenerateAgeKeyPairResponse{ + SecretKey: pair.SecretKey, + PublicKey: pair.PublicKey, + }, nil) +} + func invokeConvertXrayJsonToShareLinks(payload json.RawMessage) string { request, err := decodePayload[ConvertXrayJsonToShareLinksRequest](payload) if err != nil { @@ -165,21 +182,6 @@ func invokeCountGeoData(payload json.RawMessage) string { return encodeInvokeNoDataResponse(err) } -func invokePing(payload json.RawMessage) string { - request, err := decodePayload[PingRequest](payload) - if err != nil { - return encodeInvokeResponse(nil, err) - } - delay, err := xray.Ping(request.ConfigPath, request.Timeout, request.URL, request.Proxy) - if err != nil { - if delay == nodep.PingDelayError || delay == nodep.PingDelayTimeout { - return encodeInvokeResponse(&PingResponse{Delay: delay}, err) - } - return encodeInvokeResponse(nil, err) - } - return encodeInvokeResponse(&PingResponse{Delay: delay}, nil) -} - func invokePingBatch(payload json.RawMessage) string { request, err := decodePayload[PingBatchRequest](payload) if err != nil { @@ -189,7 +191,7 @@ func invokePingBatch(payload json.RawMessage) string { configs := make([]xray.PingBatchItem, len(request.Configs)) for i, config := range request.Configs { configs[i] = xray.PingBatchItem{ - ConfigPath: config.ConfigPath, + XrayJSON: config.XrayJson, OutboundTag: config.OutboundTag, } } @@ -215,11 +217,11 @@ func invokePingBatch(payload json.RawMessage) string { } func invokeTestXray(payload json.RawMessage) string { - request, err := decodePayload[RunXrayRequest](payload) + request, err := decodePayload[TestXrayRequest](payload) if err != nil { return encodeInvokeNoDataResponse(err) } - err = xray.TestXray(request.ConfigPath) + err = xray.TestXray(request.XrayJson) return encodeInvokeNoDataResponse(err) } @@ -228,15 +230,6 @@ func invokeRunXray(payload json.RawMessage) string { if err != nil { return encodeInvokeNoDataResponse(err) } - err = xray.RunXray(request.ConfigPath) - return encodeInvokeNoDataResponse(err) -} - -func invokeRunXrayFromJSON(payload json.RawMessage) string { - request, err := decodePayload[RunXrayFromJSONRequest](payload) - if err != nil { - return encodeInvokeNoDataResponse(err) - } - err = xray.RunXrayFromJSON(request.ConfigJSON) + err = xray.RunXray(request.XrayJson) return encodeInvokeNoDataResponse(err) } diff --git a/invoke_model.go b/invoke_model.go index a0cd3168..f317f0ea 100644 --- a/invoke_model.go +++ b/invoke_model.go @@ -5,16 +5,17 @@ import "encoding/json" type LibXrayMethod string +const LibXrayAPIVersion = 2 + const ( LibXrayMethodGetFreePorts LibXrayMethod = "getFreePorts" LibXrayMethodConvertShareLinksToXrayJson LibXrayMethod = "convertShareLinksToXrayJson" LibXrayMethodConvertXrayJsonToShareLinks LibXrayMethod = "convertXrayJsonToShareLinks" + LibXrayMethodGenerateAgeKeyPair LibXrayMethod = "generateAgeKeyPair" LibXrayMethodCountGeoData LibXrayMethod = "countGeoData" - LibXrayMethodPing LibXrayMethod = "ping" LibXrayMethodPingBatch LibXrayMethod = "pingBatch" LibXrayMethodTestXray LibXrayMethod = "testXray" LibXrayMethodRunXray LibXrayMethod = "runXray" - LibXrayMethodRunXrayFromJson LibXrayMethod = "runXrayFromJson" LibXrayMethodStopXray LibXrayMethod = "stopXray" LibXrayMethodXrayVersion LibXrayMethod = "xrayVersion" LibXrayMethodGetXrayState LibXrayMethod = "getXrayState" @@ -34,8 +35,29 @@ type GetFreePortsResponse struct { Ports []int `json:"ports,omitempty"` } +type AgeDecryptConfig struct { + SecretKey string `json:"secretKey,omitempty"` +} + type ConvertShareLinksToXrayJsonRequest struct { - Text string `json:"text,omitempty"` + Text string `json:"text,omitempty"` + Age *AgeDecryptConfig `json:"age,omitempty"` +} + +type AgeKeyType string + +const ( + AgeKeyTypeX25519 AgeKeyType = "x25519" + AgeKeyTypeHybrid AgeKeyType = "hybrid" +) + +type GenerateAgeKeyPairRequest struct { + KeyType AgeKeyType `json:"keyType,omitempty"` +} + +type GenerateAgeKeyPairResponse struct { + SecretKey string `json:"secretKey,omitempty"` + PublicKey string `json:"publicKey,omitempty"` } type ConvertXrayJsonToShareLinksRequest struct { @@ -52,17 +74,6 @@ type CountGeoDataRequest struct { DatDir string `json:"datDir,omitempty"` } -type PingRequest struct { - ConfigPath string `json:"configPath,omitempty"` - Timeout int `json:"timeout,omitempty"` - URL string `json:"url,omitempty"` - Proxy string `json:"proxy,omitempty"` -} - -type PingResponse struct { - Delay int64 `json:"delay,omitempty"` -} - type PingBatchRequest struct { Configs []PingBatchItemRequest `json:"configs,omitempty"` Timeout int `json:"timeout,omitempty"` @@ -70,7 +81,7 @@ type PingBatchRequest struct { } type PingBatchItemRequest struct { - ConfigPath string `json:"configPath,omitempty"` + XrayJson string `json:"xrayJson,omitempty"` OutboundTag string `json:"outboundTag,omitempty"` } @@ -85,11 +96,11 @@ type PingBatchItemResponse struct { } type RunXrayRequest struct { - ConfigPath string `json:"configPath,omitempty"` + XrayJson string `json:"xrayJson,omitempty"` } -type RunXrayFromJSONRequest struct { - ConfigJSON string `json:"configJSON,omitempty"` +type TestXrayRequest struct { + XrayJson string `json:"xrayJson,omitempty"` } type XrayVersionResponse struct { diff --git a/invoke_test.go b/invoke_test.go index edc35f24..45e10f41 100644 --- a/invoke_test.go +++ b/invoke_test.go @@ -1,13 +1,17 @@ package libXray import ( + "bytes" "encoding/base64" "encoding/json" + "io" "os" "path/filepath" "strings" "testing" + "github.com/metacubex/age" + "github.com/metacubex/age/armor" "github.com/xtls/libxray/nodep" "github.com/xtls/xray-core/common/geodata" "github.com/xtls/xray-core/infra/conf" @@ -27,7 +31,7 @@ func invokeForTest(t *testing.T, method LibXrayMethod, payload any) testResponse t.Fatal(err) } rawRequest, err := json.Marshal(&LibXrayInvokeRequest{ - APIVersion: 1, + APIVersion: LibXrayAPIVersion, Method: method, Payload: rawPayload, }) @@ -73,21 +77,6 @@ func decodeDataObject[T any](t *testing.T, response testResponse) T { return value } -func writeConfigToFile(t *testing.T, config any, path string) { - t.Helper() - if err := os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil { - t.Fatal(err) - } - file, err := os.Create(path) - if err != nil { - t.Fatal(err) - } - defer file.Close() - if err := json.NewEncoder(file).Encode(config); err != nil { - t.Fatal(err) - } -} - func writeGeoSiteDatForTest(t *testing.T, path string) { t.Helper() data, err := proto.Marshal(&geodata.GeoSiteList{ @@ -201,14 +190,15 @@ func testXrayConfig(t *testing.T) any { } func TestInvokeTestXray(t *testing.T) { - projectRoot, _ := filepath.Abs(".") - configPath := filepath.Join(projectRoot, "config", "xray_config_test.json") - writeConfigToFile(t, testXrayConfig(t), configPath) + xrayJSON, err := json.Marshal(testXrayConfig(t)) + if err != nil { + t.Fatal(err) + } response := invokeForTest( t, LibXrayMethodTestXray, - RunXrayRequest{ConfigPath: configPath}, + TestXrayRequest{XrayJson: string(xrayJSON)}, ) if !response.Success { t.Fatalf("TestXray failed: %s", response.Err) @@ -216,15 +206,36 @@ func TestInvokeTestXray(t *testing.T) { requireNoDataObject(t, response) } +func TestInvokeTestXrayDoesNotReadConfigPath(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "xray.json") + configJSON, err := json.Marshal(testXrayConfig(t)) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(configPath, configJSON, 0o600); err != nil { + t.Fatal(err) + } + + response := invokeForTest( + t, + LibXrayMethodTestXray, + TestXrayRequest{XrayJson: configPath}, + ) + if response.Success { + t.Fatal("testXray should parse xrayJson instead of reading a path") + } +} + func TestInvokeRunXray(t *testing.T) { - projectRoot, _ := filepath.Abs(".") - configPath := filepath.Join(projectRoot, "config", "xray_config_run.json") - writeConfigToFile(t, testXrayConfig(t), configPath) + xrayJSON, err := json.Marshal(testXrayConfig(t)) + if err != nil { + t.Fatal(err) + } response := invokeForTest( t, LibXrayMethodRunXray, - RunXrayRequest{ConfigPath: configPath}, + RunXrayRequest{XrayJson: string(xrayJSON)}, ) defer xrayStopForTest(t) if !response.Success { @@ -247,12 +258,14 @@ func TestInvokeRunXrayAppliesConfigEnv(t *testing.T) { } config["env"] = map[string]string{key: "configured"} - configPath := filepath.Join(t.TempDir(), "xray.json") - writeConfigToFile(t, config, configPath) + xrayJSON, err := json.Marshal(config) + if err != nil { + t.Fatal(err) + } response := invokeForTest( t, LibXrayMethodRunXray, - RunXrayRequest{ConfigPath: configPath}, + RunXrayRequest{XrayJson: string(xrayJSON)}, ) defer xrayStopForTest(t) if !response.Success { @@ -330,44 +343,103 @@ func TestInvokeConvertShareLinksFiltersBuildInvalidOutbounds(t *testing.T) { } } -func TestInvokeConvertShareLinksFailsWhenAllOutboundsAreBuildInvalid(t *testing.T) { - response := invokeForTest( +func TestInvokeAgeKeyGenerationAndConversion(t *testing.T) { + generated := invokeForTest( + t, + LibXrayMethodGenerateAgeKeyPair, + GenerateAgeKeyPairRequest{KeyType: AgeKeyTypeX25519}, + ) + if !generated.Success { + t.Fatalf("GenerateAgeKeyPair failed: %s", generated.Err) + } + pair := decodeDataObject[GenerateAgeKeyPairResponse](t, generated) + if pair.SecretKey == "" || pair.PublicKey == "" { + t.Fatalf("generated pair is incomplete: %+v", pair) + } + + recipient, err := age.ParseX25519Recipient(pair.PublicKey) + if err != nil { + t.Fatal(err) + } + var encrypted bytes.Buffer + armored := armor.NewWriter(&encrypted) + writer, err := age.Encrypt(armored, recipient) + if err != nil { + t.Fatal(err) + } + const link = "vless://12345678-abcd-abcd-abcd-123456789abc@example.com:443?encryption=none&security=tls&sni=example.com#AgeInvoke" + if _, err := io.WriteString(writer, link); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + if err := armored.Close(); err != nil { + t.Fatal(err) + } + + converted := invokeForTest( t, LibXrayMethodConvertShareLinksToXrayJson, ConvertShareLinksToXrayJsonRequest{ - Text: "vless://2418d087-648k-4990-86e8-19dca1d006d3@invalid.example:443?encryption=none&security=tls&sni=invalid.example&fp=chrome", + Text: encrypted.String(), + Age: &AgeDecryptConfig{SecretKey: pair.SecretKey}, }, ) - if response.Success { - t.Fatal("ConvertShareLinksToXrayJson should fail when all outbounds are invalid") + if !converted.Success { + t.Fatalf("ConvertShareLinksToXrayJson failed: %s", converted.Err) } - if !strings.Contains(response.Err, "no valid outbound found") { - t.Fatalf("error = %q", response.Err) + config := decodeDataObject[conf.Config](t, converted) + if len(config.OutboundConfigs) != 1 { + t.Fatalf("outbounds = %d, want 1", len(config.OutboundConfigs)) } - if got := string(response.Data); got != "null" { - t.Fatalf("data = %s, want null", got) +} + +func TestInvokeAgeFailuresHaveNullData(t *testing.T) { + for _, test := range []struct { + method LibXrayMethod + payload any + }{ + { + method: LibXrayMethodGenerateAgeKeyPair, + payload: GenerateAgeKeyPairRequest{KeyType: AgeKeyType("invalid")}, + }, + { + method: LibXrayMethodConvertShareLinksToXrayJson, + payload: ConvertShareLinksToXrayJsonRequest{ + Text: "-----BEGIN AGE ENCRYPTED FILE-----\ninvalid", + }, + }, + } { + response := invokeForTest(t, test.method, test.payload) + if response.Success { + t.Fatalf("method %q unexpectedly succeeded", test.method) + } + if got := string(response.Data); got != "null" { + t.Fatalf("method %q data = %s, want null", test.method, got) + } + if response.Err == "" { + t.Fatalf("method %q returned no error", test.method) + } } } -func TestInvokePingReturnsDelaySentinelOnXrayError(t *testing.T) { +func TestInvokeConvertShareLinksFailsWhenAllOutboundsAreBuildInvalid(t *testing.T) { response := invokeForTest( t, - LibXrayMethodPing, - PingRequest{ - ConfigPath: filepath.Join(t.TempDir(), "missing.json"), - Timeout: 1, - URL: "https://example.com", + LibXrayMethodConvertShareLinksToXrayJson, + ConvertShareLinksToXrayJsonRequest{ + Text: "vless://2418d087-648k-4990-86e8-19dca1d006d3@invalid.example:443?encryption=none&security=tls&sni=invalid.example&fp=chrome", }, ) if response.Success { - t.Fatal("Ping should keep failure success state on Xray error") + t.Fatal("ConvertShareLinksToXrayJson should fail when all outbounds are invalid") } - if response.Err == "" { - t.Fatal("Ping failure should keep error text") + if !strings.Contains(response.Err, "no valid outbound found") { + t.Fatalf("error = %q", response.Err) } - ping := decodeDataObject[PingResponse](t, response) - if ping.Delay != nodep.PingDelayError { - t.Fatalf("delay = %d, want %d", ping.Delay, nodep.PingDelayError) + if got := string(response.Data); got != "null" { + t.Fatalf("data = %s, want null", got) } } @@ -378,7 +450,7 @@ func TestInvokePingBatchReturnsPerItemFailures(t *testing.T) { PingBatchRequest{ Configs: []PingBatchItemRequest{ { - ConfigPath: filepath.Join(t.TempDir(), "missing.json"), + XrayJson: "not JSON", }, }, Timeout: 1, @@ -428,7 +500,7 @@ func TestInvokePingBatchRejectsMoreThanFiveConfigs(t *testing.T) { configs := make([]PingBatchItemRequest, 6) for i := range configs { configs[i] = PingBatchItemRequest{ - ConfigPath: "unused.json", + XrayJson: `{"outbounds":[{"protocol":"freedom"}]}`, } } response := invokeForTest( @@ -494,6 +566,21 @@ func TestInvokeUnknownMethod(t *testing.T) { } } +func TestInvokeRemovedMethods(t *testing.T) { + for _, method := range []string{"ping", "runXrayFromJson", "deriveAgePublicKey"} { + response := invokeRawForTest( + t, + `{"apiVersion":2,"method":"`+method+`","payload":{}}`, + ) + if response.Success { + t.Fatalf("removed method %q should fail", method) + } + if response.Err != "unknown method" { + t.Fatalf("method %q error = %q, want unknown method", method, response.Err) + } + } +} + func TestInvokeRejectsOversizedRequest(t *testing.T) { response := invokeRawForTest(t, strings.Repeat(" ", maxInvokeJSONBytes+1)) if response.Success { @@ -531,17 +618,22 @@ func TestInvokeRejectsOversizedResponse(t *testing.T) { func TestInvokeAPIVersion(t *testing.T) { response := invokeRawForTest(t, `{"method":"xrayVersion"}`) - if !response.Success { - t.Fatalf("omitted apiVersion should default to v1: %s", response.Err) + if response.Success { + t.Fatal("omitted apiVersion should fail") } - response = invokeRawForTest(t, `{"apiVersion":2,"method":"xrayVersion","env":{"xray.location.asset":"updated-asset"}}`) + response = invokeRawForTest(t, `{"apiVersion":1,"method":"xrayVersion"}`) if response.Success { - t.Fatal("unsupported apiVersion should fail") + t.Fatal("v1 apiVersion should fail") } if got := string(response.Data); got != "null" { t.Fatalf("data = %s, want null", got) } + + response = invokeRawForTest(t, `{"apiVersion":2,"method":"xrayVersion"}`) + if !response.Success { + t.Fatalf("v2 apiVersion should succeed: %s", response.Err) + } } func TestInvokeNoDataResponseShape(t *testing.T) { @@ -551,7 +643,7 @@ func TestInvokeNoDataResponseShape(t *testing.T) { } requireNoDataObject(t, response) - response = invokeRawForTest(t, `{"apiVersion":1,"method":"runXray","payload":"invalid"}`) + response = invokeRawForTest(t, `{"apiVersion":2,"method":"runXray","payload":"invalid"}`) if response.Success { t.Fatal("invalid runXray payload should fail") } @@ -564,7 +656,7 @@ func TestInvokeIgnoresTopLevelEnv(t *testing.T) { const key = "XRAY_LIBXRAY_UNKNOWN_ENV_TEST" _ = os.Unsetenv(key) t.Cleanup(func() { _ = os.Unsetenv(key) }) - requestJSON := `{"apiVersion":1,"method":"xrayVersion","env":{"` + key + `":"/tmp"}}` + requestJSON := `{"apiVersion":2,"method":"xrayVersion","env":{"` + key + `":"/tmp"}}` var response testResponse if err := json.Unmarshal([]byte(Invoke(requestJSON)), &response); err != nil { t.Fatal(err) diff --git a/readme/README.zh_CN.md b/readme/README.zh_CN.md index d21bdb78..a859496e 100644 --- a/readme/README.zh_CN.md +++ b/readme/README.zh_CN.md @@ -96,10 +96,10 @@ void CGoFree(char* value); ```json { - "apiVersion": 1, + "apiVersion": 2, "method": "runXray", "payload": { - "configPath": "/path/to/config.json" + "xrayJson": "{\"outbounds\":[...]}" } } ``` @@ -116,12 +116,13 @@ void CGoFree(char* value); 设计决定: -1. 顶层 `env` 字段会被忽略且不会生效。Xray-core 运行时环境项应写入 Xray 配置根 `env` 对象。 -2. `SetTunFd` 已删除。如果 fd 只能在运行时获得,请在调用 `runXray` 前把 `xray.tun.fd` 写入 Xray 配置根 `env` 对象。 -3. `countGeoData` 不依赖 Xray 配置,因此通过 method payload 的 `datDir` 传入数据目录。 -4. 完整的 UTF-8 编码 Invoke 请求和响应 JSON 包体限制为 16 MiB。任一方向超过限制时,Invoke 将返回 `success: false`、`data: null` 和对应的大小限制错误。 -5. `convertShareLinksToXrayJson` 会使用当前 Xray-core 配置构建器校验每个已解析的 outbound。无效 outbound 会被忽略;如果没有剩余的有效 outbound,该方法返回失败。校验不会创建或启动 Xray instance。 -6. Xray-core 的系统拨号 DNS client 和 outbound manager 属于进程级状态。当 `runXray` 或 `runXrayFromJson` 正在运行时,通过 `ping`、`pingBatch`、`testXray` 或导出的 Go API 创建另一个 Xray instance,可能覆盖这些状态并影响正在运行的 instance。关闭临时 instance 不会恢复之前的状态。libXray 不对并发 instance 进行串行化、隔离或状态恢复;调用方如需同时运行多个 instance,必须将它们放在不同进程中。 +1. Invoke 当前只接受 `apiVersion: 2`。Xray 配置通过 `xrayJson` 传递 UTF-8 JSON 文本;libXray 不读取配置文件路径。 +2. 顶层 `env` 字段会被忽略且不会生效。Xray-core 运行时环境项应写入 Xray 配置根 `env` 对象。 +3. `SetTunFd` 已删除。如果 fd 只能在运行时获得,请在调用 `runXray` 前把 `xray.tun.fd` 写入 Xray 配置根 `env` 对象。 +4. `countGeoData` 不依赖 Xray 配置,因此通过 method payload 的 `datDir` 传入数据目录。 +5. 完整的 UTF-8 编码 Invoke 请求和响应 JSON 包体限制为 16 MiB。任一方向超过限制时,Invoke 将返回 `success: false`、`data: null` 和对应的大小限制错误。 +6. `convertShareLinksToXrayJson` 会使用当前 Xray-core 配置构建器校验每个已解析的 outbound。无效 outbound 会被忽略;如果没有剩余的有效 outbound,该方法返回失败。校验不会创建或启动 Xray instance。可选的 `age.secretKey` 会在现有解析流程前于内存中解密官方 age ASCII armor;明文输入保持原有行为。 +7. Xray-core 的系统拨号 DNS client 和 outbound manager 属于进程级状态。当 `runXray` 正在运行时,通过 `pingBatch`、`testXray` 或导出的 Go API 创建另一个 Xray instance,可能覆盖这些状态并影响正在运行的 instance。关闭临时 instance 不会恢复之前的状态。libXray 不对并发 instance 进行串行化、隔离或状态恢复;调用方如需同时运行多个 instance,必须将它们放在不同进程中。 支持的 method: @@ -129,12 +130,11 @@ void CGoFree(char* value); getFreePorts convertShareLinksToXrayJson convertXrayJsonToShareLinks +generateAgeKeyPair countGeoData -ping pingBatch testXray runXray -runXrayFromJson stopXray xrayVersion getXrayState @@ -209,6 +209,46 @@ libXray 使用 `sendThrough` 来存储节点名称。 转换 VMessQRCode 为 Xray Json。 +### age 加密订阅 + +`convertShareLinksToXrayJson` 接受可选的 age 原生私钥。仅支持 X25519 +(`AGE-SECRET-KEY-1...`)和 ML-KEM-768 + X25519 hybrid +(`AGE-SECRET-KEY-PQ-1...`)identity。识别到 age armor 后会在内存中完成 +解密,解密后明文上限为 16 MiB。 + +```json +{ + "apiVersion": 2, + "method": "convertShareLinksToXrayJson", + "payload": { + "text": "-----BEGIN AGE ENCRYPTED FILE-----\n...", + "age": { + "secretKey": "AGE-SECRET-KEY-1..." + } + } +} +``` + +`generateAgeKeyPair` 可生成新密钥对,`keyType` 支持 `x25519` 或 +`hybrid`;省略时默认为 `x25519`。`hybrid` 对应 Mihomo 的 +`age keygen-pq`,生成 `AGE-SECRET-KEY-PQ-1...` identity 和 +`age1pq1...` recipient: + +```json +{ + "apiVersion": 2, + "method": "generateAgeKeyPair", + "payload": { + "keyType": "x25519" + } +} +``` + +响应同时包含 `secretKey` 和 `publicKey`。接入 App 必须持久化该密钥对,并且 +只将 `publicKey` 作为 `X-Age-Public-Key` 发送。libXray 不负责订阅 HTTP 请求、 +密钥持久化或请求 Header;严禁通过 HTTP 发送私钥,也不能把解密后的订阅文本 +写入磁盘。 + ### vmess 转换 VMessQRCode 为 Xray Json。 @@ -219,27 +259,23 @@ libXray 使用 `sendThrough` 来存储节点名称。 ## xray -### ping - -延迟测试。 - ### pingBatch -在一个临时 Xray instance 内并发测试多份 outbound 配置。每份配置文件只解析 -`outbounds`,其他根字段全部忽略。目标 outbound 依次按 `outboundTag`、`proxy` -tag、首个 outbound 选择。 +在一个临时 Xray instance 内并发测试多份 outbound 配置。每个 `xrayJson` 文本只 +解析 `outbounds`,其他根字段全部忽略。目标 outbound 依次按 `outboundTag`、 +`proxy` tag、首个 outbound 选择。 ```json { - "apiVersion": 1, + "apiVersion": 2, "method": "pingBatch", "payload": { "configs": [ { - "configPath": "/path/to/node-1.json" + "xrayJson": "{\"outbounds\":[...]}" }, { - "configPath": "/path/to/full-config.json", + "xrayJson": "{\"outbounds\":[...]}", "outboundTag": "media" } ], @@ -258,6 +294,25 @@ tag、首个 outbound 选择。 通过 `streamSettings.sockopt.dialerProxy` 或 `proxySettings.tag` 引用的 outbound 依赖会被自动包含。 +### testXray + +直接校验传入的 Xray JSON 文本,不读取配置文件: + +```json +{ + "apiVersion": 2, + "method": "testXray", + "payload": { + "xrayJson": "{\"outbounds\":[...]}" + } +} +``` + +### runXray + +使用传入的 Xray JSON 文本启动由 libXray 管理的 Xray instance,并通过 +`stopXray` 停止。`runXrayFromJson` 不再作为独立 method 存在。 + ### metrics 统计。 @@ -309,6 +364,8 @@ http://localhost:49227/debug/vars [FreePort](https://github.com/phayes/freeport) +[MetaCubeX age](https://github.com/MetaCubeX/age)(BSD 3-Clause) + # License 本仓库基于 MIT License 。 diff --git a/share/age.go b/share/age.go new file mode 100644 index 00000000..1057911f --- /dev/null +++ b/share/age.go @@ -0,0 +1,122 @@ +package share + +import ( + "errors" + "io" + "strings" + + "github.com/metacubex/age" + "github.com/metacubex/age/armor" + "github.com/xtls/xray-core/infra/conf" +) + +const ( + ageArmorHeader = "-----BEGIN AGE ENCRYPTED FILE-----" + maxAgePlaintextBytes = 16 * 1024 * 1024 +) + +var ( + ErrAgeSecretKeyMissing = errors.New("missing age secret key") + ErrAgeSecretKeyInvalid = errors.New("invalid or unsupported age secret key") + ErrAgeDecryptFailed = errors.New("unable to decrypt age subscription") + ErrAgeArmorMalformed = errors.New("malformed age armor") + ErrAgePlaintextTooLarge = errors.New("decrypted subscription exceeds the 16 MiB size limit") + ErrAgePlaintextUnsupported = errors.New("decrypted subscription is unsupported") + ErrAgeKeyTypeUnsupported = errors.New("unsupported age key type") +) + +type AgeKeyType string + +const ( + AgeKeyTypeX25519 AgeKeyType = "x25519" + AgeKeyTypeHybrid AgeKeyType = "hybrid" +) + +type AgeKeyPair struct { + SecretKey string + PublicKey string +} + +func GenerateAgeKeyPair(keyType AgeKeyType) (*AgeKeyPair, error) { + switch keyType { + case "", AgeKeyTypeX25519: + identity, err := age.GenerateX25519Identity() + if err != nil { + return nil, errors.New("failed to generate age keypair") + } + return &AgeKeyPair{ + SecretKey: identity.String(), + PublicKey: identity.Recipient().String(), + }, nil + case AgeKeyTypeHybrid: + identity, err := age.GenerateHybridIdentity() + if err != nil { + return nil, errors.New("failed to generate age keypair") + } + return &AgeKeyPair{ + SecretKey: identity.String(), + PublicKey: identity.Recipient().String(), + }, nil + default: + return nil, ErrAgeKeyTypeUnsupported + } +} + +func ConvertShareLinksToXrayJsonWithAge(links, secretKey string) (*conf.Config, error) { + text := strings.TrimSpace(FixWindowsReturn(links)) + if !strings.HasPrefix(text, ageArmorHeader) { + return ConvertShareLinksToXrayJson(links) + } + if strings.TrimSpace(secretKey) == "" { + return nil, ErrAgeSecretKeyMissing + } + + identity, _, err := parseNativeAgeIdentity(secretKey) + if err != nil { + return nil, err + } + reader, err := age.Decrypt(armor.NewReader(strings.NewReader(text)), identity) + if err != nil { + var noMatch *age.NoIdentityMatchError + if errors.As(err, &noMatch) { + return nil, ErrAgeDecryptFailed + } + return nil, ErrAgeArmorMalformed + } + + plaintext, err := io.ReadAll(io.LimitReader(reader, maxAgePlaintextBytes+1)) + if err != nil { + return nil, ErrAgeArmorMalformed + } + if len(plaintext) > maxAgePlaintextBytes { + return nil, ErrAgePlaintextTooLarge + } + config, err := ConvertShareLinksToXrayJson(string(plaintext)) + if err != nil { + return nil, ErrAgePlaintextUnsupported + } + return config, nil +} + +func parseNativeAgeIdentity(secretKey string) (age.Identity, age.Recipient, error) { + key := strings.TrimSpace(secretKey) + if key == "" || strings.ContainsAny(key, "\r\n") { + return nil, nil, ErrAgeSecretKeyInvalid + } + + if strings.HasPrefix(key, "AGE-SECRET-KEY-1") { + identity, err := age.ParseX25519Identity(key) + if err != nil { + return nil, nil, ErrAgeSecretKeyInvalid + } + return identity, identity.Recipient(), nil + } + if strings.HasPrefix(key, "AGE-SECRET-KEY-PQ-1") { + identity, err := age.ParseHybridIdentity(key) + if err != nil { + return nil, nil, ErrAgeSecretKeyInvalid + } + return identity, identity.Recipient(), nil + } + return nil, nil, ErrAgeSecretKeyInvalid +} diff --git a/share/age_test.go b/share/age_test.go new file mode 100644 index 00000000..9b14a701 --- /dev/null +++ b/share/age_test.go @@ -0,0 +1,179 @@ +package share + +import ( + "bytes" + "errors" + "io" + "strings" + "testing" + + "github.com/metacubex/age" + "github.com/metacubex/age/armor" +) + +const ageTestShareLink = "vless://12345678-abcd-abcd-abcd-123456789abc@example.com:443?encryption=none&security=tls&sni=example.com#AgeTest" + +func TestGenerateAgeKeyPair(t *testing.T) { + for _, keyType := range []AgeKeyType{AgeKeyTypeX25519, AgeKeyTypeHybrid} { + t.Run(string(keyType), func(t *testing.T) { + first, err := GenerateAgeKeyPair(keyType) + if err != nil { + t.Fatal(err) + } + second, err := GenerateAgeKeyPair(keyType) + if err != nil { + t.Fatal(err) + } + if first.SecretKey == second.SecretKey || first.PublicKey == second.PublicKey { + t.Fatal("generated age keypairs should be unique") + } + assertAgeRoundTrip(t, first, ageTestShareLink) + }) + } +} + +func TestGenerateAgeKeyPairRejectsUnsupportedType(t *testing.T) { + _, err := GenerateAgeKeyPair(AgeKeyType("unsupported")) + if !errors.Is(err, ErrAgeKeyTypeUnsupported) { + t.Fatalf("error = %v, want %v", err, ErrAgeKeyTypeUnsupported) + } +} + +func TestConvertShareLinksToXrayJsonWithAgePlaintext(t *testing.T) { + pair, err := GenerateAgeKeyPair(AgeKeyTypeX25519) + if err != nil { + t.Fatal(err) + } + config, err := ConvertShareLinksToXrayJsonWithAge(ageTestShareLink, pair.SecretKey) + if err != nil { + t.Fatal(err) + } + if len(config.OutboundConfigs) != 1 { + t.Fatalf("outbounds = %d, want 1", len(config.OutboundConfigs)) + } +} + +func TestConvertShareLinksToXrayJsonWithAgeEncrypted(t *testing.T) { + for _, keyType := range []AgeKeyType{AgeKeyTypeX25519, AgeKeyTypeHybrid} { + t.Run(string(keyType), func(t *testing.T) { + pair, err := GenerateAgeKeyPair(keyType) + if err != nil { + t.Fatal(err) + } + armored := encryptAgeForTest(t, pair, ageTestShareLink) + config, err := ConvertShareLinksToXrayJsonWithAge(armored, pair.SecretKey) + if err != nil { + t.Fatal(err) + } + if len(config.OutboundConfigs) != 1 { + t.Fatalf("outbounds = %d, want 1", len(config.OutboundConfigs)) + } + }) + } +} + +func TestConvertShareLinksToXrayJsonWithAgeErrors(t *testing.T) { + pair, err := GenerateAgeKeyPair(AgeKeyTypeX25519) + if err != nil { + t.Fatal(err) + } + armored := encryptAgeForTest(t, pair, ageTestShareLink) + + _, err = ConvertShareLinksToXrayJsonWithAge(armored, "") + if !errors.Is(err, ErrAgeSecretKeyMissing) { + t.Fatalf("missing key error = %v", err) + } + + wrongPair, err := GenerateAgeKeyPair(AgeKeyTypeX25519) + if err != nil { + t.Fatal(err) + } + _, err = ConvertShareLinksToXrayJsonWithAge(armored, wrongPair.SecretKey) + if !errors.Is(err, ErrAgeDecryptFailed) { + t.Fatalf("wrong key error = %v", err) + } + if strings.Contains(err.Error(), wrongPair.SecretKey) { + t.Fatal("decryption error contains the secret key") + } + + _, err = ConvertShareLinksToXrayJsonWithAge(ageArmorHeader+"\ninvalid", pair.SecretKey) + if !errors.Is(err, ErrAgeArmorMalformed) { + t.Fatalf("malformed armor error = %v", err) + } +} + +func TestConvertShareLinksToXrayJsonWithAgeRejectsLargePlaintext(t *testing.T) { + pair, err := GenerateAgeKeyPair(AgeKeyTypeX25519) + if err != nil { + t.Fatal(err) + } + armored := encryptAgeForTest( + t, + pair, + strings.Repeat("x", maxAgePlaintextBytes+1), + ) + _, err = ConvertShareLinksToXrayJsonWithAge(armored, pair.SecretKey) + if !errors.Is(err, ErrAgePlaintextTooLarge) { + t.Fatalf("large plaintext error = %v", err) + } +} + +func TestConvertShareLinksToXrayJsonWithAgeSanitizesParserErrors(t *testing.T) { + pair, err := GenerateAgeKeyPair(AgeKeyTypeX25519) + if err != nil { + t.Fatal(err) + } + sensitivePlaintext := "unsupported://user:password@example.com" + armored := encryptAgeForTest(t, pair, sensitivePlaintext) + _, err = ConvertShareLinksToXrayJsonWithAge(armored, pair.SecretKey) + if !errors.Is(err, ErrAgePlaintextUnsupported) { + t.Fatalf("unsupported plaintext error = %v", err) + } + if strings.Contains(err.Error(), sensitivePlaintext) || strings.Contains(err.Error(), pair.SecretKey) { + t.Fatal("age parser error contains sensitive input") + } +} + +func assertAgeRoundTrip(t *testing.T, pair *AgeKeyPair, plaintext string) { + t.Helper() + armored := encryptAgeForTest(t, pair, plaintext) + identity, _, err := parseNativeAgeIdentity(pair.SecretKey) + if err != nil { + t.Fatal(err) + } + reader, err := age.Decrypt(armor.NewReader(strings.NewReader(armored)), identity) + if err != nil { + t.Fatal(err) + } + decrypted, err := io.ReadAll(reader) + if err != nil { + t.Fatal(err) + } + if string(decrypted) != plaintext { + t.Fatalf("decrypted = %q, want %q", decrypted, plaintext) + } +} + +func encryptAgeForTest(t *testing.T, pair *AgeKeyPair, plaintext string) string { + t.Helper() + _, recipient, err := parseNativeAgeIdentity(pair.SecretKey) + if err != nil { + t.Fatal(err) + } + var output bytes.Buffer + armored := armor.NewWriter(&output) + writer, err := age.Encrypt(armored, recipient) + if err != nil { + t.Fatal(err) + } + if _, err := io.WriteString(writer, plaintext); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + if err := armored.Close(); err != nil { + t.Fatal(err) + } + return output.String() +} diff --git a/xray/ping.go b/xray/ping.go deleted file mode 100644 index f667b80d..00000000 --- a/xray/ping.go +++ /dev/null @@ -1,30 +0,0 @@ -package xray - -import ( - "github.com/xtls/libxray/nodep" -) - -// Ping Xray config and find the delay and country code of its outbound. -// configPath means the config.json file path. -// timeout means how long the http request will be cancelled if no response, in units of seconds. -// url means the website we use to test speed. "https://www.google.com" is a good choice for most cases. -// proxy means the local http/socks5 proxy, like "socks5://[::1]:1080". -func Ping(configPath string, timeout int, url string, proxy string) (int64, error) { - server, err := StartXray(configPath) - if err != nil { - return nodep.PingDelayError, err - } - - if err := server.Start(); err != nil { - _ = server.Close() - return nodep.PingDelayError, err - } - defer server.Close() - - delay, err := nodep.MeasureDelay(timeout, url, proxy) - if err != nil { - return delay, err - } - - return delay, nil -} diff --git a/xray/ping_batch.go b/xray/ping_batch.go index 947498d7..f40f24d9 100644 --- a/xray/ping_batch.go +++ b/xray/ping_batch.go @@ -9,7 +9,7 @@ import ( "net" "net/http" "net/url" - "os" + "strings" "sync" "time" @@ -26,7 +26,7 @@ const ( ) type PingBatchItem struct { - ConfigPath string + XrayJSON string OutboundTag string } @@ -59,7 +59,7 @@ func PingBatch( mergedOutbounds := make([]conf.OutboundDetourConfig, 0, len(items)) for index, item := range items { - outbounds, err := readPingOutbounds(item.ConfigPath) + outbounds, err := readPingOutbounds(item.XrayJSON) if err != nil { results[index] = failedPingBatchResult(nodep.PingDelayError, err) continue @@ -155,19 +155,13 @@ func validatePingBatchRequest( return nil } -func readPingOutbounds(path string) ([]conf.OutboundDetourConfig, error) { - if path == "" { - return nil, errors.New("ping config path is empty") +func readPingOutbounds(xrayJSON string) ([]conf.OutboundDetourConfig, error) { + if xrayJSON == "" { + return nil, errors.New("ping Xray JSON is empty") } - file, err := os.Open(path) - if err != nil { - return nil, err - } - defer file.Close() - var config pingOutboundConfig - decoder := json.NewDecoder(&confJSON.Reader{Reader: file}) + decoder := json.NewDecoder(&confJSON.Reader{Reader: strings.NewReader(xrayJSON)}) if err := decoder.Decode(&config); err != nil { return nil, err } diff --git a/xray/ping_batch_test.go b/xray/ping_batch_test.go index 933ae8f9..ece744e8 100644 --- a/xray/ping_batch_test.go +++ b/xray/ping_batch_test.go @@ -4,8 +4,6 @@ import ( "fmt" "net/http" "net/http/httptest" - "os" - "path/filepath" "strings" "testing" "time" @@ -13,17 +11,8 @@ import ( "github.com/xtls/xray-core/infra/conf" ) -func writePingBatchConfig(t *testing.T, name string, content string) string { - t.Helper() - path := filepath.Join(t.TempDir(), name) - if err := os.WriteFile(path, []byte(content), 0o600); err != nil { - t.Fatal(err) - } - return path -} - func TestReadPingOutboundsIgnoresOtherRootFields(t *testing.T) { - path := writePingBatchConfig(t, "config.json", `{ + xrayJSON := `{ // These fields are deliberately invalid for the full Xray schema. "inbounds": "ignored", "routing": 42, @@ -31,9 +20,9 @@ func TestReadPingOutboundsIgnoresOtherRootFields(t *testing.T) { "outbounds": [ {"protocol": "freedom", "tag": "proxy"} ] - }`) + }` - outbounds, err := readPingOutbounds(path) + outbounds, err := readPingOutbounds(xrayJSON) if err != nil { t.Fatal(err) } @@ -46,7 +35,7 @@ func TestReadPingOutboundsIgnoresOtherRootFields(t *testing.T) { } func TestPreparePingOutboundsPreservesRealityClientConfig(t *testing.T) { - path := writePingBatchConfig(t, "reality.json", `{ + xrayJSON := `{ "outbounds": [{ "tag": "proxy", "protocol": "vless", @@ -71,9 +60,9 @@ func TestPreparePingOutboundsPreservesRealityClientConfig(t *testing.T) { } } }] - }`) + }` - outbounds, err := readPingOutbounds(path) + outbounds, err := readPingOutbounds(xrayJSON) if err != nil { t.Fatal(err) } @@ -198,9 +187,6 @@ func TestPingBatchRunsRequestsConcurrently(t *testing.T) { defer server.Close() config := `{"outbounds":[{"protocol":"freedom","tag":"proxy"}]}` - firstPath := writePingBatchConfig(t, "first.json", config) - secondPath := writePingBatchConfig(t, "second.json", config) - type batchResult struct { results []PingBatchResult err error @@ -209,8 +195,8 @@ func TestPingBatchRunsRequestsConcurrently(t *testing.T) { go func() { results, err := PingBatch( []PingBatchItem{ - {ConfigPath: firstPath}, - {ConfigPath: secondPath}, + {XrayJSON: config}, + {XrayJSON: config}, }, 2, server.URL, @@ -248,14 +234,8 @@ func TestPingBatchRunsRequestsConcurrently(t *testing.T) { func TestPingBatchKeepsPerItemConfigErrorsInInputOrder(t *testing.T) { results, err := PingBatch( []PingBatchItem{ - {ConfigPath: filepath.Join(t.TempDir(), "missing.json")}, - { - ConfigPath: writePingBatchConfig( - t, - "invalid.json", - `{"outbounds":[]}`, - ), - }, + {XrayJSON: "not JSON"}, + {XrayJSON: `{"outbounds":[]}`}, }, 1, "https://example.com", @@ -277,8 +257,8 @@ func TestPingBatchKeepsPerItemConfigErrorsInInputOrder(t *testing.T) { t.Fatalf("result %d has no error", index) } } - if !strings.Contains(results[0].Error, "missing.json") { - t.Fatalf("first result error = %q, want missing config error", results[0].Error) + if results[0].Error == "" { + t.Fatal("first result should contain a JSON parsing error") } if results[1].Error != "ping config has no outbounds" { t.Fatalf("second result error = %q, want empty outbounds error", results[1].Error) @@ -355,7 +335,7 @@ func TestValidatePingBatchRequestAcceptsFiveConfigs(t *testing.T) { func ExamplePingBatch() { results, _ := PingBatch( - []PingBatchItem{{ConfigPath: "config.json"}}, + []PingBatchItem{{XrayJSON: `{"outbounds":[{"protocol":"freedom"}]}`}}, 5, "https://cp.cloudflare.com/", ) diff --git a/xray/validation.go b/xray/validation.go index f7e2c1e1..3d0a442f 100644 --- a/xray/validation.go +++ b/xray/validation.go @@ -1,9 +1,9 @@ package xray // Test Xray Config. -// configPath means the config.json file path. -func TestXray(configPath string) error { - server, err := StartXray(configPath) +// xrayJSON is the serialized Xray JSON configuration. +func TestXray(xrayJSON string) error { + server, err := newXrayInstance(xrayJSON) if err != nil { return err } diff --git a/xray/xray.go b/xray/xray.go index a684bffb..ec3f2145 100644 --- a/xray/xray.go +++ b/xray/xray.go @@ -3,10 +3,10 @@ package xray import ( "errors" "runtime/debug" + "strings" "sync" "github.com/xtls/libxray/memory" - "github.com/xtls/xray-core/common/cmdarg" "github.com/xtls/xray-core/core" _ "github.com/xtls/xray-core/main/distro/all" ) @@ -18,9 +18,8 @@ var ( 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) +func newXrayInstance(xrayJSON string) (*core.Instance, error) { + config, err := core.LoadConfig("json", strings.NewReader(xrayJSON)) if err != nil { return nil, err } @@ -33,22 +32,9 @@ func StartXray(configPath string) (*core.Instance, error) { return server, nil } -func StartXrayFromJSON(configJSON string) (*core.Instance, error) { - // Convert JSON string to bytes - configBytes := []byte(configJSON) - - // Use core.StartInstance which can load configuration directly from bytes - server, err := core.StartInstance("json", configBytes) - if err != nil { - return nil, err - } - - return server, nil -} - // Run Xray instance. -// configPath means the config.json file path. -func RunXray(configPath string) (err error) { +// xrayJSON is the serialized Xray JSON configuration. +func RunXray(xrayJSON string) (err error) { coreServerMu.Lock() defer coreServerMu.Unlock() if coreServer != nil { @@ -56,7 +42,7 @@ func RunXray(configPath string) (err error) { } memory.InitForceFree() - server, err := StartXray(configPath) + server, err := newXrayInstance(xrayJSON) if err != nil { return } @@ -71,26 +57,6 @@ func RunXray(configPath string) (err error) { return nil } -// 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() - server, err := StartXrayFromJSON(configJSON) - if err != nil { - return - } - coreServer = server - - debug.FreeOSMemory() - return nil -} - // Get Xray State func GetXrayState() bool { coreServerMu.Lock() diff --git a/xray/xray_test.go b/xray/xray_test.go index a0b7f3bc..192f825e 100644 --- a/xray/xray_test.go +++ b/xray/xray_test.go @@ -12,7 +12,7 @@ const minimalConfig = `{ "outbounds": [{"protocol": "freedom", "tag": "direct"}] }` -func TestRunXrayFromJSONRejectsDuplicateStart(t *testing.T) { +func TestRunXrayRejectsDuplicateStart(t *testing.T) { t.Cleanup(func() { if err := StopXray(); err != nil { t.Errorf("stop xray: %v", err) @@ -22,13 +22,13 @@ func TestRunXrayFromJSONRejectsDuplicateStart(t *testing.T) { if err := StopXray(); err != nil { t.Fatalf("reset xray state: %v", err) } - if err := RunXrayFromJSON(minimalConfig); err != nil { + if err := RunXray(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) { + if err := RunXray(minimalConfig); !errors.Is(err, ErrAlreadyRunning) { t.Fatalf("duplicate start error = %v, want %v", err, ErrAlreadyRunning) } } @@ -37,7 +37,7 @@ func TestXrayLifecycleConcurrentStateReads(t *testing.T) { if err := StopXray(); err != nil { t.Fatalf("reset xray state: %v", err) } - if err := RunXrayFromJSON(minimalConfig); err != nil { + if err := RunXray(minimalConfig); err != nil { t.Fatalf("start xray: %v", err) } @@ -59,17 +59,17 @@ func TestXrayLifecycleConcurrentStateReads(t *testing.T) { } } -func TestRunXrayFromJSONFailureDoesNotPublishInstance(t *testing.T) { +func TestRunXrayFailureDoesNotPublishInstance(t *testing.T) { if err := StopXray(); err != nil { t.Fatalf("reset xray state: %v", err) } - if err := RunXrayFromJSON(`{"outbounds":[`); err == nil { + if err := RunXray(`{"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 { + if err := RunXray(minimalConfig); err != nil { t.Fatalf("start after failure: %v", err) } if err := StopXray(); err != nil { @@ -77,7 +77,7 @@ func TestRunXrayFromJSONFailureDoesNotPublishInstance(t *testing.T) { } } -func TestRunXrayFromJSONSerializesConcurrentStarts(t *testing.T) { +func TestRunXraySerializesConcurrentStarts(t *testing.T) { if err := StopXray(); err != nil { t.Fatalf("reset xray state: %v", err) } @@ -94,7 +94,7 @@ func TestRunXrayFromJSONSerializesConcurrentStarts(t *testing.T) { starters.Add(1) go func() { defer starters.Done() - errorsByStart <- RunXrayFromJSON(minimalConfig) + errorsByStart <- RunXray(minimalConfig) }() } starters.Wait() From d1b4b3fa8542b4ff070187fbf1cbf9ecc2c2a275 Mon Sep 17 00:00:00 2001 From: yiguodev <147401898+yiguodev@users.noreply.github.com> Date: Fri, 14 Aug 2026 21:03:15 +0800 Subject: [PATCH 12/38] Document single Go runtime limitation (#144) * Document single Go runtime limitation * Generalize Go runtime warning across platforms --- README.md | 21 ++++++++++++++++++++- readme/README.zh_CN.md | 17 ++++++++++++++++- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 29b38fb1..e5fa0026 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,24 @@ python3 build/main.py windows local ``` +> [!WARNING] +> **Use only one Go runtime per process.** Go does not support loading multiple +> independently built Go runtimes into one process. Every native libXray +> artifact embeds a Go runtime, whether it is produced through cgo or gomobile. +> Do not load libXray together with another independently built Go, cgo, or +> gomobile library in the same executable or process. Doing so can fail during +> build, link, or load, or crash during runtime initialization before +> application code runs. +> If one process needs Go packages from several libraries, include those +> packages in the same Go build or `gomobile bind` invocation and produce one +> native artifact so they share a runtime. Merely repackaging or merging +> independently built frameworks, archives, AARs, shared libraries, or DLLs is +> not sufficient. Separate OS processes may each load one Go runtime, so apply +> this rule independently to each process. See +> [Go #18976](https://github.com/golang/go/issues/18976#issuecomment-308505600), +> [golang/go#15956](https://github.com/golang/go/issues/15956#issuecomment-373709423), +> and [libXray #116](https://github.com/XTLS/libXray/issues/116). + ### Android use [gomobile](https://github.com/golang/mobile) . @@ -76,7 +94,8 @@ use [gomobile](https://github.com/golang/mobile) . Need "iOS Simulator Runtime". -This is the best choice for general scenarios and will not conflict with other frameworks. +This is the best choice for general scenarios. The cross-platform single-runtime +restriction above still applies when linking other Go-based libraries. Supports iOS, iOSSimulator, macOS, macCatalyst. diff --git a/readme/README.zh_CN.md b/readme/README.zh_CN.md index a859496e..87ca9e54 100644 --- a/readme/README.zh_CN.md +++ b/readme/README.zh_CN.md @@ -34,6 +34,20 @@ python3 build/main.py windows python3 build/main.py windows local ``` +> [!WARNING] +> **每个进程只能使用一个 Go runtime。** Go 不支持在同一进程中加载多个独立构建的 +> Go runtime。libXray 的所有原生产物都会嵌入 Go runtime,无论它们通过 cgo 还是 +> gomobile 生成。不要在同一个可执行文件或进程中同时加载 libXray 与另一个独立构建的 +> Go、cgo 或 gomobile 库,否则可能在构建、链接或加载阶段失败,也可能在应用代码执行前 +> 的 runtime 初始化阶段崩溃。 +> 如果同一进程需要多个库中的 Go package,应将这些 package 放入同一次 Go build 或 +> `gomobile bind` 并生成一个原生产物,使其共享一个 runtime。仅重新打包或合并已经独立 +> 构建的 framework、archive、AAR、shared library 或 DLL 并不能解决问题。不同的操作系统 +> 进程可以各自加载一个 Go runtime,因此需要分别对每个进程遵守这一限制。参见 +> [Go #18976](https://github.com/golang/go/issues/18976#issuecomment-308505600)、 +> [golang/go#15956](https://github.com/golang/go/issues/15956#issuecomment-373709423) +> 和 [libXray #116](https://github.com/XTLS/libXray/issues/116)。 + ### Android 使用 [gomobile](https://github.com/golang/mobile) 。 @@ -44,7 +58,8 @@ python3 build/main.py windows local 需要 “iOS Simulator Runtime”。 -这是常规场景下的最佳选择,不会与其他 frameworks 冲突。 +这是常规场景下的最佳选择;与其他基于 Go 的库同时集成时,仍须遵守上方跨平台的 +单 runtime 限制。 支持 iOS,iOSSimulator,macOS,macCatalyst。 From 68119ddec6a16ea635838af0e9e11fd93316812c Mon Sep 17 00:00:00 2001 From: Tamim Hossain <132823494+codewithtamim@users.noreply.github.com> Date: Thu, 20 Aug 2026 18:40:50 +0600 Subject: [PATCH 13/38] Add Tools .go (#146) Solves the annoying problem when first time building binary, cc @yiguodev --- go.mod | 2 -- tools.go | 10 ++++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) create mode 100644 tools.go diff --git a/go.mod b/go.mod index 3cb04fc5..cef1f3b3 100644 --- a/go.mod +++ b/go.mod @@ -62,5 +62,3 @@ require ( gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect lukechampine.com/blake3 v1.4.1 // indirect ) - -tool golang.org/x/mobile/cmd/gobind diff --git a/tools.go b/tools.go new file mode 100644 index 00000000..65da148b --- /dev/null +++ b/tools.go @@ -0,0 +1,10 @@ +//go:build tools +// +build tools + +// See https://github.com/golang/go/wiki/Modules#how-can-i-track-tool-dependencies-for-a-module +package tools + +import ( + _ "golang.org/x/mobile/cmd/gobind" + _ "golang.org/x/mobile/cmd/gomobile" +) From d3f2c47f9ba4dfc67e1aee2755b8d5dc2d5aa8d8 Mon Sep 17 00:00:00 2001 From: yiguodev <147401898+yiguodev@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:09:49 +0800 Subject: [PATCH 14/38] Return validated outbound-only JSON from share conversion (#147) * Remove deprecated AllowInsecure share handling * Keep only outbounds from Xray JSON imports * Remove KCP seed and header share handling * Stop generating empty KCP share settings * Add share config JSON projection * Update libXray build workflow * Update workflow action versions --- .github/workflows/build.yml | 10 +- .github/workflows/libxray-test.yml | 4 +- .github/workflows/release-go-mirror.yml | 2 +- .github/workflows/validate.yml | 4 +- README.md | 5 + invoke.go | 8 +- invoke_test.go | 40 +++ readme/README.zh_CN.md | 2 +- share/clash_meta.go | 29 +- share/clash_meta_test.go | 5 +- share/generate_share.go | 29 -- share/generate_share_test.go | 20 ++ share/marshal_share.go | 368 ++++++++++++++++++++++++ share/marshal_share_test.go | 141 +++++++++ share/parse_share.go | 4 +- share/parse_share_test.go | 31 +- share/stream.go | 4 - share/transport_build.go | 32 +-- share/xray_json.go | 4 - 19 files changed, 640 insertions(+), 102 deletions(-) create mode 100644 share/marshal_share.go create mode 100644 share/marshal_share_test.go diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 49c04183..36bdc62f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -30,7 +30,7 @@ jobs: - runner: ubuntu-24.04 target: android artifact: android - - runner: windows-2022 + - runner: windows-2025 target: windows arch: x64 goarch: amd64 @@ -57,13 +57,13 @@ jobs: # Checkout # ========================= - name: Checkout source - uses: actions/checkout@v6 + uses: actions/checkout@v7 # ========================= # Python (build scripts) # ========================= - name: Setup Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.x" check-latest: true @@ -76,7 +76,7 @@ jobs: # Go setup # ========================= - name: Setup Go - uses: actions/setup-go@v6 + uses: actions/setup-go@v7 with: go-version: stable check-latest: true @@ -273,7 +273,7 @@ jobs: done - name: Create GitHub Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: name: libXray ${{ github.ref_name }} tag_name: ${{ github.ref_name }} diff --git a/.github/workflows/libxray-test.yml b/.github/workflows/libxray-test.yml index 044b999c..32dcc8a8 100644 --- a/.github/workflows/libxray-test.yml +++ b/.github/workflows/libxray-test.yml @@ -11,10 +11,10 @@ jobs: contents: read steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@v7 with: go-version: stable check-latest: true diff --git a/.github/workflows/release-go-mirror.yml b/.github/workflows/release-go-mirror.yml index d45255c5..a24f2ed5 100644 --- a/.github/workflows/release-go-mirror.yml +++ b/.github/workflows/release-go-mirror.yml @@ -30,7 +30,7 @@ jobs: contents: write steps: - name: Checkout (full history + tags) - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 fetch-tags: true diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 6fd42472..7f67360e 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -16,9 +16,9 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - - uses: actions/setup-go@v6 + - uses: actions/setup-go@v7 with: go-version: stable check-latest: true diff --git a/README.md b/README.md index e5fa0026..10eea4db 100644 --- a/README.md +++ b/README.md @@ -183,6 +183,11 @@ Design notes: 6. `convertShareLinksToXrayJson` validates each parsed outbound with the current Xray-core config builder. Invalid outbounds are omitted, and the method fails if none remain. Validation does not create or start an Xray instance. + Xray JSON input is treated as a node source: only its root `outbounds` are + retained, and all other root fields are ignored. The response contains only + fields supported by libXray share links; unsupported and generated empty + fields are omitted. Opaque XHTTP `extra` and FinalMask mask `settings` JSON + remain unchanged. Its optional `age.secretKey` decrypts official age ASCII armor in memory before the existing parser runs. Plaintext input remains unchanged. 7. Xray-core keeps its system dialer DNS client and outbound manager in diff --git a/invoke.go b/invoke.go index 4abcf6cc..f2134fe3 100644 --- a/invoke.go +++ b/invoke.go @@ -139,8 +139,12 @@ func invokeConvertShareLinksToXrayJson(payload json.RawMessage) string { if request.Age != nil { secretKey = request.Age.SecretKey } - xrayJson, err := share.ConvertShareLinksToXrayJsonWithAge(request.Text, secretKey) - return encodeInvokeResponse(xrayJson, err) + config, err := share.ConvertShareLinksToXrayJsonWithAge(request.Text, secretKey) + if err != nil { + return encodeInvokeResponse(nil, err) + } + xrayJSON, err := share.MarshalShareConfigJSON(config) + return encodeInvokeResponse(xrayJSON, err) } func invokeGenerateAgeKeyPair(payload json.RawMessage) string { diff --git a/invoke_test.go b/invoke_test.go index 45e10f41..78cd23f3 100644 --- a/invoke_test.go +++ b/invoke_test.go @@ -343,6 +343,46 @@ func TestInvokeConvertShareLinksFiltersBuildInvalidOutbounds(t *testing.T) { } } +func TestInvokeConvertShareLinksReturnsProjectedObject(t *testing.T) { + const publicKey = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + link := "vless://12345678-abcd-abcd-abcd-123456789abc@example.com:443" + + "?encryption=none&type=xhttp&host=cdn.example.com&path=%2Fx&mode=stream-up" + + "&security=reality&sni=example.com&fp=chrome&pbk=" + publicKey + "&sid=abcd" + response := invokeForTest( + t, + LibXrayMethodConvertShareLinksToXrayJson, + ConvertShareLinksToXrayJsonRequest{Text: link}, + ) + if !response.Success { + t.Fatalf("ConvertShareLinksToXrayJson failed: %s", response.Err) + } + + var root map[string]json.RawMessage + if err := json.Unmarshal(response.Data, &root); err != nil { + t.Fatalf("data is not an object: %s", response.Data) + } + if len(root) != 1 || root["outbounds"] == nil { + t.Fatalf("data root = %s, want only outbounds", response.Data) + } + for _, field := range []string{"publicKey", "target", "dest", "proxySettings", "sockopt"} { + if bytes.Contains(response.Data, []byte(`"`+field+`"`)) { + t.Fatalf("data contains unsupported field %q: %s", field, response.Data) + } + } + if !bytes.Contains(response.Data, []byte(`"password":"`+publicKey+`"`)) { + t.Fatalf("data did not canonicalize REALITY password: %s", response.Data) + } + + config := decodeDataObject[conf.Config](t, response) + if len(config.OutboundConfigs) != 1 { + t.Fatalf("outbounds = %d, want 1", len(config.OutboundConfigs)) + } + config.OutboundConfigs[0].SendThrough = nil + if _, err := config.OutboundConfigs[0].Build(); err != nil { + t.Fatalf("projected outbound does not build: %v", err) + } +} + func TestInvokeAgeKeyGenerationAndConversion(t *testing.T) { generated := invokeForTest( t, diff --git a/readme/README.zh_CN.md b/readme/README.zh_CN.md index 87ca9e54..a9e605c7 100644 --- a/readme/README.zh_CN.md +++ b/readme/README.zh_CN.md @@ -136,7 +136,7 @@ void CGoFree(char* value); 3. `SetTunFd` 已删除。如果 fd 只能在运行时获得,请在调用 `runXray` 前把 `xray.tun.fd` 写入 Xray 配置根 `env` 对象。 4. `countGeoData` 不依赖 Xray 配置,因此通过 method payload 的 `datDir` 传入数据目录。 5. 完整的 UTF-8 编码 Invoke 请求和响应 JSON 包体限制为 16 MiB。任一方向超过限制时,Invoke 将返回 `success: false`、`data: null` 和对应的大小限制错误。 -6. `convertShareLinksToXrayJson` 会使用当前 Xray-core 配置构建器校验每个已解析的 outbound。无效 outbound 会被忽略;如果没有剩余的有效 outbound,该方法返回失败。校验不会创建或启动 Xray instance。可选的 `age.secretKey` 会在现有解析流程前于内存中解密官方 age ASCII armor;明文输入保持原有行为。 +6. `convertShareLinksToXrayJson` 会使用当前 Xray-core 配置构建器校验每个已解析的 outbound。无效 outbound 会被忽略;如果没有剩余的有效 outbound,该方法返回失败。校验不会创建或启动 Xray instance。Xray JSON 输入仅作为节点来源,只保留根级 `outbounds`,忽略其他根字段。响应仅包含 libXray 分享链接支持的字段,不支持的字段和生成的空字段会被省略;XHTTP `extra` 与 FinalMask mask `settings` 中的原始 JSON 保持不变。可选的 `age.secretKey` 会在现有解析流程前于内存中解密官方 age ASCII armor;明文输入保持原有行为。 7. Xray-core 的系统拨号 DNS client 和 outbound manager 属于进程级状态。当 `runXray` 正在运行时,通过 `pingBatch`、`testXray` 或导出的 Go API 创建另一个 Xray instance,可能覆盖这些状态并影响正在运行的 instance。关闭临时 instance 不会恢复之前的状态。libXray 不对并发 instance 进行串行化、隔离或状态恢复;调用方如需同时运行多个 instance,必须将它们放在不同进程中。 支持的 method: diff --git a/share/clash_meta.go b/share/clash_meta.go index ce8d3022..315f9ae7 100644 --- a/share/clash_meta.go +++ b/share/clash_meta.go @@ -35,11 +35,10 @@ type ClashProxy struct { Udp bool `yaml:"udp,omitempty"` UdpOverTcp bool `yaml:"udp-over-tcp,omitempty"` - Tls bool `yaml:"tls,omitempty"` - SkipCertVerify bool `yaml:"skip-cert-verify,omitempty"` - Servername string `yaml:"servername,omitempty"` - Sni string `yaml:"sni,omitempty"` - Alpn []string `yaml:"alpn,omitempty"` + Tls bool `yaml:"tls,omitempty"` + Servername string `yaml:"servername,omitempty"` + Sni string `yaml:"sni,omitempty"` + Alpn []string `yaml:"alpn,omitempty"` Fingerprint string `yaml:"fingerprint,omitempty"` ClientFingerprint string `yaml:"client-fingerprint,omitempty"` @@ -66,14 +65,13 @@ type ClashProxyRealityOpts struct { } type ClashProxyPluginOpts struct { - Mode string `yaml:"mode,omitempty"` - Tls bool `yaml:"tls,omitempty"` - Fingerprint string `yaml:"fingerprint,omitempty"` - EchOpts *ClashProxyEchOpts `yaml:"ech-opts,omitempty"` - SkipCertVerify bool `yaml:"skip-cert-verify,omitempty"` - Host string `yaml:"host,omitempty"` - Path string `yaml:"path,omitempty"` - Mux bool `yaml:"mux,omitempty"` + Mode string `yaml:"mode,omitempty"` + Tls bool `yaml:"tls,omitempty"` + Fingerprint string `yaml:"fingerprint,omitempty"` + EchOpts *ClashProxyEchOpts `yaml:"ech-opts,omitempty"` + Host string `yaml:"host,omitempty"` + Path string `yaml:"path,omitempty"` + Mux bool `yaml:"mux,omitempty"` } type ClashProxyWsOpts struct { @@ -125,7 +123,6 @@ type ClashProxyXhttpOptsDownloadSettings struct { Alpn []string `yaml:"alpn,omitempty"` EchOpts *ClashProxyEchOpts `yaml:"ech-opts,omitempty"` RealityOpts *ClashProxyRealityOpts `yaml:"reality-opts,omitempty"` - SkipCertVerify bool `yaml:"skip-cert-verify,omitempty"` Fingerprint string `yaml:"fingerprint,omitempty"` Servername string `yaml:"servername,omitempty"` ClientFingerprint string `yaml:"client-fingerprint,omitempty"` @@ -500,8 +497,6 @@ func (proxy ClashProxy) parseSecurity(streamSettings *conf.StreamConfig, outboun realitySettings.Fingerprint = proxy.ClientFingerprint } - tlsSettings.AllowInsecure = proxy.SkipCertVerify - if (outbound.Protocol == "trojan" || outbound.Protocol == "hysteria") && len(streamSettings.Security) == 0 { streamSettings.Security = "tls" } @@ -695,8 +690,6 @@ func parseXHTTPDownloadSettingsSecurity(streamSettings *conf.StreamConfig, proxy realitySettings.Fingerprint = proxy.ClientFingerprint } - tlsSettings.AllowInsecure = proxy.SkipCertVerify - switch streamSettings.Security { case "tls": streamSettings.TLSSettings = tlsSettings diff --git a/share/clash_meta_test.go b/share/clash_meta_test.go index 4733eccc..9563f448 100644 --- a/share/clash_meta_test.go +++ b/share/clash_meta_test.go @@ -269,7 +269,6 @@ func TestClashVless_XhttpExtraDownloadAndRanges(t *testing.T) { ech-opts: enable: true config: echcfg123 - skip-cert-verify: true xhttp-opts: path: /xh host: xh.h @@ -342,15 +341,13 @@ func TestClashTrojan_TlsFromType(t *testing.T) { server: tr.host port: 443 password: trpass - sni: tr.host - skip-cert-verify: true` + sni: tr.host` cfg := parseClashYAML(t, yaml) require.Len(t, cfg.OutboundConfigs, 1) ss := cfg.OutboundConfigs[0].StreamSetting require.NotNil(t, ss) assert.Equal(t, "tls", ss.Security) require.NotNil(t, ss.TLSSettings) - assert.True(t, ss.TLSSettings.AllowInsecure) } func TestClashVless_Reality(t *testing.T) { diff --git a/share/generate_share.go b/share/generate_share.go index 340654e0..8f32f009 100644 --- a/share/generate_share.go +++ b/share/generate_share.go @@ -274,9 +274,6 @@ func streamSettingsQuery(proxy conf.OutboundDetourConfig, link *url.URL) { if len(vcn) > 0 { query = addQuery(query, "vcn", vcn) } - if streamSettings.TLSSettings.AllowInsecure { - query = addQuery(query, "insecure", "1") - } } // QuicParams (bandwidth + port-hopping) @@ -353,29 +350,6 @@ func streamSettingsQuery(proxy conf.OutboundDetourConfig, link *url.URL) { query = addQuery(query, "host", strings.Join(host, ",")) } } - case "kcp": - if streamSettings.KCPSettings == nil { - break - } - seed := streamSettings.KCPSettings.Seed - if seed != nil && len(*seed) > 0 { - query = addQuery(query, "seed", *seed) - } - - headerConfig := streamSettings.KCPSettings.HeaderConfig - if headerConfig == nil { - break - } - var header XrayFakeHeader - err := json.Unmarshal(headerConfig, &header) - if err != nil { - break - } - - headerType := header.Type - if len(headerType) > 0 { - query = addQuery(query, "headerType", headerType) - } case "ws": if streamSettings.WSSettings == nil { break @@ -476,9 +450,6 @@ func streamSettingsQuery(proxy conf.OutboundDetourConfig, link *url.URL) { if len(vcn) > 0 { query = addQuery(query, "vcn", vcn) } - if streamSettings.TLSSettings.AllowInsecure { - query = addQuery(query, "insecure", "1") - } case "reality": if streamSettings.REALITYSettings == nil { break diff --git a/share/generate_share_test.go b/share/generate_share_test.go index 0b9c844e..f8b8e21e 100644 --- a/share/generate_share_test.go +++ b/share/generate_share_test.go @@ -173,6 +173,26 @@ func TestConvertXrayJsonToShareLinks_RoundTripProtocols(t *testing.T) { } } +func TestGenerate_KCPIgnoresSeedAndHeader(t *testing.T) { + config, err := ConvertShareLinksToXrayJson( + "vless://" + testShareUUID + "@kcp.example:443?encryption=none&type=kcp", + ) + require.NoError(t, err) + + seed := "legacy-seed" + header := json.RawMessage(`{"type":"srtp"}`) + config.OutboundConfigs[0].StreamSetting.KCPSettings = &conf.KCPConfig{ + Seed: &seed, + HeaderConfig: header, + } + + link, err := shareLink(config.OutboundConfigs[0]) + require.NoError(t, err) + assert.Equal(t, "kcp", link.Query().Get("type")) + assert.Empty(t, link.Query().Get("seed")) + assert.Empty(t, link.Query().Get("headerType")) +} + func TestGenerate_ShadowsocksAEAD2022PlainUserInfo(t *testing.T) { const original = "ss://2022-blake3-aes-256-gcm:" + "YctPZ6U7xPPcU%2Bgp3u%2B0tx%2FtRizJN9K8y%2BuKlW2qjlI%3D" + diff --git a/share/marshal_share.go b/share/marshal_share.go new file mode 100644 index 00000000..729cd64d --- /dev/null +++ b/share/marshal_share.go @@ -0,0 +1,368 @@ +package share + +import ( + "bytes" + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/xtls/xray-core/infra/conf" +) + +// MarshalShareConfigJSON returns the Xray JSON subset supported by share links. +func MarshalShareConfigJSON(config *conf.Config) (json.RawMessage, error) { + if config == nil { + return nil, fmt.Errorf("no valid outbound found") + } + + outbounds := make([]map[string]any, 0, len(config.OutboundConfigs)) + var firstBuildError error + for _, outbound := range config.OutboundConfigs { + source, err := marshalShareJSONObject(outbound) + if err != nil { + return nil, err + } + projected, supported := projectShareOutbound(source) + if !supported { + continue + } + if err := validateProjectedShareOutbound(projected); err != nil { + if firstBuildError == nil { + firstBuildError = err + } + continue + } + outbounds = append(outbounds, projected) + } + + if len(outbounds) == 0 { + if firstBuildError != nil { + return nil, fmt.Errorf("no valid outbound found: %w", firstBuildError) + } + return nil, fmt.Errorf("no valid outbound found") + } + + raw, err := json.Marshal(map[string]any{"outbounds": outbounds}) + if err != nil { + return nil, fmt.Errorf("failed to marshal share config: %w", err) + } + return raw, nil +} + +func marshalShareJSONObject(value any) (map[string]any, error) { + raw, err := json.Marshal(value) + if err != nil { + return nil, fmt.Errorf("failed to marshal share config: %w", err) + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + var object map[string]any + if err := decoder.Decode(&object); err != nil { + return nil, fmt.Errorf("failed to marshal share config: %w", err) + } + return object, nil +} + +func projectShareOutbound(source map[string]any) (map[string]any, bool) { + protocol := strings.ToLower(shareString(source["protocol"])) + settings, _ := shareObject(source["settings"]) + + projectedSettings := map[string]any{} + switch protocol { + case "shadowsocks": + copyShareFields(projectedSettings, settings, "address", "port", "method", "password") + case "vmess": + copyShareFields(projectedSettings, settings, "address", "port", "id", "security") + case "vless": + copyShareFields(projectedSettings, settings, "address", "port", "id", "flow", "encryption") + case "socks": + copyShareFields(projectedSettings, settings, "address", "port", "user", "pass") + case "trojan": + copyShareFields(projectedSettings, settings, "address", "port", "password") + case "hysteria": + copyShareFields(projectedSettings, settings, "version", "address", "port") + default: + return nil, false + } + + projected := map[string]any{ + "protocol": protocol, + "settings": projectedSettings, + } + copyShareFields(projected, source, "sendThrough", "tag") + + if stream, ok := shareObject(source["streamSettings"]); ok { + projectedStream, supported := projectShareStream(stream) + if !supported { + return nil, false + } + if len(projectedStream) > 0 { + projected["streamSettings"] = projectedStream + } + } + return projected, true +} + +func projectShareStream(source map[string]any) (map[string]any, bool) { + networkValue := shareString(source["network"]) + if method := shareString(source["method"]); method != "" { + networkValue = method + } + network, supported := canonicalShareNetwork(networkValue) + if !supported { + return nil, false + } + + securityValue := shareString(source["security"]) + security := strings.ToLower(securityValue) + if security != "" && security != "none" && security != "tls" && security != "reality" { + return nil, false + } + + projected := map[string]any{} + if networkValue != "" { + projected["network"] = network + } + if securityValue != "" { + projected["security"] = security + } + + switch network { + case "raw": + if settings, ok := firstShareObject(source, "rawSettings", "tcpSettings"); ok { + if value := projectShareRAWSettings(settings); len(value) > 0 { + projected["rawSettings"] = value + } + } + case "kcp": + // KCP share links carry only the transport discriminator. + case "ws": + projectShareTransportSettings(projected, source, "wsSettings", "host", "path") + case "grpc": + projectShareTransportSettings(projected, source, "grpcSettings", "authority", "serviceName", "multiMode") + case "httpupgrade": + projectShareTransportSettings(projected, source, "httpupgradeSettings", "host", "path") + case "xhttp": + if settings, ok := firstShareObject(source, "xhttpSettings", "splithttpSettings"); ok { + value := map[string]any{} + copyShareFields(value, settings, "host", "path", "mode", "extra") + if len(value) > 0 { + projected["xhttpSettings"] = value + } + } + case "hysteria": + projectShareTransportSettings(projected, source, "hysteriaSettings", "version", "auth") + } + + switch security { + case "tls": + if settings, ok := shareObject(source["tlsSettings"]); ok { + value := map[string]any{} + copyShareFields(value, settings, + "serverName", "alpn", "fingerprint", "echConfigList", + "pinnedPeerCertSha256", "verifyPeerCertByName", + ) + if len(value) > 0 { + projected["tlsSettings"] = value + } + } + case "reality": + if settings, ok := shareObject(source["realitySettings"]); ok { + value := map[string]any{} + copyShareFields(value, settings, + "serverName", "fingerprint", "shortId", "mldsa65Verify", "spiderX", + ) + if password := shareString(settings["password"]); password != "" { + value["password"] = password + } else if publicKey := shareString(settings["publicKey"]); publicKey != "" { + value["password"] = publicKey + } + if len(value) > 0 { + projected["realitySettings"] = value + } + } + } + + if finalMask, ok := shareObject(source["finalmask"]); ok { + if value := projectShareFinalMask(finalMask); len(value) > 0 { + projected["finalmask"] = value + } + } + return projected, true +} + +func canonicalShareNetwork(network string) (string, bool) { + switch strings.ToLower(network) { + case "", "raw", "tcp": + return "raw", true + case "kcp", "mkcp": + return "kcp", true + case "ws", "websocket": + return "ws", true + case "grpc": + return "grpc", true + case "httpupgrade": + return "httpupgrade", true + case "xhttp", "splithttp": + return "xhttp", true + case "hysteria": + return "hysteria", true + default: + return "", false + } +} + +func projectShareRAWSettings(source map[string]any) map[string]any { + header, ok := shareObject(source["header"]) + if !ok || strings.ToLower(shareString(header["type"])) != "http" { + return nil + } + + projectedHeader := map[string]any{"type": "http"} + if request, ok := shareObject(header["request"]); ok { + projectedRequest := map[string]any{} + copyShareFields(projectedRequest, request, "path") + if headers, ok := shareObject(request["headers"]); ok { + projectedHeaders := map[string]any{} + copyShareFields(projectedHeaders, headers, "Host") + if len(projectedHeaders) > 0 { + projectedRequest["headers"] = projectedHeaders + } + } + if len(projectedRequest) > 0 { + projectedHeader["request"] = projectedRequest + } + } + return map[string]any{"header": projectedHeader} +} + +func projectShareTransportSettings(projected, source map[string]any, field string, keys ...string) { + settings, ok := shareObject(source[field]) + if !ok { + return + } + value := map[string]any{} + copyShareFields(value, settings, keys...) + if len(value) > 0 { + projected[field] = value + } +} + +func projectShareFinalMask(source map[string]any) map[string]any { + projected := map[string]any{} + for _, field := range []string{"tcp", "udp"} { + masks, ok := source[field].([]any) + if !ok { + continue + } + projectedMasks := make([]map[string]any, 0, len(masks)) + for _, item := range masks { + mask, ok := shareObject(item) + if !ok { + continue + } + maskType := shareString(mask["type"]) + if maskType == "" { + continue + } + projectedMask := map[string]any{"type": maskType} + if settings, exists := mask["settings"]; exists && !emptyShareValue(settings) { + projectedMask["settings"] = settings + } + projectedMasks = append(projectedMasks, projectedMask) + } + if len(projectedMasks) > 0 { + projected[field] = projectedMasks + } + } + + if params, ok := shareObject(source["quicParams"]); ok { + projectedParams := map[string]any{} + copyShareFields(projectedParams, params, "congestion", "brutalUp", "brutalDown") + if udpHop, ok := shareObject(params["udpHop"]); ok { + projectedUDPHop := map[string]any{} + copyShareFields(projectedUDPHop, udpHop, "ports") + if interval, exists := udpHop["interval"]; exists && !emptyShareRange(interval) { + projectedUDPHop["interval"] = interval + } + if len(projectedUDPHop) > 0 { + projectedParams["udpHop"] = projectedUDPHop + } + } + if len(projectedParams) > 0 { + projected["quicParams"] = projectedParams + } + } + return projected +} + +func validateProjectedShareOutbound(projected map[string]any) error { + raw, err := json.Marshal(projected) + if err != nil { + return err + } + var outbound conf.OutboundDetourConfig + if err := json.Unmarshal(raw, &outbound); err != nil { + return err + } + // sendThrough stores the node display name during share conversion. + outbound.SendThrough = nil + _, err = outbound.Build() + return err +} + +func copyShareFields(target, source map[string]any, fields ...string) { + for _, field := range fields { + if value, ok := source[field]; ok && !emptyShareValue(value) { + target[field] = value + } + } +} + +func firstShareObject(source map[string]any, fields ...string) (map[string]any, bool) { + for _, field := range fields { + if object, ok := shareObject(source[field]); ok { + return object, true + } + } + return nil, false +} + +func shareObject(value any) (map[string]any, bool) { + object, ok := value.(map[string]any) + return object, ok +} + +func shareString(value any) string { + text, _ := value.(string) + return text +} + +func emptyShareValue(value any) bool { + switch value := value.(type) { + case nil: + return true + case string: + return value == "" + case bool: + return !value + case json.Number: + number, err := strconv.ParseFloat(value.String(), 64) + return err == nil && number == 0 + case []any: + return len(value) == 0 + case map[string]any: + return len(value) == 0 + default: + return false + } +} + +func emptyShareRange(value any) bool { + if text, ok := value.(string); ok { + number, err := strconv.ParseInt(text, 10, 32) + return err == nil && number == 0 + } + return emptyShareValue(value) +} diff --git a/share/marshal_share_test.go b/share/marshal_share_test.go new file mode 100644 index 00000000..e915bcba --- /dev/null +++ b/share/marshal_share_test.go @@ -0,0 +1,141 @@ +package share + +import ( + "encoding/base64" + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/xtls/xray-core/infra/conf" +) + +func TestMarshalShareConfigJSONProjectsSupportedFields(t *testing.T) { + const input = `{ + "log":{"loglevel":"warning"}, + "outbounds":[ + {"protocol":"freedom","settings":{}}, + { + "protocol":"vless", + "settings":{"address":"drop.example","port":443,"id":"12345678-abcd-abcd-abcd-123456789abc","encryption":"none"}, + "streamSettings":{"network":"quic","security":"tls","tlsSettings":{"fingerprint":"chrome"}} + }, + { + "protocol":"vless", + "settings":{"address":"drop.example","port":443,"id":"12345678-abcd-abcd-abcd-123456789abc","encryption":"none"}, + "streamSettings":{"network":"raw","security":"xtls"} + }, + { + "protocol":"VLESS", + "sendThrough":"Node name", + "tag":"tag fallback", + "settings":{ + "address":"example.com","port":443,"id":"12345678-abcd-abcd-abcd-123456789abc", + "flow":"","encryption":"none","level":1,"email":"drop@example.com","seed":"drop","reverse":{} + }, + "streamSettings":{ + "address":"drop.example","port":8443,"network":"splithttp","security":"REALITY", + "xhttpSettings":{ + "host":"cdn.example.com","path":"/xhttp","mode":"stream-up","headers":{"X-Drop":"yes"}, + "extra":{"maxConcurrency":"1-2","empty":null} + }, + "realitySettings":{ + "show":false,"target":null,"dest":null,"serverNames":null,"privateKey":"", + "serverName":"example.com","fingerprint":"chrome","password":"", + "publicKey":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","shortId":"abcd","spiderX":"/" + }, + "sockopt":{"dialerProxy":"drop"} + }, + "proxySettings":{"tag":"drop"},"mux":{"enabled":true},"targetStrategy":"UseIP" + } + ] + }` + + var config conf.Config + require.NoError(t, json.Unmarshal([]byte(input), &config)) + raw, err := MarshalShareConfigJSON(&config) + require.NoError(t, err) + + const expected = `{ + "outbounds":[{ + "protocol":"vless", + "sendThrough":"Node name", + "tag":"tag fallback", + "settings":{"address":"example.com","port":443,"id":"12345678-abcd-abcd-abcd-123456789abc","encryption":"none"}, + "streamSettings":{ + "network":"xhttp","security":"reality", + "xhttpSettings":{"host":"cdn.example.com","path":"/xhttp","mode":"stream-up","extra":{"maxConcurrency":"1-2","empty":null}}, + "realitySettings":{"serverName":"example.com","fingerprint":"chrome","password":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","shortId":"abcd","spiderX":"/"} + } + }] + }` + assert.JSONEq(t, expected, string(raw)) + requireProjectedOutboundsBuild(t, raw) +} + +func TestMarshalShareConfigJSONPreservesHysteriaPortHopping(t *testing.T) { + config, err := ConvertShareLinksToXrayJson( + "hy2://auth@host:443?up=50+mbps&down=100+mbps&ports=20000-40000&hop-interval=30&sni=example.com&fp=chrome", + ) + require.NoError(t, err) + raw, err := MarshalShareConfigJSON(config) + require.NoError(t, err) + + var document map[string]any + require.NoError(t, json.Unmarshal(raw, &document)) + outbound := document["outbounds"].([]any)[0].(map[string]any) + stream := outbound["streamSettings"].(map[string]any) + finalMask := stream["finalmask"].(map[string]any) + quicParams := finalMask["quicParams"].(map[string]any) + udpHop := quicParams["udpHop"].(map[string]any) + assert.Equal(t, "20000-40000", udpHop["ports"]) + assert.Equal(t, "30", udpHop["interval"]) + requireProjectedOutboundsBuild(t, raw) +} + +func TestMarshalShareConfigJSONKeepsKCPWithoutSettings(t *testing.T) { + qr := `{"ps":"k","add":"kcp.host","port":"8391","id":"` + testShareUUID + `","net":"kcp","path":"seedval","type":"wireguard"}` + link := "vmess://" + base64.StdEncoding.EncodeToString([]byte(qr)) + config, err := ConvertShareLinksToXrayJson(link) + require.NoError(t, err) + raw, err := MarshalShareConfigJSON(config) + require.NoError(t, err) + + assert.Contains(t, string(raw), `"network":"kcp"`) + assert.NotContains(t, string(raw), "kcpSettings") + assert.NotContains(t, string(raw), "seedval") + assert.NotContains(t, string(raw), "wireguard") + requireProjectedOutboundsBuild(t, raw) +} + +func TestMarshalShareConfigJSONSupportedProtocolsBuild(t *testing.T) { + tests := map[string]string{ + "shadowsocks": "ss://" + ssUserB64("chacha20-ietf-poly1305", "password") + "@10.0.0.1:8388", + "vmess": "vmess://" + testShareUUID + "@vm.example:443?encryption=auto&type=raw", + "vless": "vless://" + testShareUUID + "@10.0.0.1:443?encryption=none&security=none", + "socks": "socks://" + base64.StdEncoding.EncodeToString([]byte("user:password")) + "@127.0.0.1:1080", + "trojan": "trojan://password@trojan.example:443?sni=trojan.example", + "hysteria": "hy2://auth@hysteria.example:443?sni=hysteria.example&fp=chrome", + } + for protocol, link := range tests { + t.Run(protocol, func(t *testing.T) { + config, err := ConvertShareLinksToXrayJson(link) + require.NoError(t, err) + raw, err := MarshalShareConfigJSON(config) + require.NoError(t, err) + requireProjectedOutboundsBuild(t, raw) + }) + } +} + +func requireProjectedOutboundsBuild(t *testing.T, raw json.RawMessage) { + t.Helper() + var config conf.Config + require.NoError(t, json.Unmarshal(raw, &config)) + require.NotEmpty(t, config.OutboundConfigs) + for index := range config.OutboundConfigs { + config.OutboundConfigs[index].SendThrough = nil + _, err := config.OutboundConfigs[index].Build() + require.NoError(t, err) + } +} diff --git a/share/parse_share.go b/share/parse_share.go index 9cfcb005..7a24af6e 100644 --- a/share/parse_share.go +++ b/share/parse_share.go @@ -44,7 +44,7 @@ func decodeBase64Text(text string) (string, error) { // https://github.com/XTLS/Xray-core/discussions/716 // ConvertShareLinksToXrayJson parses: -// - a single Xray JSON object (starts with '{') +// - a single Xray JSON object (starts with '{'; only root outbounds are retained) // - plain v2rayN-style lines (vless/vmess/ss/socks/trojan/hy2…) // - one base64 blob that decodes to Xray JSON, share lines, or Clash YAML // - Clash / Clash.Meta YAML (proxies:) @@ -87,7 +87,7 @@ func parseXrayJSONConfig(text string) (*conf.Config, error) { if len(xray.OutboundConfigs) == 0 { return nil, fmt.Errorf("no valid outbounds") } - return xray, nil + return &conf.Config{OutboundConfigs: xray.OutboundConfigs}, nil } var shareSchemes = []string{ diff --git a/share/parse_share_test.go b/share/parse_share_test.go index f97e2584..6ccdc1ee 100644 --- a/share/parse_share_test.go +++ b/share/parse_share_test.go @@ -173,6 +173,25 @@ func TestConvertShareLinksToXrayJson_XrayJSONRoundTrip(t *testing.T) { assert.Equal(t, orig.OutboundConfigs[0].Protocol, again.OutboundConfigs[0].Protocol) } +func TestConvertShareLinksToXrayJson_XrayJSONKeepsOnlyOutbounds(t *testing.T) { + config, err := ConvertShareLinksToXrayJson(`{ + "env": {"TEST_ENV": "value"}, + "log": {"loglevel": "debug"}, + "routing": {"rules": []}, + "dns": {"servers": ["8.8.8.8"]}, + "inbounds": [{"protocol": "http", "listen": "127.0.0.1", "port": 1080, "settings": {}}], + "outbounds": [{"protocol": "freedom", "tag": "direct", "settings": {}}], + "policy": {"levels": {}}, + "metrics": {"tag": "metrics"}, + "stats": {}, + "fakeDns": {"ipPool": "198.18.0.0/15", "poolSize": 65535} + }`) + require.NoError(t, err) + require.Len(t, config.OutboundConfigs, 1) + assert.Equal(t, "direct", config.OutboundConfigs[0].Tag) + assert.Equal(t, &conf.Config{OutboundConfigs: config.OutboundConfigs}, config) +} + func TestConvertShareLinksToXrayJson_XrayJSONInvalid(t *testing.T) { _, err := ConvertShareLinksToXrayJson("{not json") require.Error(t, err) @@ -365,9 +384,9 @@ func TestConvertShareLinksToXrayJson_VmessBase64QR(t *testing.T) { func TestConvertShareLinksToXrayJson_TransportKcpGrpcHttpUpgradeXhttp(t *testing.T) { t.Run("kcp", func(t *testing.T) { link := "vless://" + testShareUUID + "@k.example:443?encryption=none&type=kcp&headerType=srtp&seed=myseed" - _, err := ConvertShareLinksToXrayJson(link) - require.Error(t, err) - assert.Contains(t, err.Error(), "mkcp header & seed has been removed") + cfg, err := ConvertShareLinksToXrayJson(link) + require.NoError(t, err) + assert.Nil(t, cfg.OutboundConfigs[0].StreamSetting.KCPSettings) }) t.Run("grpc", func(t *testing.T) { @@ -527,9 +546,9 @@ func TestConvertShareLinksToXrayJson_VmessQRGrpcAndKcp(t *testing.T) { t.Run("kcp", func(t *testing.T) { qr := `{"ps":"k","add":"kcp.host","port":"8391","id":"` + testShareUUID + `","net":"kcp","path":"seedval","type":"wireguard"}` link := "vmess://" + base64.StdEncoding.EncodeToString([]byte(qr)) - _, err := ConvertShareLinksToXrayJson(link) - require.Error(t, err) - assert.Contains(t, err.Error(), "mkcp header & seed has been removed") + cfg, err := ConvertShareLinksToXrayJson(link) + require.NoError(t, err) + assert.Nil(t, cfg.OutboundConfigs[0].StreamSetting.KCPSettings) }) } diff --git a/share/stream.go b/share/stream.go index b488f272..d6b822ee 100644 --- a/share/stream.go +++ b/share/stream.go @@ -47,10 +47,6 @@ func (proxy xrayShareLink) parseSecurityFromURL(link *url.URL, streamSettings *c tlsSettings.ALPN = new(conf.StringList(strings.Split(alpn, ","))) } - if query.Get("insecure") == "1" { - tlsSettings.AllowInsecure = true - } - pbk := query.Get("pbk") realitySettings.Password = pbk realitySettings.PublicKey = pbk diff --git a/share/transport_build.go b/share/transport_build.go index 11f9d6ba..060b29b4 100644 --- a/share/transport_build.go +++ b/share/transport_build.go @@ -14,7 +14,6 @@ type shareTransportFields struct { HeaderType string Path string Host string - Seed string // KCP seed (URL query or VMess QR path-as-seed) GrpcAuthority string GrpcServiceName string GrpcMultiMode bool @@ -28,12 +27,10 @@ func transportFieldsFromURLQuery(q url.Values) shareTransportFields { if network == "" { network = "raw" } - return shareTransportFields{ + fields := shareTransportFields{ Network: network, - HeaderType: q.Get("headerType"), Path: q.Get("path"), Host: q.Get("host"), - Seed: q.Get("seed"), GrpcAuthority: q.Get("authority"), GrpcServiceName: q.Get("serviceName"), GrpcMultiMode: q.Get("mode") == "multi", @@ -41,6 +38,10 @@ func transportFieldsFromURLQuery(q url.Values) shareTransportFields { ExtraJSON: q.Get("extra"), FMJSON: q.Get("fm"), } + if network == "raw" || network == "tcp" { + fields.HeaderType = q.Get("headerType") + } + return fields } func transportFieldsFromVmessQR(p vmessQrCode) shareTransportFields { @@ -49,17 +50,16 @@ func transportFieldsFromVmessQR(p vmessQrCode) shareTransportFields { network = "raw" } t := shareTransportFields{ - Network: network, - HeaderType: p.Type, - Path: p.Path, - Host: p.Host, + Network: network, + Path: p.Path, + Host: p.Host, } switch network { + case "raw", "tcp": + t.HeaderType = p.Type case "grpc", "gun": t.GrpcServiceName = p.Path t.GrpcMultiMode = p.Type == "multi" - case "kcp", "mkcp": - t.Seed = p.Path } if network == "xhttp" || network == "splithttp" { t.XHTTPMode = p.Type @@ -95,18 +95,6 @@ func buildStreamFromTransportFields(t shareTransportFields) (*conf.StreamConfig, rawSettings.HeaderConfig = headerRawMessage streamSettings.RAWSettings = rawSettings } - case "kcp", "mkcp": - kcpSettings := &conf.KCPConfig{} - if t.HeaderType != "" { - header := XrayFakeHeader{Type: t.HeaderType} - headerRawMessage, err := convertJsonToRawMessage(header) - if err != nil { - return nil, err - } - kcpSettings.HeaderConfig = headerRawMessage - } - kcpSettings.Seed = new(t.Seed) - streamSettings.KCPSettings = kcpSettings case "ws", "websocket": streamSettings.WSSettings = &conf.WebSocketConfig{Path: t.Path, Host: t.Host} case "grpc", "gun": diff --git a/share/xray_json.go b/share/xray_json.go index fd29bbd0..78589109 100644 --- a/share/xray_json.go +++ b/share/xray_json.go @@ -21,10 +21,6 @@ type XrayRawSettingsHeaderRequestHeaders struct { Host []string `json:"Host,omitempty"` } -type XrayFakeHeader struct { - Type string `json:"type,omitempty"` -} - func setOutboundName(outbound *conf.OutboundDetourConfig, name string) { outbound.SendThrough = &name } From 15e88365296a6f955e5e38caa2d02c97b499733f Mon Sep 17 00:00:00 2001 From: yiguodev <147401898+yiguodev@users.noreply.github.com> Date: Fri, 28 Aug 2026 11:39:09 +0800 Subject: [PATCH 15/38] Restore protected desktop DNS resolver (#148) * Restore protected desktop DNS resolver * Fix protected DNS resolver lifecycle * Preflight desktop DNS interface binding --- .github/workflows/build.yml | 28 +++++++++-- .github/workflows/validate.yml | 3 +- AGENTS.md | 13 +++-- README.md | 10 ++++ build/app/build.py | 22 +++++++++ build/app/linux.py | 2 + build/app/windows.py | 2 + desktop_bin/main.go | 90 ++++++++++++++++++++++++++++++++++ desktop_bin/main_test.go | 24 +++++++++ dns/dns_android.go | 25 +--------- dns/dns_desktop.go | 47 ++++++++++++++++++ dns/dns_desktop_test.go | 70 ++++++++++++++++++++++++++ dns/dns_linux.go | 17 +++++++ dns/dns_windows.go | 43 ++++++++++++++++ dns/resolver.go | 70 ++++++++++++++++++++++---- dns/resolver_test.go | 53 ++++++++++++++++++++ go.mod | 2 +- readme/README.zh_CN.md | 10 ++++ 18 files changed, 487 insertions(+), 44 deletions(-) create mode 100644 desktop_bin/main.go create mode 100644 desktop_bin/main_test.go create mode 100644 dns/dns_desktop.go create mode 100644 dns/dns_desktop_test.go create mode 100644 dns/dns_linux.go create mode 100644 dns/dns_windows.go 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 还是 From 33517b045fabde2cc95c4d43d04cb3b81d441f21 Mon Sep 17 00:00:00 2001 From: yiguodev <147401898+yiguodev@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:40:19 +0800 Subject: [PATCH 16/38] Add managed runtime accounting and probe APIs (#150) * Use outbound tags for share names * feat: add isolated route checking and build-only validation * feat: persist managed Xray session traffic snapshots * feat: report share parsing counts and probe server locations * feat: probe full routing configurations without binding listeners * build: record effective native build inputs and gomobile version * feat: expose saved runtime statistics over loopback HTTP * refactor: return raw ping location responses * refactor: simplify managed runtime accounting * refactor: make runtime snapshots internal * fix: address probe review feedback * refactor: unify share conversion API * chore: require Go 1.27.1 * refactor: simplify configuration validation API * build: remove build input metadata * refactor: simplify share parsing and runtime accounting --- AGENTS.md | 271 ++++++------------------ README.md | 209 ++++++++++++++++--- build/app/android.py | 5 +- build/app/apple_go.py | 5 +- build/app/apple_gomobile.py | 5 +- build/app/build.py | 8 +- build/app/linux.py | 5 +- build/app/windows.py | 5 +- build/test_build.py | 74 +++++++ desktop_bin/main.go | 26 ++- desktop_bin/main_test.go | 4 + go.mod | 2 +- invoke.go | 21 +- invoke_model.go | 31 ++- invoke_probes_test.go | 93 +++++++++ invoke_test.go | 144 ++++++++++--- readme/README.zh_CN.md | 159 +++++++++++++-- share/age.go | 23 +-- share/age_test.go | 24 +-- share/clash_meta.go | 69 +------ share/clash_meta_test.go | 6 +- share/convert_share.go | 120 +++++++++++ share/convert_share_test.go | 129 ++++++++++++ share/generate_share_test.go | 20 +- share/marshal_share.go | 22 +- share/marshal_share_test.go | 31 ++- share/parse_share.go | 106 +--------- share/parse_share_test.go | 81 ++++---- share/test_helpers_test.go | 32 +++ share/validate_outbound.go | 10 - share/xray_json.go | 7 +- xray/ping_batch.go | 76 +++++-- xray/ping_location_test.go | 131 ++++++++++++ xray/runtime.go | 263 ++++++++++++++++++++++++ xray/runtime_file.go | 26 +++ xray/runtime_file_windows.go | 23 +++ xray/runtime_http.go | 81 ++++++++ xray/runtime_http_test.go | 189 +++++++++++++++++ xray/runtime_test.go | 385 +++++++++++++++++++++++++++++++++++ xray/validation.go | 26 ++- xray/xray.go | 38 +++- xray/xray_test.go | 28 +++ 42 files changed, 2370 insertions(+), 643 deletions(-) create mode 100644 build/test_build.py create mode 100644 invoke_probes_test.go create mode 100644 share/convert_share.go create mode 100644 share/convert_share_test.go create mode 100644 share/test_helpers_test.go create mode 100644 xray/ping_location_test.go create mode 100644 xray/runtime.go create mode 100644 xray/runtime_file.go create mode 100644 xray/runtime_file_windows.go create mode 100644 xray/runtime_http.go create mode 100644 xray/runtime_http_test.go create mode 100644 xray/runtime_test.go diff --git a/AGENTS.md b/AGENTS.md index fe403bbe..883d63a1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,215 +1,70 @@ -# Project Overview - -libXray is a Go wrapper around Xray-core for mobile and desktop applications. -It exposes one structured JSON entrypoint, provides share-link and GeoData -utilities, and builds native artifacts for Android, Apple platforms, Linux, and -Windows. - -The public Invoke contract is intentionally small. Platform applications should -construct typed request models, serialize them at the native boundary, and call -`Invoke` or `CGoInvoke`. Do not add platform-specific application behavior to -the generic API. - -# Repository Layout - -| Path | Purpose | -| --- | --- | -| `invoke.go` | Invoke request validation, method dispatch, and response encoding. | -| `invoke_model.go` | Public method enum and typed request/response models. | -| `xray/` | Xray instance lifecycle, configuration validation, and batch latency testing. | -| `share/` | Share-link parsing, validation, and generation. | -| `geo/` | GeoData inspection helpers. | -| `controller/` | Android socket protection and process lookup integration. | -| `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. | -| `android_wrapper.go` | Android-only gomobile interfaces and controller registration. | -| `build/` | Cross-platform build scripts and artifact assembly. | -| `.github/workflows/` | CI builds and release artifact publication. | -| `README.md` | English integration documentation. | -| `readme/README.zh_CN.md` | Chinese integration documentation. | - -# Invoke API Contract - -The current API version is `2`. Requests using an omitted or different -`apiVersion` are rejected. - -```json -{ - "apiVersion": 2, - "method": "runXray", - "payload": { - "xrayJson": "{\"outbounds\":[...]}" - } -} -``` - -Every response uses the same envelope: - -```json -{ - "success": true, - "data": {}, - "error": "" -} -``` - -`data` must be a JSON object for successful methods that return data, `{}` for -successful methods without data, or `null` for failures without structured -failure data. Do not return scalar values directly from `data`. - -Supported methods: - -- `getFreePorts` -- `convertShareLinksToXrayJson` -- `convertXrayJsonToShareLinks` -- `generateAgeKeyPair` -- `countGeoData` -- `pingBatch` -- `testXray` -- `runXray` -- `stopXray` -- `xrayVersion` -- `getXrayState` - -Age-encrypted subscription support is part of the share boundary. libXray owns -native key generation, in-memory armor decryption, and parsing. Integrating -applications own HTTP headers, persistence of both generated keys, and refresh -behavior. Never log age secret keys, decrypted subscription text, or complete -Invoke requests containing those values. - -`pingBatch`, `testXray`, and `runXray` receive serialized Xray configuration -text through `xrayJson`. They must not accept or read an application-provided -configuration file path. `countGeoData` is the exception because it operates on -GeoData files directly and receives `datDir` in its payload. - -The complete UTF-8 Invoke request and response envelopes are limited to 16 MiB. -`pingBatch` accepts at most five configurations. It parses only `outbounds`, -ignores other root fields, and includes outbound dependencies referenced by -`streamSettings.sockopt.dialerProxy` or `proxySettings.tag`. - -# Runtime Semantics - -`runXray` manages one package-level Xray instance. A second `runXray` call fails -until `stopXray` closes the current instance. - -`testXray` and `pingBatch` create temporary Xray instances. Xray-core contains -process-wide state, including the system dialer DNS client and outbound manager. -Running temporary instances while another Xray instance is active may replace -that state, and closing a temporary instance does not restore it. libXray does -not serialize or isolate these calls. Integrators that require independent -concurrent instances must place them in separate processes. - -Xray runtime environment values belong in the root `env` object of `xrayJson`. -A top-level `env` field on the Invoke request is ignored. Missing root env fields -are governed by Xray-core behavior. - -# Platform Integration - -## C ABI - -`cgo_bridge/main.go` exports: - -```c -char* CGoInvoke(char* requestJSON); -void CGoFree(char* value); -``` - -`CGoInvoke` returns C-allocated memory. Every non-null response must be released -exactly once with `CGoFree`. Do not release it with a platform allocator or from -Go directly. - -## Android - -Android uses gomobile and produces `libXray.aar` plus -`libXray-sources.jar`. Android-only APIs include socket protection, process -lookup registration, `SetDNS`, and `ResetDNS`. - -`SetDNS` changes Go's process-wide resolver and requires a protected IP endpoint -such as `8.8.8.8:53`. Call `ResetDNS` after the managed Xray instance stops. -Keep Android-only code behind the `android` build tag. - -## Apple Platforms - -The CGo build produces `LibXray.xcframework` for iOS, iOS Simulator, macOS, -tvOS, and tvOS Simulator. Swift callers use `CGoInvoke` and `CGoFree`; the Xray -configuration and runtime TUN fd are supplied by the application through the -typed JSON contract. - -## Linux and Windows - -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. Linux -and Windows builds produce both the native library and session Core: - -```shell +# libXray + +Go wrapper around Xray-core for mobile and desktop clients. Keep platform-specific +App behavior out of the generic library. + +## API and runtime + +Before changing a method, read [README API](README.md#api), its method section, +and the models/dispatch in `invoke_model.go` and `invoke.go`. + +- Keep `LibXrayAPIVersion` fixed at `3`; do not increment it within this release. + Synchronize contract changes with typed models, downstream consumers, tests, + and both `README.md` and `readme/README.zh_CN.md`. +- Applications use `Invoke`/`CGoInvoke` with typed requests. Config methods receive + `xrayJson` text, not configuration file paths. Runtime `env` belongs inside + that Xray JSON. File-oriented APIs and the desktop Core CLI retain file access. +- `TestXray` only loads/builds configuration with `core.LoadConfig`; it neither + constructs nor starts an instance. Success does not guarantee startup or + connectivity. Builders may still read local assets/certificates and apply `env`. +- Manage one running instance. Validation and temporary instances must reject + managed-instance overlap before loading configuration and hold the lifecycle + lock through worker completion and instance cleanup. Close temporary instances + on every exit path; unmanaged overlaps require caller-owned process isolation. +- When changing [batch probes](README.md#pingbatch), preserve input order, + per-item failure isolation, and raw `locationJson`; provider parsing belongs + to the App. +- Before changing persistence or HTTP access, read + [managed runtime accounting](README.md#managed-runtime-accounting). + Save only the current session's inbound counters. Native metrics provides live + readings; runtime HTTP provides saved snapshots. The App owns totals and reset. +- When changing [age subscriptions](README.md#age-encrypted-subscriptions), + keep key generation/decryption in libXray and HTTP/persistence in the App. + Never log secret keys, decrypted subscriptions, or requests containing them. + +## Native integration and builds + +Before changing platform bridges or build scripts, read [build](README.md#build) +and the relevant platform/controller section in README. + +- Free each non-null `CGoInvoke` response exactly once with `CGoFree`. + Load only one independently built Go runtime per process. +- Keep Android-only APIs behind the `android` build tag. When changing DNS + integration, read [DNS resolver](README.md#dns-resolver): `SetDNS` affects the + process resolver and `ResetDNS` follows managed-instance shutdown. +- Use `build/main.py` to generate native artifacts; do not edit generated + headers, archives, or binaries. Verify temporary module edits are restored + after a build and check the build command's success and resulting artifacts. +- Modify an adjacent Xray-core checkout only when explicitly requested. + +Common builds: + +```sh python3 build/main.py android python3 build/main.py apple go -python3 build/main.py linux -python build/main.py windows -``` - -Apple also has a gomobile build path: - -```shell -python3 build/main.py apple gomobile ``` -To test an adjacent Xray-core checkout, place it at `../Xray-core` and append -`local`: - -```shell -python3 build/main.py android local -python3 build/main.py apple go local -``` +Other targets and local-core options are documented in [build usage](README.md#usage). -The build scripts temporarily adjust the Go module graph and restore `go.mod` -and `go.sum` when the build finishes. Generated native artifacts, downloaded -GeoData, and intermediate build directories are ignored by Git. Do not edit -generated headers, archives, frameworks, AARs, JARs, DLLs, or shared libraries -manually. +## Verification -# Development Rules - -1. Keep `Invoke` as the single cross-platform API entrypoint. Platform-only - controller APIs must remain isolated by build tags. -2. Define request and response fields as typed Go models in `invoke_model.go`. - Do not pass unstructured maps into package business logic. -3. Treat method names, JSON keys, response shapes, and `apiVersion` as a public - wire contract. Breaking changes require an API version increment and - synchronized integration documentation. -4. Xray configuration APIs accept `xrayJson` text, not file paths. File access - remains limited to APIs whose purpose is operating on files. -5. Keep the English and Chinese README API sections synchronized. -6. Preserve per-item ordering in `pingBatch`; one invalid configuration should - produce an item failure without discarding other accepted items. -7. Close every temporary Xray instance on success and error paths. Do not add - hidden serialization or state restoration that changes existing runtime - semantics. -8. Do not modify the adjacent Xray-core checkout as part of a libXray change - unless the task explicitly requires it. -9. Use `gofmt` for Go source and keep changes narrowly scoped to the owning - package. - -# Validation - -Run the checks appropriate to the change scope: - -```shell -gofmt -w -go test ./... -count=1 -git diff --check -``` +Run `git diff --check` for all changes. Match further verification to the change; +expand or repeat checks only for new changes, failures, or unresolved concerns. -Changes to build scripts or platform bridges should also build the affected -artifact. Changes to the Invoke wire contract must include dispatch/model tests, -unknown or removed method tests where relevant, response-shape tests, and -synchronized consumer model updates in downstream applications. +- Go changes: format changed files with `gofmt`, then `go test ./... -count=1`. +- Invoke changes: cover dispatch/models, response shapes, and removed methods + where relevant; verify downstream request models against the same contract. +- Bridge/build changes: build the affected artifact where supported. Report + unsupported targets or unbuilt artifacts explicitly. +- Documentation-only changes: check referenced paths/anchors; no Go tests or + native builds are needed. diff --git a/README.md b/README.md index bab9ab30..6f965e7c 100644 --- a/README.md +++ b/README.md @@ -66,15 +66,21 @@ python3 build/main.py windows local ``` +Builds restore `go.mod` and `go.sum` on success or failure. Gomobile builds +resolve `latest` by default; set `LIBXRAY_GOMOBILE_VERSION` to select a Go module +version. Both `gomobile` and `gobind` use that resolved version. + 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 +xray run -dns -interface -config [-runtime ] ``` All three options are required. `-dns` must be an IP endpoint, and `-config` points directly to the Xray JSON configuration. +Optional `-runtime` reads the host metadata object described under "Managed +runtime accounting", without a wrapping `runtime` key; it does not replace `-config`. > [!WARNING] > **Use only one Go runtime per process.** Go does not support loading multiple @@ -157,7 +163,7 @@ The request is a JSON object: ```json { - "apiVersion": 2, + "apiVersion": 3, "method": "runXray", "payload": { "xrayJson": "{\"outbounds\":[...]}" @@ -177,9 +183,10 @@ The response is a JSON object: Design notes: -1. Invoke currently accepts only `apiVersion: 2`. Xray configurations are - passed as UTF-8 JSON text in `xrayJson`; libXray does not read configuration - file paths. +1. Invoke accepts only `apiVersion: 3`; the API version remains fixed at 3. + Contract changes require synchronized consumers and documentation within + that version. Xray configurations are passed as UTF-8 JSON text in + `xrayJson`; libXray does not read configuration file paths. 2. A top-level `env` field is ignored and has no effect. Xray-core runtime environment options belong in the root `env` object of the Xray config. 3. `SetTunFd` has been removed. When the fd is only known at runtime, write @@ -198,16 +205,18 @@ Design notes: fields supported by libXray share links; unsupported and generated empty fields are omitted. Opaque XHTTP `extra` and FinalMask mask `settings` JSON remain unchanged. + Every successful response returns the projected config together with + `usableCount` and `failedCount`. Its optional `age.secretKey` decrypts official age ASCII armor in memory before the existing parser runs. Plaintext input remains unchanged. 7. Xray-core keeps its system dialer DNS client and outbound manager in - process-wide state. Creating another Xray instance through `pingBatch`, - `testXray`, or the exported Go APIs while `runXray` is active may replace - that state and affect the running - instance. Closing the temporary instance does not restore the previous - state. libXray does not serialize, isolate, or restore concurrent instances; - callers that require overlapping instances must place them in separate - processes. + process-wide state. `pingBatch`, `testXray`, and their exported Go + entrypoints take the managed lifecycle lock and reject an active `runXray` + instance before loading/building config. + A batch holds the lock through all workers and temporary-core close. This + also serializes these operations with one another. Instances + created outside the managed APIs are not detected or restored; callers + requiring overlap with them must still use separate processes. Supported methods: @@ -299,7 +308,8 @@ Get free ports. ## share -libXray uses `sendThrough` to store outbound names. +libXray stores outbound names in `tag`. `sendThrough` keeps its native Xray +meaning as the local bind address. ### clash_meta @@ -315,6 +325,28 @@ convert VMessAEAD/VLESS sharing protocol to Xray Json. convert VMessQRCode to Xray Json. +#### Parsing result + +`convertShareLinksToXrayJson` has one response shape. Its payload contains +`text` and optional `age`. Every successful conversion returns +`data: {"config":{"outbounds":[...]},"usableCount":2,"failedCount":1}`. + +Counts describe this input only, not added/changed nodes. Each root JSON +`outbounds` element or YAML `proxies` element is one candidate. In detected +share-link lists, each URI-like row is one candidate; blank lines, comments and +text headers are ignored. Base64 and age wrappers use the inner format's +candidates. Malformed individual elements are skipped without discarding other +valid elements. `usableCount` equals the final projected, buildable +outbound count; parse, build and unsupported-projection failures count toward +`failedCount`. No per-node hash comparison or deduplication is performed. + +A recognized container with zero usable nodes returns `success: false` with +structured counts and `config: {"outbounds":[]}`. An unrecognized format, +malformed whole document, invalid container or decryption failure returns +`data: null`; counts are not guessed. Error text never includes rejected +candidates or decrypted subscription text. Callers must not import/replace a +subscription when no usable nodes remain. + ### age-encrypted subscriptions `convertShareLinksToXrayJson` accepts an optional native age secret key. Only @@ -324,7 +356,7 @@ decrypted in memory and limited to 16 MiB of plaintext. ```json { - "apiVersion": 2, + "apiVersion": 3, "method": "convertShareLinksToXrayJson", "payload": { "text": "-----BEGIN AGE ENCRYPTED FILE-----\n...", @@ -342,7 +374,7 @@ Generate a new keypair with `keyType` set to `x25519` or `hybrid`. An omitted ```json { - "apiVersion": 2, + "apiVersion": 3, "method": "generateAgeKeyPair", "payload": { "keyType": "x25519" @@ -375,7 +407,7 @@ by the `proxy` tag, and finally by the first outbound. ```json { - "apiVersion": 2, + "apiVersion": 3, "method": "pingBatch", "payload": { "configs": [ @@ -388,7 +420,8 @@ by the `proxy` tag, and finally by the first outbound. } ], "timeout": 5, - "url": "https://cp.cloudflare.com/" + "url": "https://cp.cloudflare.com/", + "locationUrl": "https://ip-check-perf.radar.cloudflare.com/" } } ``` @@ -399,19 +432,37 @@ fail before any configuration is tested. The top-level response succeeds when the batch itself was accepted. Each item has its own result; `delay` is `10000` for an error and `11000` for a timeout. +`delay` is always present, including a successful zero-millisecond result. The result array has the same length and order as the input config array. Outbound dependencies referenced by `streamSettings.sockopt.dialerProxy` or `proxySettings.tag` are included automatically. +`locationUrl` is optional and must be an absolute HTTP(S) URL. When omitted, +no location request is made and no location fields are returned. When supplied, +each prepared item sends its latency HEAD and then its location GET using the +same client forced through that item's selected outbound and dependencies. +Each request has the configured timeout (so an item may take up to twice it). +Location time is not included in `delay`, and the two results are independent: +`success`, `delay` and `error` describe latency only; a location failure does not +invalidate a successful latency result, and GET is still attempted after a +latency failure. + +A successful GET adds the unmodified response body as the `locationJson` +string. The App owns JSON parsing and provider-specific field handling. The +provider must return HTTP 200 and at most 64 KiB; transport or body-read +failures instead add `locationError`. Errors do not echo the URL, credentials +or response body. Invalid outbound configs retain their ordinary per-item +failure and do not perform either request. + ### testXray -Validates an Xray configuration from the supplied JSON text without reading a -configuration file: +Loads and builds the complete configuration from the supplied JSON text. The +payload contains only `xrayJson`; success returns `data: {}`: ```json { - "apiVersion": 2, + "apiVersion": 3, "method": "testXray", "payload": { "xrayJson": "{\"outbounds\":[...]}" @@ -419,11 +470,122 @@ configuration file: } ``` +The Go entrypoint `TestXray` uses `core.LoadConfig` without constructing or +starting an Xray instance or runtime handlers. It validates configuration +structure, including TUN/WireGuard definitions, without creating devices, +listeners, log files, or background connections. The builder can still read +local GeoData/certificates and apply the root `env` to the current process. +Geodata asset declarations validate HTTPS URLs and existing local files; their +downloader/cron does not run during validation. + +A successful check establishes that the configuration builds. It does not +prove that runtime resources are available, that an instance can start, or +that the network is reachable. Callers must handle actual startup failures. + ### runXray Starts the managed Xray instance from the supplied JSON text. Use `stopXray` to stop that instance. `runXrayFromJson` is no longer a separate method. +### Managed runtime accounting + +`runXray.payload.runtime` is optional API v3 host metadata. Omitting it +preserves the original lifecycle and writes no runtime snapshots. Hosts opt in +with this object (also the complete content of the desktop `-runtime` file): + +```json +{ + "statePath": "/private/app/run/runtime.json", + "inboundTag": "tunIn", + "listen": "127.0.0.1:49228", + "token": "538fc3253a3e433491bc2d653fc74214" +} +``` + +The host supplies an existing private directory and an absolute `statePath`. +`inboundTag` must be nonempty and at most 256 bytes. Metadata stays separate +from Xray JSON, so user configuration cannot override it. The named inbound +must exist, with uplink/downlink system statistics and a statistics manager enabled. +`listen` and `token` may both be omitted to save snapshots without HTTP. When +enabled, `listen` must be `127.0.0.1:` with port 1–65535, and the host must +generate a fresh random 32-character lowercase hex `token`. Keep it private; +do not reuse the example token. Invalid metadata, an occupied HTTP port, or an +initial save failure rejects startup; any constructed core and statistics +listener are closed. + +The saved file contains only the current session's raw inbound counter values: + +```json +{ + "version": 1, + "session": { + "id": "2a7e2e49b947a802d8b39af4fbc48f52", + "startedAtMs": 1788300000000, + "endedAtMs": 0, + "uplink": 120, + "downlink": 800 + }, + "available": true, + "sampledAtMs": 1788300030000, + "savedAtMs": 1788300030000, + "error": "" +} +``` + +Timestamps are Unix milliseconds. Each new start generates a random +32-character lowercase hex session ID, even when replaying identical metadata. +`endedAtMs: 0` means no final stop was saved; it is not proof that the VPN is +running. The host saves an initial snapshot, samples/saves every 30 seconds, +and attempts a final sample/save before closing the core on `stopXray`. + +Sampling reads the named inbound's `Value()`, never resets it and never adds +outbound/node counters. Repeated samples do not accumulate bytes. A nonnegative +counter rollback is recorded as the smaller raw value, not a synthetic delta. +Missing or negative counters set `available: false` and +`error: "counters_unavailable"`, retaining the last valid nonnegative values. +Idle valid counters report available zero. There are no application-wide totals, +reset generations, or VPN control HTTP methods. +`resetRuntime` is not an Invoke method. Applications may read existing Xray +metrics for live rates; their own totals/reset policy stays outside libXray. + +Starting a new session atomically replaces the previous `runtime.json`; libXray +does not archive or merge earlier sessions. Traffic not read by the App before +replacement is intentionally lost. Each session starts from zero and receives a +new ID. + +Snapshot files use a mode-0600 same-directory temporary file, sync, and atomic +replacement (Windows uses `MoveFileEx` with replace-existing and write-through). +The private parent directory/Windows ACL remains the host's responsibility. +Failed saves leave the previous complete disk snapshot for later retry; a final +save error is returned but never prevents core shutdown. An error after rename +can have an uncertain persistence outcome, so consumers must re-read saved +snapshots through HTTP when available. +This is reference data, not billing: crashes, forced termination, or replacement +before the App reads the file can lose traffic, with no strict loss bound. + +A nonblocking OS lock on `statePath + ".lock"` is held until core close, +preventing another process from writing the current session. +Hosts must use one consistent canonical path and leave the lock file in place. +App code reads snapshots through HTTP instead of opening the host's files, so +macOS System Extension files can remain root-owned. This does not provide +graceful final settlement when Windows forcibly terminates a job. + +#### Snapshot HTTP + +The optional statistics listener starts with the managed session and closes on +stop, including when the final save fails. It uses a separate loopback port +from Xray's native metrics; it provides no VPN start/stop/configuration methods. +Every request requires `Authorization: Bearer `. Responses use +`Cache-Control: no-store`; CORS is not enabled. + +- `GET /runtime` returns the current saved snapshot directly. + +Requests read the host's saved atomic snapshot without sampling, resetting +counters, or updating the save time. Use native metrics for live rates. A +missing, corrupt, or non-regular snapshot returns service unavailable. Requests +have bounded read/write timeouts. While stopped, HTTP is unavailable; libXray +never owns App totals or clear/reset policy. + ### metrics Refer to the following configuration: @@ -452,11 +614,8 @@ when `listen` is `127.0.0.1:49227`, read: http://localhost:49227/debug/vars ``` -Note: - -1. When testing latency or validating configuration, make sure `metrics` is `null`. - -2. Metrics only needs the `listen` field in this wrapper. Query `/debug/vars` directly with an HTTP client instead of going through libXray. +Metrics only needs the `listen` field in this wrapper. Query `/debug/vars` +directly with an HTTP client instead of going through libXray. ### validation diff --git a/build/app/android.py b/build/app/android.py index 9ab3c3bd..4d62b7f7 100644 --- a/build/app/android.py +++ b/build/app/android.py @@ -34,7 +34,4 @@ def build(self): if ret.returncode != 0: raise Exception("build failed") finally: - try: - self.after_build() - finally: - self.restore_go_env() + self.restore_go_env() diff --git a/build/app/apple_go.py b/build/app/apple_go.py index 38ff8c3a..bf4def26 100644 --- a/build/app/apple_go.py +++ b/build/app/apple_go.py @@ -125,10 +125,7 @@ def build(self): self.create_include_dir() self.create_framework() finally: - try: - self.after_build() - finally: - self.restore_go_env() + self.restore_go_env() def build_targets(self, targets: list[AppleTarget]): for target in targets: diff --git a/build/app/apple_gomobile.py b/build/app/apple_gomobile.py index 50819e51..38a6498b 100644 --- a/build/app/apple_gomobile.py +++ b/build/app/apple_gomobile.py @@ -29,7 +29,4 @@ def build(self): if ret.returncode != 0: raise Exception("build failed") finally: - try: - self.after_build() - finally: - self.restore_go_env() + self.restore_go_env() diff --git a/build/app/build.py b/build/app/build.py index 8d467f8e..d516d3d8 100644 --- a/build/app/build.py +++ b/build/app/build.py @@ -115,6 +115,7 @@ def download_geo(self): raise Exception("download_geo failed") def prepare_gomobile(self): + requested_version = os.environ.get("LIBXRAY_GOMOBILE_VERSION") or "latest" result = subprocess.run( [ "go", @@ -122,14 +123,14 @@ def prepare_gomobile(self): "-m", "-f", "{{.Version}}", - "golang.org/x/mobile@latest", + f"golang.org/x/mobile@{requested_version}", ], capture_output=True, text=True, ) version = result.stdout.strip() if result.returncode != 0 or not version: - raise Exception("resolve latest gomobile version failed") + raise Exception("resolve gomobile version failed") ret = subprocess.run( [ @@ -191,6 +192,3 @@ def before_build(self): def build(self): pass - - def after_build(self): - pass diff --git a/build/app/linux.py b/build/app/linux.py index c05130d2..79051b6b 100644 --- a/build/app/linux.py +++ b/build/app/linux.py @@ -26,10 +26,7 @@ def build(self): self.build_linux() self.build_desktop_bin(self.bin_file) finally: - try: - self.after_build() - finally: - self.restore_go_env() + self.restore_go_env() def build_linux(self): output_dir = self.framework_dir diff --git a/build/app/windows.py b/build/app/windows.py index 31d01127..88870111 100644 --- a/build/app/windows.py +++ b/build/app/windows.py @@ -26,10 +26,7 @@ def build(self): self.build_windows() self.build_desktop_bin(self.bin_file) finally: - try: - self.after_build() - finally: - self.restore_go_env() + self.restore_go_env() def build_windows(self): output_dir = self.framework_dir diff --git a/build/test_build.py b/build/test_build.py new file mode 100644 index 00000000..47d61033 --- /dev/null +++ b/build/test_build.py @@ -0,0 +1,74 @@ +"""Run: python3 build/test_build.py. No Go or platform build is run.""" +from pathlib import Path +import shutil +import subprocess +import unittest +from unittest.mock import call, patch +from uuid import uuid4 + +from app.android import AndroidBuilder + + +class BuildTest(unittest.TestCase): + def setUp(self): + self.root = ( + Path(__file__).resolve().parents[2] + / "references" + / "onexray-refactor-validation" + / "build-scripts" + / uuid4().hex + ) + (self.root / "build").mkdir(parents=True) + self.addCleanup(shutil.rmtree, self.root) + self.builder = AndroidBuilder(str(self.root / "build")) + + def test_build_restores_modules_on_success_and_failure(self): + for fails in (False, True): + with self.subTest(fails=fails): + (self.root / "go.mod").write_text("original module\n") + (self.root / "go.sum").write_text("original sums\n") + + def prepare(): + (self.root / "go.mod").write_text("effective module\n") + (self.root / "go.sum").write_text("effective sums\n") + if fails: + raise RuntimeError("original build failed") + + with ( + patch.object(self.builder, "before_build", side_effect=prepare), + patch("app.android.os.chdir"), + patch("app.android.subprocess.run", return_value=subprocess.CompletedProcess([], 0)), + ): + if fails: + with self.assertRaisesRegex(RuntimeError, "original build failed"): + self.builder.build() + else: + self.builder.build() + + self.assertEqual((self.root / "go.mod").read_text(), "original module\n") + self.assertEqual((self.root / "go.sum").read_text(), "original sums\n") + self.assertEqual(list((self.root / "build").iterdir()), []) + self.assertIsNone(self.builder._go_env_snapshot) + + def test_gomobile_and_gobind_use_the_same_resolved_version(self): + version = "v0.0.0-20260821190718-4776eadac327" + for requested in ("", version): + with self.subTest(requested=requested), patch.dict( + "app.build.os.environ", {"LIBXRAY_GOMOBILE_VERSION": requested} + ), patch( + "app.build.subprocess.run", + return_value=subprocess.CompletedProcess([], 0, version + "\n", ""), + ) as run: + self.builder.prepare_gomobile() + self.assertEqual(run.call_args_list, [ + call(["go", "list", "-m", "-f", "{{.Version}}", + f"golang.org/x/mobile@{requested or 'latest'}"], + capture_output=True, text=True), + call(["go", "get", "-tool", f"golang.org/x/mobile/cmd/gobind@{version}"]), + call(["go", "install", f"golang.org/x/mobile/cmd/gomobile@{version}"]), + call(["gomobile", "init"]), + ]) + + +if __name__ == "__main__": + unittest.main() diff --git a/desktop_bin/main.go b/desktop_bin/main.go index 33ba90af..8ea0a6f6 100644 --- a/desktop_bin/main.go +++ b/desktop_bin/main.go @@ -3,6 +3,7 @@ package main import ( + "encoding/json" "errors" "flag" "fmt" @@ -19,6 +20,7 @@ type runOptions struct { dns string interfaceName string configPath string + runtimePath string } func parseRunOptions(args []string) (runOptions, error) { @@ -32,6 +34,7 @@ func parseRunOptions(args []string) (runOptions, error) { 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") + flags.StringVar(&options.runtimePath, "runtime", "", "optional host runtime metadata JSON path") if err := flags.Parse(args[1:]); err != nil { return options, err } @@ -49,12 +52,31 @@ func run(options runOptions) error { if err != nil { return err } + var runtime *xray.RuntimeConfig + if options.runtimePath != "" { + file, err := os.Open(options.runtimePath) + if err != nil { + return err + } + data, readErr := io.ReadAll(io.LimitReader(file, 64*1024+1)) + closeErr := file.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return err + } + if len(data) > 64*1024 { + return errors.New("runtime metadata exceeds 64 KiB") + } + runtime = new(xray.RuntimeConfig) + if err := json.Unmarshal(data, runtime); err != nil { + return errors.New("invalid runtime metadata") + } + } if err := dns.SetDNS(options.dns, options.interfaceName); err != nil { return err } defer dns.ResetDNS() - if err := xray.RunXray(string(config)); err != nil { + if err := xray.RunXrayWithRuntime(string(config), runtime); err != nil { return err } @@ -66,7 +88,7 @@ func run(options runOptions) error { } func printUsage() { - fmt.Fprintln(os.Stdout, "Usage: xray run -dns -interface -config ") + fmt.Fprintln(os.Stdout, "Usage: xray run -dns -interface -config [-runtime ]") } func main() { diff --git a/desktop_bin/main_test.go b/desktop_bin/main_test.go index 8d77f4d0..becb4cf8 100644 --- a/desktop_bin/main_test.go +++ b/desktop_bin/main_test.go @@ -10,6 +10,7 @@ func TestParseRunOptions(t *testing.T) { "-dns", "8.8.8.8:53", "-interface", "Ethernet", "-config", `C:\run\xray.json`, + "-runtime", `C:\run\runtime.json`, }) if err != nil { t.Fatal(err) @@ -17,6 +18,9 @@ func TestParseRunOptions(t *testing.T) { if options.dns != "8.8.8.8:53" || options.interfaceName != "Ethernet" || options.configPath != `C:\run\xray.json` { t.Fatalf("unexpected options: %#v", options) } + if options.runtimePath != `C:\run\runtime.json` { + t.Fatalf("unexpected runtime path: %q", options.runtimePath) + } if _, err := parseRunOptions([]string{"run", "-config", "xray.json"}); err == nil { t.Fatal("missing DNS protection options were accepted") diff --git a/go.mod b/go.mod index d258b2fa..434c640d 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/xtls/libxray -go 1.26.3 +go 1.27.1 require ( github.com/metacubex/age v0.0.0-20260603010618-28d156b4ea78 diff --git a/invoke.go b/invoke.go index f2134fe3..4ae1a147 100644 --- a/invoke.go +++ b/invoke.go @@ -139,12 +139,8 @@ func invokeConvertShareLinksToXrayJson(payload json.RawMessage) string { if request.Age != nil { secretKey = request.Age.SecretKey } - config, err := share.ConvertShareLinksToXrayJsonWithAge(request.Text, secretKey) - if err != nil { - return encodeInvokeResponse(nil, err) - } - xrayJSON, err := share.MarshalShareConfigJSON(config) - return encodeInvokeResponse(xrayJSON, err) + result, err := share.ConvertShareLinksToXrayJson(request.Text, secretKey) + return encodeInvokeResponse(result, err) } func invokeGenerateAgeKeyPair(payload json.RawMessage) string { @@ -200,10 +196,11 @@ func invokePingBatch(payload json.RawMessage) string { } } - results, err := xray.PingBatch( + results, err := xray.PingBatchWithLocation( configs, request.Timeout, request.URL, + request.LocationURL, ) if err != nil { return encodeInvokeResponse(nil, err) @@ -212,9 +209,11 @@ func invokePingBatch(payload json.RawMessage) string { responseResults := make([]PingBatchItemResponse, len(results)) for i, result := range results { responseResults[i] = PingBatchItemResponse{ - Success: result.Success, - Delay: result.Delay, - Error: result.Error, + Success: result.Success, + Delay: result.Delay, + Error: result.Error, + LocationJSON: result.LocationJSON, + LocationError: result.LocationError, } } return encodeInvokeResponse(&PingBatchResponse{Results: responseResults}, nil) @@ -234,6 +233,6 @@ func invokeRunXray(payload json.RawMessage) string { if err != nil { return encodeInvokeNoDataResponse(err) } - err = xray.RunXray(request.XrayJson) + err = xray.RunXrayWithRuntime(request.XrayJson, request.Runtime) return encodeInvokeNoDataResponse(err) } diff --git a/invoke_model.go b/invoke_model.go index f317f0ea..05fb45b4 100644 --- a/invoke_model.go +++ b/invoke_model.go @@ -1,11 +1,16 @@ // libXray is an Xray wrapper focusing on improving the experience of Xray-core mobile development. package libXray -import "encoding/json" +import ( + "encoding/json" + + "github.com/xtls/libxray/share" + "github.com/xtls/libxray/xray" +) type LibXrayMethod string -const LibXrayAPIVersion = 2 +const LibXrayAPIVersion = 3 const ( LibXrayMethodGetFreePorts LibXrayMethod = "getFreePorts" @@ -44,6 +49,8 @@ type ConvertShareLinksToXrayJsonRequest struct { Age *AgeDecryptConfig `json:"age,omitempty"` } +type ConvertShareLinksToXrayJsonResponse = share.ConvertShareLinksResult + type AgeKeyType string const ( @@ -75,9 +82,10 @@ type CountGeoDataRequest struct { } type PingBatchRequest struct { - Configs []PingBatchItemRequest `json:"configs,omitempty"` - Timeout int `json:"timeout,omitempty"` - URL string `json:"url,omitempty"` + Configs []PingBatchItemRequest `json:"configs,omitempty"` + Timeout int `json:"timeout,omitempty"` + URL string `json:"url,omitempty"` + LocationURL string `json:"locationUrl,omitempty"` } type PingBatchItemRequest struct { @@ -90,15 +98,20 @@ type PingBatchResponse struct { } type PingBatchItemResponse struct { - Success bool `json:"success"` - Delay int64 `json:"delay,omitempty"` - Error string `json:"error,omitempty"` + Success bool `json:"success"` + Delay int64 `json:"delay"` + Error string `json:"error,omitempty"` + LocationJSON *string `json:"locationJson,omitempty"` + LocationError string `json:"locationError,omitempty"` } type RunXrayRequest struct { - XrayJson string `json:"xrayJson,omitempty"` + XrayJson string `json:"xrayJson,omitempty"` + Runtime *RuntimeConfig `json:"runtime,omitempty"` } +type RuntimeConfig = xray.RuntimeConfig + type TestXrayRequest struct { XrayJson string `json:"xrayJson,omitempty"` } diff --git a/invoke_probes_test.go b/invoke_probes_test.go new file mode 100644 index 00000000..56f795f6 --- /dev/null +++ b/invoke_probes_test.go @@ -0,0 +1,93 @@ +package libXray + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestInvokeShareStatsResponseShape(t *testing.T) { + const validLink = "vless://12345678-abcd-abcd-abcd-123456789abc@example.com:443?encryption=none&security=tls&sni=example.com" + for _, test := range []struct { + text string + usable, failed int + success bool + }{ + {validLink + "\nvless://bad@example.com:443", 1, 1, true}, + {"vless://bad@example.com:443", 0, 1, false}, + } { + response := invokeForTest(t, LibXrayMethodConvertShareLinksToXrayJson, ConvertShareLinksToXrayJsonRequest{Text: test.text}) + if response.Success != test.success { + t.Fatalf("success = %v, error = %s", response.Success, response.Err) + } + var result ConvertShareLinksToXrayJsonResponse + if err := json.Unmarshal(response.Data, &result); err != nil { + t.Fatal(err) + } + if result.UsableCount != test.usable || result.FailedCount != test.failed || len(result.Config) == 0 { + t.Fatalf("result = %+v", result) + } + var root map[string]json.RawMessage + if err := json.Unmarshal(response.Data, &root); err != nil { + t.Fatal(err) + } + if len(root) != 3 || root["config"] == nil || root["usableCount"] == nil || root["failedCount"] == nil { + t.Fatalf("data = %s", response.Data) + } + } + for _, text := range []string{`{"outbounds":`, "-----BEGIN AGE ENCRYPTED FILE-----\ninvalid"} { + response := invokeForTest(t, LibXrayMethodConvertShareLinksToXrayJson, ConvertShareLinksToXrayJsonRequest{Text: text}) + if response.Success || string(response.Data) != "null" { + t.Fatalf("response = %+v", response) + } + } +} + +func TestInvokePingLocationAndZeroDelayWireFields(t *testing.T) { + raw, err := json.Marshal(PingBatchItemResponse{Success: true, Delay: 0}) + if err != nil { + t.Fatal(err) + } + if string(raw) != `{"success":true,"delay":0}` { + t.Fatalf("zero latency response = %s", raw) + } + empty := "" + raw, err = json.Marshal(PingBatchItemResponse{Success: true, Delay: 0, LocationJSON: &empty}) + if err != nil { + t.Fatal(err) + } + if string(raw) != `{"success":true,"delay":0,"locationJson":""}` { + t.Fatalf("empty location response = %s", raw) + } + locationBody := `{"ip_address":"203.0.113.1","country":"SG"}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, locationBody) + })) + defer server.Close() + request := PingBatchRequest{ + Configs: []PingBatchItemRequest{{XrayJson: `{"outbounds":[{"protocol":"freedom"}]}`}}, + Timeout: 1, URL: server.URL, LocationURL: server.URL, + } + response := invokeForTest(t, LibXrayMethodPingBatch, request) + if !response.Success { + t.Fatalf("error = %s", response.Err) + } + result := decodeDataObject[PingBatchResponse](t, response).Results[0] + if !result.Success || result.LocationJSON == nil || *result.LocationJSON != `{"ip_address":"203.0.113.1","country":"SG"}` { + t.Fatalf("result = %+v", result) + } + locationBody = "" + response = invokeForTest(t, LibXrayMethodPingBatch, request) + result = decodeDataObject[PingBatchResponse](t, response).Results[0] + if !response.Success || result.LocationJSON == nil || *result.LocationJSON != "" { + t.Fatalf("empty location response = %+v", response) + } + request.LocationURL = "" + response = invokeForTest(t, LibXrayMethodPingBatch, request) + if !response.Success || strings.Contains(string(response.Data), "location") { + t.Fatalf("latency-only response = %+v", response) + } +} diff --git a/invoke_test.go b/invoke_test.go index 78cd23f3..76527de0 100644 --- a/invoke_test.go +++ b/invoke_test.go @@ -77,6 +77,16 @@ func decodeDataObject[T any](t *testing.T, response testResponse) T { return value } +func decodeShareConfig(t *testing.T, response testResponse) (ConvertShareLinksToXrayJsonResponse, conf.Config) { + t.Helper() + result := decodeDataObject[ConvertShareLinksToXrayJsonResponse](t, response) + var config conf.Config + if err := json.Unmarshal(result.Config, &config); err != nil { + t.Fatal(err) + } + return result, config +} + func writeGeoSiteDatForTest(t *testing.T, path string) { t.Helper() data, err := proto.Marshal(&geodata.GeoSiteList{ @@ -206,6 +216,38 @@ func TestInvokeTestXray(t *testing.T) { requireNoDataObject(t, response) } +func TestInvokeTestXrayDoesNotCreateRuntimeResources(t *testing.T) { + logPath := filepath.Join(t.TempDir(), "not-created", "error.log") + config, err := json.Marshal(map[string]any{ + "log": map[string]any{"error": logPath, "loglevel": "debug"}, + "inbounds": []any{ + map[string]any{"tag": "tunIn", "protocol": "tun", "settings": map[string]any{"name": "TestXrayMustNotCreate", "mtu": 1500}}, + }, + "outbounds": []any{ + map[string]any{"protocol": "wireguard", "settings": map[string]any{ + "secretKey": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "address": []string{"10.0.0.2/32"}, + "peers": []any{map[string]any{"publicKey": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", "endpoint": "127.0.0.1:9"}}, + }}, + }, + }) + if err != nil { + t.Fatal(err) + } + response := invokeForTest(t, LibXrayMethodTestXray, TestXrayRequest{XrayJson: string(config)}) + if !response.Success { + t.Fatalf("testXray must accept structurally valid TUN/WireGuard without construction: %s", response.Err) + } + requireNoDataObject(t, response) + if _, err := os.Stat(filepath.Dir(logPath)); !os.IsNotExist(err) { + t.Fatalf("testXray created a runtime log directory: %v", err) + } + response = invokeForTest(t, LibXrayMethodTestXray, TestXrayRequest{XrayJson: `{"outbounds":[{"protocol":"unknown"}]}`}) + if response.Success || string(response.Data) != "null" { + t.Fatalf("testXray must still reject invalid core configuration: %+v", response) + } +} + func TestInvokeTestXrayDoesNotReadConfigPath(t *testing.T) { configPath := filepath.Join(t.TempDir(), "xray.json") configJSON, err := json.Marshal(testXrayConfig(t)) @@ -244,6 +286,43 @@ func TestInvokeRunXray(t *testing.T) { requireNoDataObject(t, response) } +func TestInvokeRunXrayRuntimeIsOptionalTypedMetadata(t *testing.T) { + defer xrayStopForTest(t) + request := RunXrayRequest{ + XrayJson: `{"log":{"loglevel":"none"},"outbounds":[{"protocol":"freedom"}]}`, + Runtime: &RuntimeConfig{ + StatePath: "relative.json", InboundTag: "tunIn", + }, + } + encoded, err := json.Marshal(request) + if err != nil || !strings.Contains(string(encoded), `"runtime":{"statePath":`) { + t.Fatalf("runtime metadata is missing from the request: %v", err) + } + response := invokeForTest(t, LibXrayMethodRunXray, request) + if response.Success || !strings.Contains(response.Err, "absolute statePath") { + t.Fatalf("runtime validation was bypassed: %+v", response) + } + response = invokeRawForTest(t, `{"apiVersion":3,"method":"runXray","payload":{"xrayJson":"{}","runtime":"invalid"}}`) + if response.Success { + t.Fatal("untyped runtime metadata was accepted") + } +} + +func TestInvokeRejectsRemovedRuntimeControl(t *testing.T) { + path := filepath.Join(t.TempDir(), "runtime.json") + data := []byte(`{"fixture":"must not change"}`) + if err := os.WriteFile(path, data, 0600); err != nil { + t.Fatal(err) + } + response := invokeForTest(t, LibXrayMethod("resetRuntime"), map[string]string{"statePath": path}) + if response.Success || response.Err != "unknown method" || string(response.Data) != "null" { + t.Fatalf("removed runtime control was accepted: %+v", response) + } + if saved, err := os.ReadFile(path); err != nil || !bytes.Equal(saved, data) { + t.Fatalf("unknown method modified a file: %s %v", saved, err) + } +} + func TestInvokeRunXrayAppliesConfigEnv(t *testing.T) { const key = "XRAY_LIBXRAY_CONFIG_ENV_TEST" t.Setenv(key, "") @@ -334,12 +413,18 @@ func TestInvokeConvertShareLinksFiltersBuildInvalidOutbounds(t *testing.T) { if !response.Success { t.Fatalf("ConvertShareLinksToXrayJson failed: %s", response.Err) } - config := decodeDataObject[conf.Config](t, response) + result, config := decodeShareConfig(t, response) + if result.UsableCount != 1 || result.FailedCount != 1 { + t.Fatalf("result = %+v, want 1 usable and 1 failed", result) + } if len(config.OutboundConfigs) != 1 { t.Fatalf("outbounds = %d, want 1", len(config.OutboundConfigs)) } - if config.OutboundConfigs[0].SendThrough == nil || *config.OutboundConfigs[0].SendThrough != validName { - t.Fatalf("sendThrough = %v, want %q", config.OutboundConfigs[0].SendThrough, validName) + if config.OutboundConfigs[0].Tag != validName { + t.Fatalf("tag = %q, want %q", config.OutboundConfigs[0].Tag, validName) + } + if config.OutboundConfigs[0].SendThrough != nil { + t.Fatalf("sendThrough = %v, want nil", config.OutboundConfigs[0].SendThrough) } } @@ -357,27 +442,26 @@ func TestInvokeConvertShareLinksReturnsProjectedObject(t *testing.T) { t.Fatalf("ConvertShareLinksToXrayJson failed: %s", response.Err) } + result, config := decodeShareConfig(t, response) var root map[string]json.RawMessage - if err := json.Unmarshal(response.Data, &root); err != nil { - t.Fatalf("data is not an object: %s", response.Data) + if err := json.Unmarshal(result.Config, &root); err != nil { + t.Fatalf("config is not an object: %s", result.Config) } if len(root) != 1 || root["outbounds"] == nil { - t.Fatalf("data root = %s, want only outbounds", response.Data) + t.Fatalf("config root = %s, want only outbounds", result.Config) } for _, field := range []string{"publicKey", "target", "dest", "proxySettings", "sockopt"} { - if bytes.Contains(response.Data, []byte(`"`+field+`"`)) { - t.Fatalf("data contains unsupported field %q: %s", field, response.Data) + if bytes.Contains(result.Config, []byte(`"`+field+`"`)) { + t.Fatalf("config contains unsupported field %q: %s", field, result.Config) } } - if !bytes.Contains(response.Data, []byte(`"password":"`+publicKey+`"`)) { - t.Fatalf("data did not canonicalize REALITY password: %s", response.Data) + if !bytes.Contains(result.Config, []byte(`"password":"`+publicKey+`"`)) { + t.Fatalf("config did not canonicalize REALITY password: %s", result.Config) } - config := decodeDataObject[conf.Config](t, response) if len(config.OutboundConfigs) != 1 { t.Fatalf("outbounds = %d, want 1", len(config.OutboundConfigs)) } - config.OutboundConfigs[0].SendThrough = nil if _, err := config.OutboundConfigs[0].Build(); err != nil { t.Fatalf("projected outbound does not build: %v", err) } @@ -429,7 +513,10 @@ func TestInvokeAgeKeyGenerationAndConversion(t *testing.T) { if !converted.Success { t.Fatalf("ConvertShareLinksToXrayJson failed: %s", converted.Err) } - config := decodeDataObject[conf.Config](t, converted) + result, config := decodeShareConfig(t, converted) + if result.UsableCount != 1 || result.FailedCount != 0 { + t.Fatalf("result = %+v, want 1 usable and 0 failed", result) + } if len(config.OutboundConfigs) != 1 { t.Fatalf("outbounds = %d, want 1", len(config.OutboundConfigs)) } @@ -478,8 +565,9 @@ func TestInvokeConvertShareLinksFailsWhenAllOutboundsAreBuildInvalid(t *testing. if !strings.Contains(response.Err, "no valid outbound found") { t.Fatalf("error = %q", response.Err) } - if got := string(response.Data); got != "null" { - t.Fatalf("data = %s, want null", got) + result, config := decodeShareConfig(t, response) + if result.UsableCount != 0 || result.FailedCount != 1 || len(config.OutboundConfigs) != 0 { + t.Fatalf("result = %+v, config = %+v", result, config) } } @@ -607,10 +695,10 @@ func TestInvokeUnknownMethod(t *testing.T) { } func TestInvokeRemovedMethods(t *testing.T) { - for _, method := range []string{"ping", "runXrayFromJson", "deriveAgePublicKey"} { + for _, method := range []string{"ping", "runXrayFromJson", "deriveAgePublicKey", "checkRoute"} { response := invokeRawForTest( t, - `{"apiVersion":2,"method":"`+method+`","payload":{}}`, + `{"apiVersion":3,"method":"`+method+`","payload":{}}`, ) if response.Success { t.Fatalf("removed method %q should fail", method) @@ -662,17 +750,19 @@ func TestInvokeAPIVersion(t *testing.T) { t.Fatal("omitted apiVersion should fail") } - response = invokeRawForTest(t, `{"apiVersion":1,"method":"xrayVersion"}`) - if response.Success { - t.Fatal("v1 apiVersion should fail") - } - if got := string(response.Data); got != "null" { - t.Fatalf("data = %s, want null", got) + for _, version := range []string{"2", "4", "5"} { + response = invokeRawForTest(t, `{"apiVersion":`+version+`,"method":"xrayVersion"}`) + if response.Success { + t.Fatalf("v%s apiVersion should fail", version) + } + if got := string(response.Data); got != "null" { + t.Fatalf("data = %s, want null", got) + } } - response = invokeRawForTest(t, `{"apiVersion":2,"method":"xrayVersion"}`) + response = invokeRawForTest(t, `{"apiVersion":3,"method":"xrayVersion"}`) if !response.Success { - t.Fatalf("v2 apiVersion should succeed: %s", response.Err) + t.Fatalf("v3 apiVersion should succeed: %s", response.Err) } } @@ -683,7 +773,7 @@ func TestInvokeNoDataResponseShape(t *testing.T) { } requireNoDataObject(t, response) - response = invokeRawForTest(t, `{"apiVersion":2,"method":"runXray","payload":"invalid"}`) + response = invokeRawForTest(t, `{"apiVersion":3,"method":"runXray","payload":"invalid"}`) if response.Success { t.Fatal("invalid runXray payload should fail") } @@ -696,7 +786,7 @@ func TestInvokeIgnoresTopLevelEnv(t *testing.T) { const key = "XRAY_LIBXRAY_UNKNOWN_ENV_TEST" _ = os.Unsetenv(key) t.Cleanup(func() { _ = os.Unsetenv(key) }) - requestJSON := `{"apiVersion":2,"method":"xrayVersion","env":{"` + key + `":"/tmp"}}` + requestJSON := `{"apiVersion":3,"method":"xrayVersion","env":{"` + key + `":"/tmp"}}` var response testResponse if err := json.Unmarshal([]byte(Invoke(requestJSON)), &response); err != nil { t.Fatal(err) diff --git a/readme/README.zh_CN.md b/readme/README.zh_CN.md index 1c75b6fc..41d3f310 100644 --- a/readme/README.zh_CN.md +++ b/readme/README.zh_CN.md @@ -34,15 +34,19 @@ python3 build/main.py windows python3 build/main.py windows local ``` +构建成功或失败后都会恢复 `go.mod` 和 `go.sum`。gomobile 默认解析 `latest`, +也可通过环境变量 `LIBXRAY_GOMOBILE_VERSION` 指定 Go 模块版本;`gomobile` 与 +`gobind` 使用同一个解析版本。 + Linux 和 Windows 构建还会生成 `bin/xray` 或 `bin/xray.exe`。该会话 Core 会保护 Go DNS 查询不被 VPN 路由重新捕获,并且只接受以下命令: ```shell -xray run -dns -interface <网卡名> -config +xray run -dns -interface <网卡名> -config [-runtime ] ``` -三个参数都必须提供。`-dns` 必须是 IP endpoint,`-config` 直接指向 Xray -JSON 配置。 +前三个参数都必须提供。`-dns` 必须是 IP endpoint,`-config` 直接指向 Xray +JSON 配置。可选 `-runtime` 的 JSON 对象见“托管运行统计”,不含外层 `runtime`。 > [!WARNING] > **每个进程只能使用一个 Go runtime。** Go 不支持在同一进程中加载多个独立构建的 @@ -121,7 +125,7 @@ void CGoFree(char* value); ```json { - "apiVersion": 2, + "apiVersion": 3, "method": "runXray", "payload": { "xrayJson": "{\"outbounds\":[...]}" @@ -141,13 +145,13 @@ void CGoFree(char* value); 设计决定: -1. Invoke 当前只接受 `apiVersion: 2`。Xray 配置通过 `xrayJson` 传递 UTF-8 JSON 文本;libXray 不读取配置文件路径。 +1. Invoke 只接受 `apiVersion: 3`,API 版本固定为 3。合同变更在该版本内同步消费方与接入文档。Xray 配置通过 `xrayJson` 传递 UTF-8 JSON 文本;libXray 不读取配置文件路径。 2. 顶层 `env` 字段会被忽略且不会生效。Xray-core 运行时环境项应写入 Xray 配置根 `env` 对象。 3. `SetTunFd` 已删除。如果 fd 只能在运行时获得,请在调用 `runXray` 前把 `xray.tun.fd` 写入 Xray 配置根 `env` 对象。 4. `countGeoData` 不依赖 Xray 配置,因此通过 method payload 的 `datDir` 传入数据目录。 5. 完整的 UTF-8 编码 Invoke 请求和响应 JSON 包体限制为 16 MiB。任一方向超过限制时,Invoke 将返回 `success: false`、`data: null` 和对应的大小限制错误。 -6. `convertShareLinksToXrayJson` 会使用当前 Xray-core 配置构建器校验每个已解析的 outbound。无效 outbound 会被忽略;如果没有剩余的有效 outbound,该方法返回失败。校验不会创建或启动 Xray instance。Xray JSON 输入仅作为节点来源,只保留根级 `outbounds`,忽略其他根字段。响应仅包含 libXray 分享链接支持的字段,不支持的字段和生成的空字段会被省略;XHTTP `extra` 与 FinalMask mask `settings` 中的原始 JSON 保持不变。可选的 `age.secretKey` 会在现有解析流程前于内存中解密官方 age ASCII armor;明文输入保持原有行为。 -7. Xray-core 的系统拨号 DNS client 和 outbound manager 属于进程级状态。当 `runXray` 正在运行时,通过 `pingBatch`、`testXray` 或导出的 Go API 创建另一个 Xray instance,可能覆盖这些状态并影响正在运行的 instance。关闭临时 instance 不会恢复之前的状态。libXray 不对并发 instance 进行串行化、隔离或状态恢复;调用方如需同时运行多个 instance,必须将它们放在不同进程中。 +6. `convertShareLinksToXrayJson` 会使用当前 Xray-core 配置构建器校验每个已解析的 outbound。无效 outbound 会被忽略;如果没有剩余的有效 outbound,该方法返回失败。校验不会创建或启动 Xray instance。Xray JSON 输入仅作为节点来源,只保留根级 `outbounds`,忽略其他根字段。响应仅包含 libXray 分享链接支持的字段,不支持的字段和生成的空字段会被省略;XHTTP `extra` 与 FinalMask mask `settings` 中的原始 JSON 保持不变。每次成功响应都会返回投影后的配置及 `usableCount` 和 `failedCount`。可选的 `age.secretKey` 会在现有解析流程前于内存中解密官方 age ASCII armor;明文输入保持原有行为。 +7. Xray-core 的系统拨号 DNS client 和 outbound manager 属于进程级状态。`pingBatch`、`testXray` 及对应导出的 Go 入口均取得受管理生命周期锁,在加载/构建配置前拒绝同进程已运行的 `runXray` instance。批量测速在全部 worker 和临时核心关闭后才释放锁,这些操作也彼此串行。由管理 API 之外创建的 instance 不在检测或恢复范围内;可能与它们重叠的调用仍须使用独立进程。 支持的 method: @@ -218,7 +222,7 @@ LibXray.resetDNS(); ## share -libXray 使用 `sendThrough` 来存储节点名称。 +libXray 使用 `tag` 存储节点名称。`sendThrough` 保留 Xray 原生语义,用于指定本地绑定地址。 ### clash_meta @@ -234,6 +238,24 @@ libXray 使用 `sendThrough` 来存储节点名称。 转换 VMessQRCode 为 Xray Json。 +#### 解析结果 + +`convertShareLinksToXrayJson` 只有一种响应结构。payload 包含 `text` 和可选的 +`age`。每次转换成功均返回 +`data: {"config":{"outbounds":[...]},"usableCount":2,"failedCount":1}`。 + +数量只描述本次输入,不区分新增和更新。JSON 根 `outbounds` 的每个元素、YAML +`proxies` 的每个元素各算一个候选。已识别的分享链接列表中,每条 URI 形式的行 +算一个候选,空行、注释和文本标题忽略。Base64 / age 包装使用内部格式的候选 +数量。类型错误的单项会被跳过,不丢弃其余有效元素。`usableCount` 与最终投影且 +可构建的 outbound 数量相同;解析失败、构建失败和投影不支持的候选均计入 +`failedCount`。不做节点 hash 比较或去重。 + +已识别容器中没有可用节点时,返回 `success: false`,保留结构化数量和 +`config: {"outbounds":[]}`。无法识别格式、整份文档语法错误、容器错误或解密 +失败时返回 `data: null`,不猜测数量。错误文案不含被拒绝的候选或解密明文。 +调用方不得在可用节点为零时导入或覆盖订阅。 + ### age 加密订阅 `convertShareLinksToXrayJson` 接受可选的 age 原生私钥。仅支持 X25519 @@ -243,7 +265,7 @@ libXray 使用 `sendThrough` 来存储节点名称。 ```json { - "apiVersion": 2, + "apiVersion": 3, "method": "convertShareLinksToXrayJson", "payload": { "text": "-----BEGIN AGE ENCRYPTED FILE-----\n...", @@ -261,7 +283,7 @@ libXray 使用 `sendThrough` 来存储节点名称。 ```json { - "apiVersion": 2, + "apiVersion": 3, "method": "generateAgeKeyPair", "payload": { "keyType": "x25519" @@ -292,7 +314,7 @@ libXray 使用 `sendThrough` 来存储节点名称。 ```json { - "apiVersion": 2, + "apiVersion": 3, "method": "pingBatch", "payload": { "configs": [ @@ -305,7 +327,8 @@ libXray 使用 `sendThrough` 来存储节点名称。 } ], "timeout": 5, - "url": "https://cp.cloudflare.com/" + "url": "https://cp.cloudflare.com/", + "locationUrl": "https://ip-check-perf.radar.cloudflare.com/" } } ``` @@ -316,16 +339,30 @@ libXray 使用 `sendThrough` 来存储节点名称。 批次请求本身被接受时,顶层 response 为成功;每个配置通过自己的结果表示成功或 失败。`delay` 为 `10000` 表示错误,`11000` 表示超时。结果数组与输入配置数组 长度相同且顺序一致。 +`delay` 始终输出,包含成功的 0 毫秒结果。 通过 `streamSettings.sockopt.dialerProxy` 或 `proxySettings.tag` 引用的 outbound 依赖会被自动包含。 +`locationUrl` 为可选的绝对 HTTP(S) 地址。省略时不请求位置、不返回位置字段; +传入时,每个完成准备的配置先执行测速 HEAD,再执行位置 GET,两者使用同一个 +强制经过当前所选 outbound 及其依赖的 client。每个请求各有一次配置的超时, +因此单项最多可能使用两倍超时时间。位置请求时间不计入 `delay`;两个结果独立: +`success`、`delay`、`error` 只表示延迟结果,位置失败不影响成功的延迟,延迟 +失败后仍尝试位置 GET。 + +GET 成功后把响应正文原样放入 `locationJson` 字符串。JSON 解析和数据源专属字段 +处理由 App 负责。数据源必须返回 HTTP 200,正文最大 64 KiB;传输或正文读取失败 +改为返回 `locationError`。错误不回显 URL、凭据或响应正文。无效 outbound 保留 +原有逐项失败结果,不发出这两个请求。 + ### testXray -直接校验传入的 Xray JSON 文本,不读取配置文件: +加载并构建传入的完整 Xray JSON 文本。payload 仅包含 `xrayJson`,成功时返回 +`data: {}`: ```json { - "apiVersion": 2, + "apiVersion": 3, "method": "testXray", "payload": { "xrayJson": "{\"outbounds\":[...]}" @@ -333,11 +370,100 @@ outbound 依赖会被自动包含。 } ``` +Go 入口 `TestXray` 只调用 `core.LoadConfig`,不构造或启动 Xray instance 及运行时 +handler。它校验包括 TUN/WireGuard 定义在内的配置结构,不创建设备、监听、日志文件 +或后台连接。构建器仍可能读取本地 GeoData/证书,并将根 `env` 应用到当前进程。 +Geodata assets 声明只校验 HTTPS URL 和已存在的本地文件,下载器及 cron 不在校验时运行。 + +校验成功只说明配置可以构建,不保证运行资源可用、instance 可以启动或网络可以连接。 +调用方仍须处理实际启动失败。 + ### runXray 使用传入的 Xray JSON 文本启动由 libXray 管理的 Xray instance,并通过 `stopXray` 停止。`runXrayFromJson` 不再作为独立 method 存在。 +### 托管运行统计 + +API v3 的 `runXray.payload.runtime` 为可选宿主元数据。省略时保留原生命周期, +不写运行快照。宿主传入以下对象;Desktop 的 `-runtime` 文件也直接使用此对象, +不含外层 `runtime`,原始 Xray 配置仍通过独立的 `-config` 传入。 + +```json +{ + "statePath": "/private/app/run/runtime.json", + "inboundTag": "tunIn", + "listen": "127.0.0.1:49228", + "token": "538fc3253a3e433491bc2d653fc74214" +} +``` + +宿主提供已存在的私有目录和绝对 `statePath`。`inboundTag` 非空且不超过 256 字节。 +元数据独立于 Xray JSON,用户配置不能覆盖。指定入站必须存在,并启用上下行系统统计 +和 stats manager。 +`listen` / `token` 可同时省略,保留仅落盘、不启用 HTTP 的行为。启用时 `listen` +只能是 `127.0.0.1:`,端口范围 1–65535;宿主须生成新的 32 位小写十六进制 +随机 `token` 并保密,不能复用示例值。元数据无效、HTTP 端口被占用或首次保存失败 +均拒绝启动,并关闭已构建的核心和统计监听器。 + +落盘文件仅包含本次会话的原始入站计数: + +```json +{ + "version": 1, + "session": { + "id": "2a7e2e49b947a802d8b39af4fbc48f52", + "startedAtMs": 1788300000000, + "endedAtMs": 0, + "uplink": 120, + "downlink": 800 + }, + "available": true, + "sampledAtMs": 1788300030000, + "savedAtMs": 1788300030000, + "error": "" +} +``` + +时间为 Unix 毫秒。每次新启动生成 32 位小写十六进制随机 session ID,即使重放相同 +元数据也不复用。`endedAtMs: 0` 只表示没有保存最终停止快照,不能用来判断 VPN +仍在运行。宿主启动时先保存新快照,此后每 30 秒采样保存,`stopXray` 在关闭核心前 +尽力完成最终采样保存。 + +采样直接读取指定入站的 `Value()`,不重置计数,不叠加节点或 outbound 计数。 +重复采样不累加字节;非负计数回退时保存实际较小值,不合成差额。计数缺失或为负时, +`available: false`、`error: "counters_unavailable"`,保留上次合法的非负值。 +有效入站尚无流量时为可用的 0。不维护 App 总量、重置代次,也不提供 VPN 控制 HTTP +方法。`resetRuntime` 不是 Invoke method。App 可通过已有 Xray metrics +读取实时速率;App 累计与重置策略由 App 自行管理,不属于 libXray。 + +启动新会话时会原子覆盖之前的 `runtime.json`;libXray 不归档或合并旧会话。 +若 App 未在覆盖前读取流量,该数据将直接丢失。每个会话都从零开始,并生成新的 ID。 + +快照文件使用同目录 0600 临时文件,sync 后原子替换;Windows 使用 +`MoveFileEx` 的替换和 write-through 标志。私有父目录/Windows ACL 由宿主管理。 +保存失败保留上次完整磁盘快照供后续重试;最终保存失败向调用方报告,但仍关闭核心。 +rename 后发生 I/O 错误时结果可能不确定,消费者应在 HTTP 可用时重新读取已保存的快照。这是参考数据, +不是计费账本:崩溃、强杀或 App 读取前被新会话覆盖都可能丢失流量,不承诺严格的 +丢失上限。 + +`statePath + ".lock"` 的非阻塞操作系统文件锁保持至核心关闭,防止跨进程同时 +改写当前会话。宿主须使用一致的规范路径并保留锁文件。App 经 HTTP 读取快照, +无需打开宿主文件,因此 macOS System Extension 文件可继续归 root 所有。此能力 +不能让 Windows Job 强制终止获得正常最终结算。 + +#### 快照 HTTP + +可选统计监听器随托管会话启动,在停止时关闭,最终保存失败也会关闭。它使用独立于 +Xray 原生 metrics 的回环端口,不提供 VPN 启停或配置方法。所有请求必须携带 +`Authorization: Bearer `;响应使用 `Cache-Control: no-store`,不启用 CORS。 + +- `GET /runtime` 直接返回当前已保存的快照。 + +请求只读取宿主已保存的原子快照,不触发采样、计数重置或保存时间更新;实时速率仍使用 +原生 metrics。快照缺失、损坏或不是常规文件时返回服务不可用。请求有读写超时限制。 +停止期间 HTTP 不可用;libXray 不维护 App 累计值或清零策略。 + ### metrics 统计。 @@ -368,10 +494,7 @@ metrics 服务通过 HTTP 暴露 Xray 运行时计数。例如 `listen` 为 http://localhost:49227/debug/vars ``` -注意: - -1. 当进行测试延迟或验证配置时,确保 `metrics` 为 `null`。 -2. libXray 这里只需要 `listen` 字段。直接用 HTTP 客户端查询 `/debug/vars`,不再通过 libXray 包装。 +libXray 这里只需要 `listen` 字段。直接用 HTTP 客户端查询 `/debug/vars`,不再通过 libXray 包装。 ### validation diff --git a/share/age.go b/share/age.go index 1057911f..ee2c8208 100644 --- a/share/age.go +++ b/share/age.go @@ -7,7 +7,6 @@ import ( "github.com/metacubex/age" "github.com/metacubex/age/armor" - "github.com/xtls/xray-core/infra/conf" ) const ( @@ -62,40 +61,36 @@ func GenerateAgeKeyPair(keyType AgeKeyType) (*AgeKeyPair, error) { } } -func ConvertShareLinksToXrayJsonWithAge(links, secretKey string) (*conf.Config, error) { +func decryptShareText(links, secretKey string) (string, bool, error) { text := strings.TrimSpace(FixWindowsReturn(links)) if !strings.HasPrefix(text, ageArmorHeader) { - return ConvertShareLinksToXrayJson(links) + return links, false, nil } if strings.TrimSpace(secretKey) == "" { - return nil, ErrAgeSecretKeyMissing + return "", true, ErrAgeSecretKeyMissing } identity, _, err := parseNativeAgeIdentity(secretKey) if err != nil { - return nil, err + return "", true, err } reader, err := age.Decrypt(armor.NewReader(strings.NewReader(text)), identity) if err != nil { var noMatch *age.NoIdentityMatchError if errors.As(err, &noMatch) { - return nil, ErrAgeDecryptFailed + return "", true, ErrAgeDecryptFailed } - return nil, ErrAgeArmorMalformed + return "", true, ErrAgeArmorMalformed } plaintext, err := io.ReadAll(io.LimitReader(reader, maxAgePlaintextBytes+1)) if err != nil { - return nil, ErrAgeArmorMalformed + return "", true, ErrAgeArmorMalformed } if len(plaintext) > maxAgePlaintextBytes { - return nil, ErrAgePlaintextTooLarge + return "", true, ErrAgePlaintextTooLarge } - config, err := ConvertShareLinksToXrayJson(string(plaintext)) - if err != nil { - return nil, ErrAgePlaintextUnsupported - } - return config, nil + return string(plaintext), true, nil } func parseNativeAgeIdentity(secretKey string) (age.Identity, age.Recipient, error) { diff --git a/share/age_test.go b/share/age_test.go index 9b14a701..77abc171 100644 --- a/share/age_test.go +++ b/share/age_test.go @@ -39,12 +39,12 @@ func TestGenerateAgeKeyPairRejectsUnsupportedType(t *testing.T) { } } -func TestConvertShareLinksToXrayJsonWithAgePlaintext(t *testing.T) { +func TestConvertShareLinksToXrayJsonPlaintext(t *testing.T) { pair, err := GenerateAgeKeyPair(AgeKeyTypeX25519) if err != nil { t.Fatal(err) } - config, err := ConvertShareLinksToXrayJsonWithAge(ageTestShareLink, pair.SecretKey) + config, err := convertShareLinksWithKeyForTest(ageTestShareLink, pair.SecretKey) if err != nil { t.Fatal(err) } @@ -53,7 +53,7 @@ func TestConvertShareLinksToXrayJsonWithAgePlaintext(t *testing.T) { } } -func TestConvertShareLinksToXrayJsonWithAgeEncrypted(t *testing.T) { +func TestConvertShareLinksToXrayJsonEncrypted(t *testing.T) { for _, keyType := range []AgeKeyType{AgeKeyTypeX25519, AgeKeyTypeHybrid} { t.Run(string(keyType), func(t *testing.T) { pair, err := GenerateAgeKeyPair(keyType) @@ -61,7 +61,7 @@ func TestConvertShareLinksToXrayJsonWithAgeEncrypted(t *testing.T) { t.Fatal(err) } armored := encryptAgeForTest(t, pair, ageTestShareLink) - config, err := ConvertShareLinksToXrayJsonWithAge(armored, pair.SecretKey) + config, err := convertShareLinksWithKeyForTest(armored, pair.SecretKey) if err != nil { t.Fatal(err) } @@ -72,14 +72,14 @@ func TestConvertShareLinksToXrayJsonWithAgeEncrypted(t *testing.T) { } } -func TestConvertShareLinksToXrayJsonWithAgeErrors(t *testing.T) { +func TestConvertShareLinksToXrayJsonErrors(t *testing.T) { pair, err := GenerateAgeKeyPair(AgeKeyTypeX25519) if err != nil { t.Fatal(err) } armored := encryptAgeForTest(t, pair, ageTestShareLink) - _, err = ConvertShareLinksToXrayJsonWithAge(armored, "") + _, err = convertShareLinksWithKeyForTest(armored, "") if !errors.Is(err, ErrAgeSecretKeyMissing) { t.Fatalf("missing key error = %v", err) } @@ -88,7 +88,7 @@ func TestConvertShareLinksToXrayJsonWithAgeErrors(t *testing.T) { if err != nil { t.Fatal(err) } - _, err = ConvertShareLinksToXrayJsonWithAge(armored, wrongPair.SecretKey) + _, err = convertShareLinksWithKeyForTest(armored, wrongPair.SecretKey) if !errors.Is(err, ErrAgeDecryptFailed) { t.Fatalf("wrong key error = %v", err) } @@ -96,13 +96,13 @@ func TestConvertShareLinksToXrayJsonWithAgeErrors(t *testing.T) { t.Fatal("decryption error contains the secret key") } - _, err = ConvertShareLinksToXrayJsonWithAge(ageArmorHeader+"\ninvalid", pair.SecretKey) + _, err = convertShareLinksWithKeyForTest(ageArmorHeader+"\ninvalid", pair.SecretKey) if !errors.Is(err, ErrAgeArmorMalformed) { t.Fatalf("malformed armor error = %v", err) } } -func TestConvertShareLinksToXrayJsonWithAgeRejectsLargePlaintext(t *testing.T) { +func TestConvertShareLinksToXrayJsonRejectsLargePlaintext(t *testing.T) { pair, err := GenerateAgeKeyPair(AgeKeyTypeX25519) if err != nil { t.Fatal(err) @@ -112,20 +112,20 @@ func TestConvertShareLinksToXrayJsonWithAgeRejectsLargePlaintext(t *testing.T) { pair, strings.Repeat("x", maxAgePlaintextBytes+1), ) - _, err = ConvertShareLinksToXrayJsonWithAge(armored, pair.SecretKey) + _, err = convertShareLinksWithKeyForTest(armored, pair.SecretKey) if !errors.Is(err, ErrAgePlaintextTooLarge) { t.Fatalf("large plaintext error = %v", err) } } -func TestConvertShareLinksToXrayJsonWithAgeSanitizesParserErrors(t *testing.T) { +func TestConvertShareLinksToXrayJsonSanitizesParserErrors(t *testing.T) { pair, err := GenerateAgeKeyPair(AgeKeyTypeX25519) if err != nil { t.Fatal(err) } sensitivePlaintext := "unsupported://user:password@example.com" armored := encryptAgeForTest(t, pair, sensitivePlaintext) - _, err = ConvertShareLinksToXrayJsonWithAge(armored, pair.SecretKey) + _, err = convertShareLinksWithKeyForTest(armored, pair.SecretKey) if !errors.Is(err, ErrAgePlaintextUnsupported) { t.Fatalf("unsupported plaintext error = %v", err) } diff --git a/share/clash_meta.go b/share/clash_meta.go index 315f9ae7..d746d060 100644 --- a/share/clash_meta.go +++ b/share/clash_meta.go @@ -3,17 +3,11 @@ package share import ( "fmt" - "gopkg.in/yaml.v3" - "github.com/xtls/xray-core/infra/conf" ) // https://github.com/MetaCubeX/mihomo/blob/Alpha/docs/config.yaml -type ClashYaml struct { - Proxies []ClashProxy `yaml:"proxies,omitempty"` -} - type ClashProxy struct { Name string `yaml:"name,omitempty"` Type string `yaml:"type,omitempty"` @@ -128,71 +122,20 @@ type ClashProxyXhttpOptsDownloadSettings struct { ClientFingerprint string `yaml:"client-fingerprint,omitempty"` } -func tryToParseClashYaml(text string) (*conf.Config, error) { - var clash ClashYaml - if err := yaml.Unmarshal([]byte(text), &clash); err != nil { - return nil, err - } - config := clash.toXrayConfig() - if len(config.OutboundConfigs) == 0 { - return nil, fmt.Errorf("no valid outbound found") - } - return config, nil -} - -func (clash ClashYaml) toXrayConfig() *conf.Config { - outbounds := make([]conf.OutboundDetourConfig, 0, len(clash.Proxies)) - for _, proxy := range clash.Proxies { - outbound, err := proxy.outbound() - if err != nil { - continue - } - outbounds = append(outbounds, *outbound) - } - return &conf.Config{OutboundConfigs: outbounds} -} - func (proxy ClashProxy) outbound() (*conf.OutboundDetourConfig, error) { switch proxy.Type { case "ss": - outbound, err := proxy.shadowsocksOutbound() - if err != nil { - return nil, err - } - return outbound, nil - + return proxy.shadowsocksOutbound() case "vmess": - outbound, err := proxy.vmessOutbound() - if err != nil { - return nil, err - } - return outbound, nil - + return proxy.vmessOutbound() case "vless": - outbound, err := proxy.vlessOutbound() - if err != nil { - return nil, err - } - return outbound, nil - + return proxy.vlessOutbound() case "socks5": - outbound, err := proxy.socksOutbound() - if err != nil { - return nil, err - } - return outbound, nil + return proxy.socksOutbound() case "trojan": - outbound, err := proxy.trojanOutbound() - if err != nil { - return nil, err - } - return outbound, nil + return proxy.trojanOutbound() case "hysteria2": - outbound, err := proxy.hysteria2Outbound() - if err != nil { - return nil, err - } - return outbound, nil + return proxy.hysteria2Outbound() } return nil, fmt.Errorf("unsupported proxy type: %s", proxy.Type) } diff --git a/share/clash_meta_test.go b/share/clash_meta_test.go index 9563f448..d24b0e2d 100644 --- a/share/clash_meta_test.go +++ b/share/clash_meta_test.go @@ -22,15 +22,17 @@ func clashHysteria2YAML(fields string) string { func parseClashHy2(t *testing.T, yaml string) *conf.OutboundDetourConfig { t.Helper() - config, err := tryToParseClashYaml(yaml) + config, err := parseShareCandidatesForTest(yaml) require.NoError(t, err) require.Len(t, config.OutboundConfigs, 1) + assert.Equal(t, "test-hy2", config.OutboundConfigs[0].Tag) + assert.Nil(t, config.OutboundConfigs[0].SendThrough) return &config.OutboundConfigs[0] } func parseClashYAML(t *testing.T, yaml string) *conf.Config { t.Helper() - cfg, err := tryToParseClashYaml(yaml) + cfg, err := parseShareCandidatesForTest(yaml) require.NoError(t, err) return cfg } diff --git a/share/convert_share.go b/share/convert_share.go new file mode 100644 index 00000000..74c19b73 --- /dev/null +++ b/share/convert_share.go @@ -0,0 +1,120 @@ +package share + +import ( + "encoding/json" + "errors" + "net/url" + "strings" + + "github.com/xtls/xray-core/infra/conf" + "gopkg.in/yaml.v3" +) + +// ConvertShareLinksResult counts source candidates, not lines or changes to a subscription. +// Config contains exactly the projected, buildable outbounds counted as usable. +type ConvertShareLinksResult struct { + Config json.RawMessage `json:"config"` + UsableCount int `json:"usableCount"` + FailedCount int `json:"failedCount"` +} + +// ConvertShareLinksToXrayJson parses share links or an Age-encrypted subscription. +// A recognized candidate container with no usable nodes returns both its counts +// and an error. Whole-document/decryption failures return no invented counts. +func ConvertShareLinksToXrayJson(links, secretKey string) (*ConvertShareLinksResult, error) { + text, encrypted, err := decryptShareText(links, secretKey) + if err != nil { + return nil, err + } + config, candidates, err := parseShareCandidates(text, true) + if err != nil { + if encrypted { + return nil, ErrAgePlaintextUnsupported + } + return nil, err + } + result := &ConvertShareLinksResult{Config: json.RawMessage(`{"outbounds":[]}`), FailedCount: candidates} + config, err = filterBuildableOutbounds(config) + if err == nil { + var raw json.RawMessage + var usable int + raw, usable, err = marshalShareConfigJSON(config) + if err == nil { + result.Config, result.UsableCount, result.FailedCount = raw, usable, candidates-usable + return result, nil + } + } + if encrypted { + return result, ErrAgePlaintextUnsupported + } + // Builder errors can contain credentials or whole source values. Counts do + // not require those diagnostics; never echo rejected candidates. + return result, errors.New("no valid outbound found") +} + +func parseShareCandidates(links string, allowBase64 bool) (*conf.Config, int, error) { + text := strings.TrimSpace(FixWindowsReturn(links)) + config := &conf.Config{} + if strings.HasPrefix(text, "{") { + var document struct { + Outbounds []json.RawMessage `json:"outbounds"` + } + if err := json.Unmarshal([]byte(text), &document); err != nil || document.Outbounds == nil { + return nil, 0, errors.New("invalid share JSON outbounds") + } + for _, raw := range document.Outbounds { + var outbound conf.OutboundDetourConfig + if err := json.Unmarshal(raw, &outbound); err == nil { + config.OutboundConfigs = append(config.OutboundConfigs, outbound) + } + } + return config, len(document.Outbounds), nil + } + if hasShareSchemeLine(text) { + candidates := 0 + for raw := range strings.SplitSeq(text, "\n") { + line := strings.TrimSpace(raw) + // Subscription comments/headers are not node candidates. A URI-like + // row is one candidate, including an unsupported or malformed URI. + scheme, _, found := strings.Cut(line, "://") + if !found || strings.ContainsAny(scheme, " \t#") { + continue + } + candidates++ + parsed, err := url.Parse(line) + if err != nil { + continue + } + outbound, err := (xrayShareLink{link: parsed, rawText: line}).outbound() + if err == nil { + config.OutboundConfigs = append(config.OutboundConfigs, *outbound) + } + } + return config, candidates, nil + } + if allowBase64 { + if decoded, err := decodeBase64Text(text); err == nil { + return parseShareCandidates(decoded, false) + } + } + if hasTopLevelClashProxiesKey(text) { + var document struct { + Proxies []yaml.Node `yaml:"proxies"` + } + if err := yaml.Unmarshal([]byte(text), &document); err != nil || document.Proxies == nil { + return nil, 0, errors.New("invalid share YAML proxies") + } + for _, node := range document.Proxies { + var proxy ClashProxy + if err := node.Decode(&proxy); err != nil { + continue + } + outbound, err := proxy.outbound() + if err == nil { + config.OutboundConfigs = append(config.OutboundConfigs, *outbound) + } + } + return config, len(document.Proxies), nil + } + return nil, 0, errors.New("unsupported share format") +} diff --git a/share/convert_share_test.go b/share/convert_share_test.go new file mode 100644 index 00000000..7a96fc91 --- /dev/null +++ b/share/convert_share_test.go @@ -0,0 +1,129 @@ +package share + +import ( + "encoding/base64" + "encoding/json" + "strings" + "testing" +) + +const statsValidOutbound = `{"protocol":"vless","tag":"Keep","settings":{"address":"example.com","port":443,"id":"12345678-abcd-abcd-abcd-123456789abc","encryption":"none"},"streamSettings":{"security":"tls","tlsSettings":{"serverName":"example.com"}}}` + +func TestConvertShareLinksCountActualCandidates(t *testing.T) { + jsonText := `{"outbounds":[` + statsValidOutbound + `,{"protocol":"freedom"},{"protocol":42},null,{"protocol":"vless","settings":{"id":"invalid"}}]}` + yamlText := `proxies: + - {name: Keep, type: vless, server: example.com, port: 443, uuid: 12345678-abcd-abcd-abcd-123456789abc, tls: true, servername: example.com} + - {name: Unsupported, type: unknown} + - {name: InvalidPort, type: vless, port: invalid} + - {name: InvalidId, type: vless, server: example.com, port: 443, uuid: invalid} + - null +` + for _, test := range []struct { + name, text string + usable, failed int + }{ + {"links with headers", "Subscription export\n# comment\n\n" + ageTestShareLink + "\nvless://bad@example.com:443?encryption=none\nunknown://example.com", 1, 2}, + {"JSON elements", jsonText, 1, 4}, + {"YAML elements", yamlText, 1, 4}, + {"base64 JSON", base64.StdEncoding.EncodeToString([]byte(jsonText)), 1, 4}, + {"base64 YAML", base64.RawURLEncoding.EncodeToString([]byte(yamlText)), 1, 4}, + {"base64 links", base64.StdEncoding.EncodeToString([]byte(ageTestShareLink + "\nvless://bad@example.com:443")), 1, 1}, + } { + t.Run(test.name, func(t *testing.T) { + result, err := ConvertShareLinksToXrayJson(test.text, "") + if err != nil { + t.Fatal(err) + } + assertShareStats(t, result, test.usable, test.failed) + }) + } +} + +func TestConvertShareLinksAllInvalidRetainsCountsWithoutSource(t *testing.T) { + for _, input := range []string{ + `{"outbounds":[{"protocol":"freedom"}]}`, + "vless://secret-not-a-uuid@example.com:443?encryption=none", + "proxies:\n - {type: unsupported, password: private-password}", + } { + result, err := ConvertShareLinksToXrayJson(input, "") + if err == nil || err.Error() != "no valid outbound found" { + t.Fatalf("error = %v", err) + } + assertShareStats(t, result, 0, 1) + } + result, err := ConvertShareLinksToXrayJson(`{"outbounds":[]}`, "") + if err == nil { + t.Fatal("empty array succeeded") + } + assertShareStats(t, result, 0, 0) +} + +func TestConvertShareLinksMalformedDocumentHasNoCounts(t *testing.T) { + for _, input := range []string{ + `{"outbounds":[`, `{"outbounds":"private-source"}`, `{"outbounds":null}`, + "proxies: [", "proxies: private-source", "not a subscription", + } { + result, err := ConvertShareLinksToXrayJson(input, "") + if err == nil || result != nil { + t.Fatalf("result = %+v, error = %v", result, err) + } + if strings.Contains(err.Error(), "private-source") { + t.Fatal("error leaked source") + } + } +} + +func TestConvertShareLinksAgeCountsInnerCandidatesAndRedactsErrors(t *testing.T) { + pair, err := GenerateAgeKeyPair(AgeKeyTypeX25519) + if err != nil { + t.Fatal(err) + } + for _, input := range []string{ + ageTestShareLink + "\nvless://secret-not-a-uuid@example.com:443", + `{"outbounds":[` + statsValidOutbound + `,{"protocol":false}]}`, + } { + result, err := ConvertShareLinksToXrayJson(encryptAgeForTest(t, pair, input), pair.SecretKey) + if err != nil { + t.Fatal(err) + } + assertShareStats(t, result, 1, 1) + } + for _, test := range []struct { + text string + hasCounts bool + }{ + {"vless://secret-not-a-uuid@example.com:443", true}, + {`{"outbounds":"private-source"}`, false}, + } { + result, err := ConvertShareLinksToXrayJson(encryptAgeForTest(t, pair, test.text), pair.SecretKey) + if err != ErrAgePlaintextUnsupported { + t.Fatalf("error = %v", err) + } + if (result != nil) != test.hasCounts { + t.Fatalf("counts = %+v", result) + } + if result != nil { + assertShareStats(t, result, 0, 1) + } + } + result, err := ConvertShareLinksToXrayJson(encryptAgeForTest(t, pair, ageTestShareLink), "private-invalid-key") + if err != ErrAgeSecretKeyInvalid || result != nil { + t.Fatalf("result = %+v, error = %v", result, err) + } +} + +func assertShareStats(t *testing.T, result *ConvertShareLinksResult, usable, failed int) { + t.Helper() + if result == nil || result.UsableCount != usable || result.FailedCount != failed { + t.Fatalf("stats = %+v, want usable %d failed %d", result, usable, failed) + } + var config struct { + Outbounds []json.RawMessage `json:"outbounds"` + } + if err := json.Unmarshal(result.Config, &config); err != nil { + t.Fatal(err) + } + if config.Outbounds == nil || len(config.Outbounds) != usable { + t.Fatalf("config count = %d, want %d", len(config.Outbounds), usable) + } +} diff --git a/share/generate_share_test.go b/share/generate_share_test.go index f8b8e21e..01ba4a81 100644 --- a/share/generate_share_test.go +++ b/share/generate_share_test.go @@ -123,7 +123,7 @@ func TestGenerate_Hy2_WithFullTLSParams(t *testing.T) { func TestGenerate_Hy2_RoundTrip(t *testing.T) { original := "hy2://auth@host:443?up=50+mbps&down=100+mbps&obfs=salamander&obfs-password=secret&ports=20000-40000&hop-interval=30&sni=example.com&alpn=h3&fp=chrome" - config, err := ConvertShareLinksToXrayJson(original) + config, err := convertShareLinksForTest(original) require.NoError(t, err) require.Len(t, config.OutboundConfigs, 1) @@ -158,14 +158,14 @@ func TestConvertXrayJsonToShareLinks_RoundTripProtocols(t *testing.T) { } for _, link := range cases { t.Run(link[:12], func(t *testing.T) { - cfg, err := ConvertShareLinksToXrayJson(link) + cfg, err := convertShareLinksForTest(link) require.NoError(t, err) out, err := json.Marshal(cfg) require.NoError(t, err) text, err := ConvertXrayJsonToShareLinks(out) require.NoError(t, err) assert.NotEmpty(t, text) - again, err := ConvertShareLinksToXrayJson(text) + again, err := convertShareLinksForTest(text) require.NoError(t, err) require.Len(t, again.OutboundConfigs, 1) assert.Equal(t, cfg.OutboundConfigs[0].Protocol, again.OutboundConfigs[0].Protocol) @@ -174,7 +174,7 @@ func TestConvertXrayJsonToShareLinks_RoundTripProtocols(t *testing.T) { } func TestGenerate_KCPIgnoresSeedAndHeader(t *testing.T) { - config, err := ConvertShareLinksToXrayJson( + config, err := convertShareLinksForTest( "vless://" + testShareUUID + "@kcp.example:443?encryption=none&type=kcp", ) require.NoError(t, err) @@ -198,7 +198,7 @@ func TestGenerate_ShadowsocksAEAD2022PlainUserInfo(t *testing.T) { "YctPZ6U7xPPcU%2Bgp3u%2B0tx%2FtRizJN9K8y%2BuKlW2qjlI%3D" + "@192.168.100.1:8888#Example3" - config, err := ConvertShareLinksToXrayJson(original) + config, err := convertShareLinksForTest(original) require.NoError(t, err) require.Len(t, config.OutboundConfigs, 1) @@ -211,7 +211,7 @@ func TestGenerate_ShadowsocksLegacyBase64UserInfo(t *testing.T) { original := "ss://" + ssUserB64("aes-128-gcm", "password") + "@ss.example.com:8388#Legacy" - config, err := ConvertShareLinksToXrayJson(original) + config, err := convertShareLinksForTest(original) require.NoError(t, err) require.Len(t, config.OutboundConfigs, 1) @@ -264,12 +264,12 @@ func TestConvertXrayJsonToShareLinksSkipsUnsupportedOutbounds(t *testing.T) { assert.Equal(t, "trojan://password@example.com:443#trojan", links) } -func TestConvertXrayJsonToShareLinks_PrefersTagWhenSendThroughEmpty(t *testing.T) { - cfg, err := ConvertShareLinksToXrayJson(`trojan://pw@tag.example:443`) +func TestConvertXrayJsonToShareLinks_IgnoresSendThroughForName(t *testing.T) { + cfg, err := convertShareLinksForTest(`trojan://pw@tag.example:443`) require.NoError(t, err) ob := cfg.OutboundConfigs[0] - empty := "" - ob.SendThrough = &empty + sendThrough := "127.0.0.1" + ob.SendThrough = &sendThrough ob.Tag = "named-by-tag" out, err := json.Marshal(&conf.Config{OutboundConfigs: []conf.OutboundDetourConfig{ob}}) require.NoError(t, err) diff --git a/share/marshal_share.go b/share/marshal_share.go index 729cd64d..835f425f 100644 --- a/share/marshal_share.go +++ b/share/marshal_share.go @@ -10,44 +10,36 @@ import ( "github.com/xtls/xray-core/infra/conf" ) -// MarshalShareConfigJSON returns the Xray JSON subset supported by share links. -func MarshalShareConfigJSON(config *conf.Config) (json.RawMessage, error) { +func marshalShareConfigJSON(config *conf.Config) (json.RawMessage, int, error) { if config == nil { - return nil, fmt.Errorf("no valid outbound found") + return nil, 0, fmt.Errorf("no valid outbound found") } outbounds := make([]map[string]any, 0, len(config.OutboundConfigs)) - var firstBuildError error for _, outbound := range config.OutboundConfigs { source, err := marshalShareJSONObject(outbound) if err != nil { - return nil, err + return nil, 0, err } projected, supported := projectShareOutbound(source) if !supported { continue } if err := validateProjectedShareOutbound(projected); err != nil { - if firstBuildError == nil { - firstBuildError = err - } continue } outbounds = append(outbounds, projected) } if len(outbounds) == 0 { - if firstBuildError != nil { - return nil, fmt.Errorf("no valid outbound found: %w", firstBuildError) - } - return nil, fmt.Errorf("no valid outbound found") + return nil, 0, fmt.Errorf("no valid outbound found") } raw, err := json.Marshal(map[string]any{"outbounds": outbounds}) if err != nil { - return nil, fmt.Errorf("failed to marshal share config: %w", err) + return nil, 0, fmt.Errorf("failed to marshal share config: %w", err) } - return raw, nil + return raw, len(outbounds), nil } func marshalShareJSONObject(value any) (map[string]any, error) { @@ -306,8 +298,6 @@ func validateProjectedShareOutbound(projected map[string]any) error { if err := json.Unmarshal(raw, &outbound); err != nil { return err } - // sendThrough stores the node display name during share conversion. - outbound.SendThrough = nil _, err = outbound.Build() return err } diff --git a/share/marshal_share_test.go b/share/marshal_share_test.go index e915bcba..9b8dff6a 100644 --- a/share/marshal_share_test.go +++ b/share/marshal_share_test.go @@ -10,7 +10,7 @@ import ( "github.com/xtls/xray-core/infra/conf" ) -func TestMarshalShareConfigJSONProjectsSupportedFields(t *testing.T) { +func TestMarshalShareConfigProjectsSupportedFields(t *testing.T) { const input = `{ "log":{"loglevel":"warning"}, "outbounds":[ @@ -27,8 +27,8 @@ func TestMarshalShareConfigJSONProjectsSupportedFields(t *testing.T) { }, { "protocol":"VLESS", - "sendThrough":"Node name", - "tag":"tag fallback", + "sendThrough":"127.0.0.1", + "tag":"Node name", "settings":{ "address":"example.com","port":443,"id":"12345678-abcd-abcd-abcd-123456789abc", "flow":"","encryption":"none","level":1,"email":"drop@example.com","seed":"drop","reverse":{} @@ -53,14 +53,14 @@ func TestMarshalShareConfigJSONProjectsSupportedFields(t *testing.T) { var config conf.Config require.NoError(t, json.Unmarshal([]byte(input), &config)) - raw, err := MarshalShareConfigJSON(&config) + raw, _, err := marshalShareConfigJSON(&config) require.NoError(t, err) const expected = `{ "outbounds":[{ "protocol":"vless", - "sendThrough":"Node name", - "tag":"tag fallback", + "sendThrough":"127.0.0.1", + "tag":"Node name", "settings":{"address":"example.com","port":443,"id":"12345678-abcd-abcd-abcd-123456789abc","encryption":"none"}, "streamSettings":{ "network":"xhttp","security":"reality", @@ -73,12 +73,12 @@ func TestMarshalShareConfigJSONProjectsSupportedFields(t *testing.T) { requireProjectedOutboundsBuild(t, raw) } -func TestMarshalShareConfigJSONPreservesHysteriaPortHopping(t *testing.T) { - config, err := ConvertShareLinksToXrayJson( +func TestMarshalShareConfigPreservesHysteriaPortHopping(t *testing.T) { + config, err := convertShareLinksForTest( "hy2://auth@host:443?up=50+mbps&down=100+mbps&ports=20000-40000&hop-interval=30&sni=example.com&fp=chrome", ) require.NoError(t, err) - raw, err := MarshalShareConfigJSON(config) + raw, _, err := marshalShareConfigJSON(config) require.NoError(t, err) var document map[string]any @@ -93,12 +93,12 @@ func TestMarshalShareConfigJSONPreservesHysteriaPortHopping(t *testing.T) { requireProjectedOutboundsBuild(t, raw) } -func TestMarshalShareConfigJSONKeepsKCPWithoutSettings(t *testing.T) { +func TestMarshalShareConfigKeepsKCPWithoutSettings(t *testing.T) { qr := `{"ps":"k","add":"kcp.host","port":"8391","id":"` + testShareUUID + `","net":"kcp","path":"seedval","type":"wireguard"}` link := "vmess://" + base64.StdEncoding.EncodeToString([]byte(qr)) - config, err := ConvertShareLinksToXrayJson(link) + config, err := convertShareLinksForTest(link) require.NoError(t, err) - raw, err := MarshalShareConfigJSON(config) + raw, _, err := marshalShareConfigJSON(config) require.NoError(t, err) assert.Contains(t, string(raw), `"network":"kcp"`) @@ -108,7 +108,7 @@ func TestMarshalShareConfigJSONKeepsKCPWithoutSettings(t *testing.T) { requireProjectedOutboundsBuild(t, raw) } -func TestMarshalShareConfigJSONSupportedProtocolsBuild(t *testing.T) { +func TestMarshalShareConfigSupportedProtocolsBuild(t *testing.T) { tests := map[string]string{ "shadowsocks": "ss://" + ssUserB64("chacha20-ietf-poly1305", "password") + "@10.0.0.1:8388", "vmess": "vmess://" + testShareUUID + "@vm.example:443?encryption=auto&type=raw", @@ -119,9 +119,9 @@ func TestMarshalShareConfigJSONSupportedProtocolsBuild(t *testing.T) { } for protocol, link := range tests { t.Run(protocol, func(t *testing.T) { - config, err := ConvertShareLinksToXrayJson(link) + config, err := convertShareLinksForTest(link) require.NoError(t, err) - raw, err := MarshalShareConfigJSON(config) + raw, _, err := marshalShareConfigJSON(config) require.NoError(t, err) requireProjectedOutboundsBuild(t, raw) }) @@ -134,7 +134,6 @@ func requireProjectedOutboundsBuild(t *testing.T, raw json.RawMessage) { require.NoError(t, json.Unmarshal(raw, &config)) require.NotEmpty(t, config.OutboundConfigs) for index := range config.OutboundConfigs { - config.OutboundConfigs[index].SendThrough = nil _, err := config.OutboundConfigs[index].Build() require.NoError(t, err) } diff --git a/share/parse_share.go b/share/parse_share.go index 7a24af6e..0b2bac8d 100644 --- a/share/parse_share.go +++ b/share/parse_share.go @@ -2,7 +2,6 @@ package share import ( "encoding/base64" - "encoding/json" "fmt" "net/url" "strconv" @@ -43,126 +42,35 @@ func decodeBase64Text(text string) (string, error) { // https://github.com/XTLS/Xray-core/discussions/716 -// ConvertShareLinksToXrayJson parses: -// - a single Xray JSON object (starts with '{'; only root outbounds are retained) -// - plain v2rayN-style lines (vless/vmess/ss/socks/trojan/hy2…) -// - one base64 blob that decodes to Xray JSON, share lines, or Clash YAML -// - Clash / Clash.Meta YAML (proxies:) -func ConvertShareLinksToXrayJson(links string) (*conf.Config, error) { - config, err := convertShareLinksToXrayJson(links, true) - if err != nil { - return nil, err - } - return filterBuildableOutbounds(config) -} - -func convertShareLinksToXrayJson(links string, allowBase64 bool) (*conf.Config, error) { - text := strings.TrimSpace(FixWindowsReturn(links)) - if text == "" { - return nil, fmt.Errorf("unsupported share format") - } - if strings.HasPrefix(text, "{") { - return parseXrayJSONConfig(text) - } - if hasShareSchemeLine(text) { - return parsePlainShareLines(text) - } - if allowBase64 { - decoded, err := decodeBase64Text(text) - if err == nil { - return convertShareLinksToXrayJson(decoded, false) - } - } - if hasTopLevelClashProxiesKey(text) { - return tryToParseClashYaml(text) - } - return nil, fmt.Errorf("unsupported share format") -} - -func parseXrayJSONConfig(text string) (*conf.Config, error) { - var xray *conf.Config - if err := json.Unmarshal([]byte(text), &xray); err != nil { - return nil, err - } - if len(xray.OutboundConfigs) == 0 { - return nil, fmt.Errorf("no valid outbounds") - } - return &conf.Config{OutboundConfigs: xray.OutboundConfigs}, nil -} - var shareSchemes = []string{ "vless://", "vmess://", "socks://", "ss://", "trojan://", "hysteria2://", "hy2://", } func hasShareSchemeLine(text string) bool { - found := false - forEachLine(text, func(raw string) bool { + for raw := range strings.SplitSeq(text, "\n") { line := strings.TrimSpace(raw) for _, p := range shareSchemes { if strings.HasPrefix(line, p) { - found = true - return false + return true } } - return true - }) - return found + } + return false } func hasTopLevelClashProxiesKey(text string) bool { - found := false - forEachLine(text, func(raw string) bool { + for raw := range strings.SplitSeq(text, "\n") { line := strings.TrimRight(raw, " \t") trimmed := strings.TrimSpace(line) if trimmed == "" || trimmed == "---" || strings.HasPrefix(trimmed, "#") { - return true + continue } if strings.HasPrefix(line, "proxies:") { - found = true - return false - } - return true - }) - return found -} - -func forEachLine(text string, visit func(string) bool) { - for { - line, rest, ok := strings.Cut(text, "\n") - if !visit(line) { - return - } - if !ok { - return - } - text = rest - } -} - -func parsePlainShareLines(text string) (*conf.Config, error) { - outbounds := make([]conf.OutboundDetourConfig, 0) - forEachLine(text, func(raw string) bool { - line := strings.TrimSpace(raw) - if line == "" { - return true - } - u, err := url.Parse(line) - if err != nil { - return true - } - sl := xrayShareLink{link: u, rawText: line} - ob, err := sl.outbound() - if err != nil { return true } - outbounds = append(outbounds, *ob) - return true - }) - if len(outbounds) == 0 { - return nil, fmt.Errorf("no valid outbound found") } - return &conf.Config{OutboundConfigs: outbounds}, nil + return false } type xrayShareLink struct { diff --git a/share/parse_share_test.go b/share/parse_share_test.go index 6ccdc1ee..89f3dd5c 100644 --- a/share/parse_share_test.go +++ b/share/parse_share_test.go @@ -19,7 +19,7 @@ func ssUserB64(cipher, password string) string { func parseHy2Link(t *testing.T, link string) *conf.OutboundDetourConfig { t.Helper() - config, err := ConvertShareLinksToXrayJson(link) + config, err := convertShareLinksForTest(link) require.NoError(t, err) require.Len(t, config.OutboundConfigs, 1) return &config.OutboundConfigs[0] @@ -158,7 +158,7 @@ func TestFixWindowsReturn(t *testing.T) { } func TestConvertShareLinksToXrayJson_XrayJSONRoundTrip(t *testing.T) { - orig, err := ConvertShareLinksToXrayJson( + orig, err := convertShareLinksForTest( "vless://" + testShareUUID + "@example.com:443?encryption=none&security=tls&sni=example.com&type=ws&path=%2Fp&host=cdn.example.com#tag1", ) require.NoError(t, err) @@ -167,14 +167,14 @@ func TestConvertShareLinksToXrayJson_XrayJSONRoundTrip(t *testing.T) { raw, err := json.Marshal(orig) require.NoError(t, err) - again, err := ConvertShareLinksToXrayJson(string(raw)) + again, err := convertShareLinksForTest(string(raw)) require.NoError(t, err) require.Len(t, again.OutboundConfigs, 1) assert.Equal(t, orig.OutboundConfigs[0].Protocol, again.OutboundConfigs[0].Protocol) } func TestConvertShareLinksToXrayJson_XrayJSONKeepsOnlyOutbounds(t *testing.T) { - config, err := ConvertShareLinksToXrayJson(`{ + config, err := convertShareLinksForTest(`{ "env": {"TEST_ENV": "value"}, "log": {"loglevel": "debug"}, "routing": {"rules": []}, @@ -193,12 +193,12 @@ func TestConvertShareLinksToXrayJson_XrayJSONKeepsOnlyOutbounds(t *testing.T) { } func TestConvertShareLinksToXrayJson_XrayJSONInvalid(t *testing.T) { - _, err := ConvertShareLinksToXrayJson("{not json") + _, err := convertShareLinksForTest("{not json") require.Error(t, err) } func TestConvertShareLinksToXrayJson_XrayJSONNoOutbounds(t *testing.T) { - _, err := ConvertShareLinksToXrayJson(`{"outbounds":[]}`) + _, err := convertShareLinksForTest(`{"outbounds":[]}`) require.Error(t, err) assert.Contains(t, err.Error(), "outbound") } @@ -208,21 +208,20 @@ func TestConvertShareLinksToXrayJson_FiltersBuildInvalidOutbounds(t *testing.T) "vless://" + testShareUUID + "@invalid-reality.example:443?encryption=none&security=reality&sni=invalid-reality.example&pbk=invalid&fp=chrome\n" + "vless://" + testShareUUID + "@valid.example:443?encryption=none&security=tls&sni=valid.example&fp=chrome#Valid" - config, err := ConvertShareLinksToXrayJson(links) + config, err := convertShareLinksForTest(links) require.NoError(t, err) require.Len(t, config.OutboundConfigs, 1) - require.NotNil(t, config.OutboundConfigs[0].SendThrough) - assert.Equal(t, "Valid", *config.OutboundConfigs[0].SendThrough) + assert.Equal(t, "Valid", config.OutboundConfigs[0].Tag) + assert.Nil(t, config.OutboundConfigs[0].SendThrough) } func TestConvertShareLinksToXrayJson_AllBuildInvalidOutbounds(t *testing.T) { - _, err := ConvertShareLinksToXrayJson( + _, err := convertShareLinksForTest( "vless://2418d087-648k-4990-86e8-19dca1d006d3@invalid.example:443?encryption=none&security=tls&sni=invalid.example&fp=chrome", ) require.Error(t, err) - assert.Contains(t, err.Error(), "no valid outbound found") - assert.Contains(t, err.Error(), "invalid byte") + assert.EqualError(t, err, "no valid outbound found") } func TestConvertShareLinksToXrayJson_Base64EncodedLines(t *testing.T) { @@ -230,7 +229,7 @@ func TestConvertShareLinksToXrayJson_Base64EncodedLines(t *testing.T) { "ss://" + ssUserB64("aes-128-gcm", "pwd") + "@ss.example.com:8388#ssn" blob := base64.StdEncoding.EncodeToString([]byte(lines)) - cfg, err := ConvertShareLinksToXrayJson(blob) + cfg, err := convertShareLinksForTest(blob) require.NoError(t, err) require.Len(t, cfg.OutboundConfigs, 2) assert.Equal(t, "trojan", cfg.OutboundConfigs[0].Protocol) @@ -240,7 +239,7 @@ func TestConvertShareLinksToXrayJson_Base64EncodedLines(t *testing.T) { func TestConvertShareLinksToXrayJson_Base64URLSafeBlob(t *testing.T) { inner := "vless://" + testShareUUID + "@10.0.0.1:443?encryption=none&security=none" b := base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString([]byte(inner)) - cfg, err := ConvertShareLinksToXrayJson(b) + cfg, err := convertShareLinksForTest(b) require.NoError(t, err) require.Len(t, cfg.OutboundConfigs, 1) assert.Equal(t, "vless", cfg.OutboundConfigs[0].Protocol) @@ -248,7 +247,7 @@ func TestConvertShareLinksToXrayJson_Base64URLSafeBlob(t *testing.T) { func TestConvertShareLinksToXrayJson_Shadowsocks(t *testing.T) { link := "ss://" + ssUserB64("chacha20-ietf-poly1305", "mypass") + "@10.0.0.1:8388#frag" - cfg, err := ConvertShareLinksToXrayJson(link) + cfg, err := convertShareLinksForTest(link) require.NoError(t, err) require.Len(t, cfg.OutboundConfigs, 1) ob := cfg.OutboundConfigs[0] @@ -266,7 +265,7 @@ func TestConvertShareLinksToXrayJson_ShadowsocksPlainUserInfo(t *testing.T) { url.QueryEscape(password) + "@192.168.100.1:8888#Example3" - cfg, err := ConvertShareLinksToXrayJson(link) + cfg, err := convertShareLinksForTest(link) require.NoError(t, err) require.Len(t, cfg.OutboundConfigs, 1) @@ -293,7 +292,7 @@ func TestParseShadowsocksUserInfo_PlainPercentEncoding(t *testing.T) { func TestConvertShareLinksToXrayJson_VlessWSAndTLS(t *testing.T) { link := "vless://" + testShareUUID + "@edge.example:443?encryption=none&type=ws&path=%2Fws&host=cdn.edge&security=tls&sni=edge.example&alpn=h2%2Ch3&fp=chrome&vcn=edge.example" - cfg, err := ConvertShareLinksToXrayJson(link) + cfg, err := convertShareLinksForTest(link) require.NoError(t, err) require.Len(t, cfg.OutboundConfigs, 1) ss := cfg.OutboundConfigs[0].StreamSetting @@ -314,7 +313,7 @@ func TestConvertShareLinksToXrayJson_VlessReality(t *testing.T) { pbk := "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" link := "vless://" + testShareUUID + "@reality.example:443?encryption=none&security=reality&type=tcp&sni=reality.example&pbk=" + pbk + "&sid=abcd&fp=chrome&spx=%2F" - cfg, err := ConvertShareLinksToXrayJson(link) + cfg, err := convertShareLinksForTest(link) require.NoError(t, err) ss := cfg.OutboundConfigs[0].StreamSetting require.NotNil(t, ss) @@ -328,7 +327,7 @@ func TestConvertShareLinksToXrayJson_VlessReality(t *testing.T) { func TestConvertShareLinksToXrayJson_Trojan(t *testing.T) { link := "trojan://tpw@trojan.host:4443?sni=trojan.host#tname" - cfg, err := ConvertShareLinksToXrayJson(link) + cfg, err := convertShareLinksForTest(link) require.NoError(t, err) var s conf.TrojanClientConfig require.NoError(t, json.Unmarshal(*cfg.OutboundConfigs[0].Settings, &s)) @@ -342,7 +341,7 @@ func TestConvertShareLinksToXrayJson_Trojan(t *testing.T) { func TestConvertShareLinksToXrayJson_SocksWithAuth(t *testing.T) { u := base64.StdEncoding.EncodeToString([]byte("socksuser:sockspass")) link := "socks://" + u + "@127.0.0.1:1081#sk" - cfg, err := ConvertShareLinksToXrayJson(link) + cfg, err := convertShareLinksForTest(link) require.NoError(t, err) var s conf.SocksClientConfig require.NoError(t, json.Unmarshal(*cfg.OutboundConfigs[0].Settings, &s)) @@ -352,7 +351,7 @@ func TestConvertShareLinksToXrayJson_SocksWithAuth(t *testing.T) { func TestConvertShareLinksToXrayJson_VmessPlainURL(t *testing.T) { link := "vmess://" + testShareUUID + "@vm.example:443?encryption=auto&type=tcp&headerType=http&path=%2Fpath1%2C%2Fpath2&host=h1%2Ch2" - cfg, err := ConvertShareLinksToXrayJson(link) + cfg, err := convertShareLinksForTest(link) require.NoError(t, err) var s conf.VMessOutboundConfig require.NoError(t, json.Unmarshal(*cfg.OutboundConfigs[0].Settings, &s)) @@ -367,8 +366,10 @@ func TestConvertShareLinksToXrayJson_VmessBase64QR(t *testing.T) { qr := `{"ps":"qrname","add":"vm.add","port":"8443","id":"` + testShareUUID + `","scy":"auto","net":"ws","host":"ws.host","path":"/w","tls":"tls","sni":"tls.sni","alpn":"h2,h3","fp":"safari"}` b64 := base64.StdEncoding.EncodeToString([]byte(qr)) link := "vmess://" + b64 - cfg, err := ConvertShareLinksToXrayJson(link) + cfg, err := convertShareLinksForTest(link) require.NoError(t, err) + assert.Equal(t, "qrname", cfg.OutboundConfigs[0].Tag) + assert.Nil(t, cfg.OutboundConfigs[0].SendThrough) var s conf.VMessOutboundConfig require.NoError(t, json.Unmarshal(*cfg.OutboundConfigs[0].Settings, &s)) assert.Equal(t, testShareUUID, s.ID) @@ -384,14 +385,14 @@ func TestConvertShareLinksToXrayJson_VmessBase64QR(t *testing.T) { func TestConvertShareLinksToXrayJson_TransportKcpGrpcHttpUpgradeXhttp(t *testing.T) { t.Run("kcp", func(t *testing.T) { link := "vless://" + testShareUUID + "@k.example:443?encryption=none&type=kcp&headerType=srtp&seed=myseed" - cfg, err := ConvertShareLinksToXrayJson(link) + cfg, err := convertShareLinksForTest(link) require.NoError(t, err) assert.Nil(t, cfg.OutboundConfigs[0].StreamSetting.KCPSettings) }) t.Run("grpc", func(t *testing.T) { link := "vless://" + testShareUUID + "@g.example:443?encryption=none&type=grpc&serviceName=svc&authority=auth.here&mode=multi" - cfg, err := ConvertShareLinksToXrayJson(link) + cfg, err := convertShareLinksForTest(link) require.NoError(t, err) gs := cfg.OutboundConfigs[0].StreamSetting.GRPCSettings require.NotNil(t, gs) @@ -402,7 +403,7 @@ func TestConvertShareLinksToXrayJson_TransportKcpGrpcHttpUpgradeXhttp(t *testing t.Run("httpupgrade", func(t *testing.T) { link := "vless://" + testShareUUID + "@hu.example:443?encryption=none&type=httpupgrade&path=%2Fup&host=hu.host" - cfg, err := ConvertShareLinksToXrayJson(link) + cfg, err := convertShareLinksForTest(link) require.NoError(t, err) h := cfg.OutboundConfigs[0].StreamSetting.HTTPUPGRADESettings require.NotNil(t, h) @@ -414,7 +415,7 @@ func TestConvertShareLinksToXrayJson_TransportKcpGrpcHttpUpgradeXhttp(t *testing extra := `{"host":"xh.extra"}` link := "vless://" + testShareUUID + "@xh.example:443?encryption=none&type=xhttp&path=%2Fx&host=xh.host&mode=stream-up&extra=" + url.QueryEscape(extra) - cfg, err := ConvertShareLinksToXrayJson(link) + cfg, err := convertShareLinksForTest(link) require.NoError(t, err) x := cfg.OutboundConfigs[0].StreamSetting.XHTTPSettings require.NotNil(t, x) @@ -427,7 +428,7 @@ func TestConvertShareLinksToXrayJson_TransportKcpGrpcHttpUpgradeXhttp(t *testing func TestConvertShareLinksToXrayJson_FinalMaskQuery(t *testing.T) { fm := `{"udp":[{"type":"noise","settings":{}}]}` link := "vless://" + testShareUUID + "@fm.example:443?encryption=none&type=tcp&fm=" + url.QueryEscape(fm) - cfg, err := ConvertShareLinksToXrayJson(link) + cfg, err := convertShareLinksForTest(link) require.NoError(t, err) ss := cfg.OutboundConfigs[0].StreamSetting require.NotNil(t, ss.FinalMask) @@ -436,14 +437,14 @@ func TestConvertShareLinksToXrayJson_FinalMaskQuery(t *testing.T) { } func TestConvertShareLinksToXrayJson_Hysteria2InvalidHop(t *testing.T) { - _, err := ConvertShareLinksToXrayJson("hy2://auth@host:443?hop-interval=notint&sni=x.com") + _, err := convertShareLinksForTest("hy2://auth@host:443?hop-interval=notint&sni=x.com") require.Error(t, err) } func TestConvertShareLinksToXrayJson_MultiLineSkipsBad(t *testing.T) { bad := "vmess://" + testShareUUID + "@bad.example:notaport?encryption=none" good := "vless://" + testShareUUID + "@ok.example:443?encryption=none" - cfg, err := ConvertShareLinksToXrayJson(bad + "\n\n" + good) + cfg, err := convertShareLinksForTest(bad + "\n\n" + good) require.NoError(t, err) require.Len(t, cfg.OutboundConfigs, 1) assert.Equal(t, "vless", cfg.OutboundConfigs[0].Protocol) @@ -459,7 +460,7 @@ func TestConvertShareLinksToXrayJson_TextHeaderBeforeShareLines(t *testing.T) { "---\n" + good - cfg, err := ConvertShareLinksToXrayJson(text) + cfg, err := convertShareLinksForTest(text) require.NoError(t, err) require.Len(t, cfg.OutboundConfigs, 1) assert.Equal(t, "vless", cfg.OutboundConfigs[0].Protocol) @@ -467,20 +468,20 @@ func TestConvertShareLinksToXrayJson_TextHeaderBeforeShareLines(t *testing.T) { func TestConvertShareLinksToXrayJson_DetectedShareLinesAllInvalid(t *testing.T) { bad := "vmess://" + testShareUUID + "@bad.example:notaport?encryption=none" - _, err := ConvertShareLinksToXrayJson("Subscription export\n" + bad) + _, err := convertShareLinksForTest("Subscription export\n" + bad) require.Error(t, err) assert.Contains(t, err.Error(), "no valid outbound found") } func TestConvertShareLinksToXrayJson_Base64EncodedJSON(t *testing.T) { - orig, err := ConvertShareLinksToXrayJson( + orig, err := convertShareLinksForTest( "vless://" + testShareUUID + "@json.example:443?encryption=none", ) require.NoError(t, err) raw, err := json.Marshal(orig) require.NoError(t, err) - cfg, err := ConvertShareLinksToXrayJson(base64.StdEncoding.EncodeToString(raw)) + cfg, err := convertShareLinksForTest(base64.StdEncoding.EncodeToString(raw)) require.NoError(t, err) require.Len(t, cfg.OutboundConfigs, 1) assert.Equal(t, "vless", cfg.OutboundConfigs[0].Protocol) @@ -494,14 +495,14 @@ func TestConvertShareLinksToXrayJson_Base64EncodedClashYAML(t *testing.T) { port: 8390 cipher: aes-256-gcm password: yamlpw` - cfg, err := ConvertShareLinksToXrayJson(base64.StdEncoding.EncodeToString([]byte(yaml))) + cfg, err := convertShareLinksForTest(base64.StdEncoding.EncodeToString([]byte(yaml))) require.NoError(t, err) require.Len(t, cfg.OutboundConfigs, 1) assert.Equal(t, "shadowsocks", cfg.OutboundConfigs[0].Protocol) } func TestConvertShareLinksToXrayJson_UnsupportedFormat(t *testing.T) { - _, err := ConvertShareLinksToXrayJson("this is not a supported subscription format") + _, err := convertShareLinksForTest("this is not a supported subscription format") require.Error(t, err) assert.Contains(t, err.Error(), "unsupported share format") } @@ -514,7 +515,7 @@ func TestConvertShareLinksToXrayJson_RawClashYAML(t *testing.T) { port: 8390 cipher: aes-256-gcm password: yamlpw` - cfg, err := ConvertShareLinksToXrayJson(yaml) + cfg, err := convertShareLinksForTest(yaml) require.NoError(t, err) require.Len(t, cfg.OutboundConfigs, 1) assert.Equal(t, "shadowsocks", cfg.OutboundConfigs[0].Protocol) @@ -526,7 +527,7 @@ func TestConvertShareLinksToXrayJson_ClashYAMLNoValidOutbound(t *testing.T) { type: unsupported server: c.example port: 8390` - _, err := ConvertShareLinksToXrayJson(yaml) + _, err := convertShareLinksForTest(yaml) require.Error(t, err) assert.Contains(t, err.Error(), "no valid outbound found") } @@ -535,7 +536,7 @@ func TestConvertShareLinksToXrayJson_VmessQRGrpcAndKcp(t *testing.T) { t.Run("grpc", func(t *testing.T) { qr := `{"ps":"g","add":"grpc.host","port":"443","id":"` + testShareUUID + `","net":"grpc","path":"svcname","type":"multi"}` link := "vmess://" + base64.StdEncoding.EncodeToString([]byte(qr)) - cfg, err := ConvertShareLinksToXrayJson(link) + cfg, err := convertShareLinksForTest(link) require.NoError(t, err) gs := cfg.OutboundConfigs[0].StreamSetting.GRPCSettings require.NotNil(t, gs) @@ -546,7 +547,7 @@ func TestConvertShareLinksToXrayJson_VmessQRGrpcAndKcp(t *testing.T) { t.Run("kcp", func(t *testing.T) { qr := `{"ps":"k","add":"kcp.host","port":"8391","id":"` + testShareUUID + `","net":"kcp","path":"seedval","type":"wireguard"}` link := "vmess://" + base64.StdEncoding.EncodeToString([]byte(qr)) - cfg, err := ConvertShareLinksToXrayJson(link) + cfg, err := convertShareLinksForTest(link) require.NoError(t, err) assert.Nil(t, cfg.OutboundConfigs[0].StreamSetting.KCPSettings) }) @@ -554,7 +555,7 @@ func TestConvertShareLinksToXrayJson_VmessQRGrpcAndKcp(t *testing.T) { func TestConvertShareLinksToXrayJson_ShadowsocksWithStreamQuery(t *testing.T) { link := "ss://" + ssUserB64("aes-128-gcm", "p") + "@ss-ws.example:443?type=ws&path=%2Fws&host=cdn.ws&security=tls&sni=ss-ws.example" - cfg, err := ConvertShareLinksToXrayJson(link) + cfg, err := convertShareLinksForTest(link) require.NoError(t, err) ss := cfg.OutboundConfigs[0].StreamSetting require.NotNil(t, ss.WSSettings) diff --git a/share/test_helpers_test.go b/share/test_helpers_test.go new file mode 100644 index 00000000..139ec322 --- /dev/null +++ b/share/test_helpers_test.go @@ -0,0 +1,32 @@ +package share + +import ( + "encoding/json" + + "github.com/xtls/xray-core/infra/conf" +) + +func convertShareLinksForTest(links string) (*conf.Config, error) { + config, err := parseShareCandidatesForTest(links) + if err != nil { + return nil, err + } + return filterBuildableOutbounds(config) +} + +func parseShareCandidatesForTest(links string) (*conf.Config, error) { + config, _, err := parseShareCandidates(links, true) + return config, err +} + +func convertShareLinksWithKeyForTest(links, secretKey string) (*conf.Config, error) { + result, err := ConvertShareLinksToXrayJson(links, secretKey) + if err != nil { + return nil, err + } + var config conf.Config + if err := json.Unmarshal(result.Config, &config); err != nil { + return nil, err + } + return &config, nil +} diff --git a/share/validate_outbound.go b/share/validate_outbound.go index 4f89d292..d0fa295f 100644 --- a/share/validate_outbound.go +++ b/share/validate_outbound.go @@ -22,23 +22,13 @@ func filterBuildableOutbounds(config *conf.Config) (*conf.Config, error) { restoreNilRawMessages(reflect.ValueOf(&validationOutbounds)) validOutbounds := make([]conf.OutboundDetourConfig, 0, len(config.OutboundConfigs)) - var firstBuildError error for index := range validationOutbounds { - // Share conversion stores the display name in sendThrough because Xray - // has no outbound name field. It is metadata here, not a bind address. - validationOutbounds[index].SendThrough = nil if _, err := validationOutbounds[index].Build(); err != nil { - if firstBuildError == nil { - firstBuildError = err - } continue } validOutbounds = append(validOutbounds, config.OutboundConfigs[index]) } if len(validOutbounds) == 0 { - if firstBuildError != nil { - return nil, fmt.Errorf("no valid outbound found: %w", firstBuildError) - } return nil, fmt.Errorf("no valid outbound found") } diff --git a/share/xray_json.go b/share/xray_json.go index 78589109..449c7c42 100644 --- a/share/xray_json.go +++ b/share/xray_json.go @@ -22,15 +22,10 @@ type XrayRawSettingsHeaderRequestHeaders struct { } func setOutboundName(outbound *conf.OutboundDetourConfig, name string) { - outbound.SendThrough = &name + outbound.Tag = name } func getOutboundName(outbound conf.OutboundDetourConfig) string { - if outbound.SendThrough != nil { - if len(*outbound.SendThrough) > 0 { - return *outbound.SendThrough - } - } if len(outbound.Tag) > 0 { return outbound.Tag } diff --git a/xray/ping_batch.go b/xray/ping_batch.go index f40f24d9..2014dc2d 100644 --- a/xray/ping_batch.go +++ b/xray/ping_batch.go @@ -31,9 +31,11 @@ type PingBatchItem struct { } type PingBatchResult struct { - Success bool - Delay int64 - Error string + Success bool + Delay int64 + Error string + LocationJSON *string + LocationError string } type pingOutboundConfig struct { @@ -50,9 +52,25 @@ func PingBatch( timeout int, targetURL string, ) ([]PingBatchResult, error) { + return PingBatchWithLocation(items, timeout, targetURL, "") +} + +// PingBatchWithLocation uses the same forced-outbound client for latency and +// optional location probes. The two probe results are independent. +func PingBatchWithLocation(items []PingBatchItem, timeout int, targetURL, locationURL string) ([]PingBatchResult, error) { if err := validatePingBatchRequest(items, timeout, targetURL); err != nil { return nil, err } + if locationURL != "" { + if err := validatePingBatchRequest(items, timeout, locationURL); err != nil { + return nil, errors.New("ping batch location URL must be an absolute HTTP or HTTPS URL") + } + } + coreServerMu.Lock() + defer coreServerMu.Unlock() + if coreServer != nil { + return nil, errors.New("pingBatch requires an isolated process without a managed Xray instance") + } results := make([]PingBatchResult, len(items)) prepared := make([]preparedPingItem, 0, len(items)) @@ -99,23 +117,13 @@ func PingBatch( go func() { defer workers.Done() for item := range jobs { - delay, err := measureOutboundDelay( + results[item.resultIndex] = probeOutbound( server, item.outboundTag, timeout, targetURL, + locationURL, ) - if err != nil { - results[item.resultIndex] = failedPingBatchResult( - delay, - err, - ) - continue - } - results[item.resultIndex] = PingBatchResult{ - Success: true, - Delay: delay, - } } }() } @@ -361,12 +369,13 @@ func startPingBatchServer( return server, nil } -func measureOutboundDelay( +func probeOutbound( server *core.Instance, outboundTag string, timeout int, targetURL string, -) (int64, error) { + locationURL string, +) PingBatchResult { httpTimeout := time.Second * time.Duration(timeout) transport := &http.Transport{ DisableKeepAlives: true, @@ -392,7 +401,38 @@ func measureOutboundDelay( Transport: transport, Timeout: httpTimeout, } - return nodep.PingHTTPRequest(client, targetURL, timeout) + delay, err := nodep.PingHTTPRequest(client, targetURL, timeout) + result := PingBatchResult{Success: true, Delay: delay} + if err != nil { + result = failedPingBatchResult(delay, err) + } + if locationURL != "" { + locationJSON, err := probeLocation(client, locationURL) + if err != nil { + result.LocationError = err.Error() + } else { + result.LocationJSON = &locationJSON + } + } + return result +} + +func probeLocation(client *http.Client, locationURL string) (string, error) { + response, err := client.Get(locationURL) + if err != nil { + // Do not include a provider URL, credentials or response body in errors. + return "", errors.New("location request failed") + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return "", fmt.Errorf("location request returned HTTP %d", response.StatusCode) + } + const maxLocationBytes = 64 * 1024 + body, err := io.ReadAll(io.LimitReader(response.Body, maxLocationBytes+1)) + if err != nil || len(body) > maxLocationBytes { + return "", errors.New("unable to read location response") + } + return string(body), nil } func failedPingBatchResult(delay int64, err error) PingBatchResult { diff --git a/xray/ping_location_test.go b/xray/ping_location_test.go new file mode 100644 index 00000000..ad4418dc --- /dev/null +++ b/xray/ping_location_test.go @@ -0,0 +1,131 @@ +package xray + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" +) + +func TestPingBatchLocationUsesEachForcedOutbound(t *testing.T) { + var heads, gets atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodHead { + heads.Add(1) + return + } + gets.Add(1) + fmt.Fprint(w, `{"ip_address":"203.0.113.9","country":"jp"}`) + })) + defer server.Close() + results, err := PingBatchWithLocation([]PingBatchItem{ + {XrayJSON: `{"outbounds":[{"protocol":"freedom","tag":"proxy"}]}`}, + {XrayJSON: `{"outbounds":[{"protocol":"blackhole","tag":"proxy"}]}`}, + }, 1, server.URL, server.URL) + if err != nil { + t.Fatal(err) + } + first, second := results[0], results[1] + if !first.Success || first.LocationJSON == nil || *first.LocationJSON != `{"ip_address":"203.0.113.9","country":"jp"}` || first.LocationError != "" { + t.Fatalf("first result = %+v", first) + } + if second.Success || second.LocationJSON != nil || second.LocationError == "" { + t.Fatalf("blocked outbound escaped through another client: %+v", second) + } + if heads.Load() != 1 || gets.Load() != 1 { + t.Fatalf("requests = HEAD %d GET %d", heads.Load(), gets.Load()) + } +} + +func TestPingBatchLocationFailureDoesNotFailLatency(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + w.WriteHeader(http.StatusServiceUnavailable) + } + })) + defer server.Close() + items := []PingBatchItem{{XrayJSON: `{"outbounds":[{"protocol":"freedom"}]}`}} + results, err := PingBatchWithLocation(items, 1, server.URL, server.URL) + if err != nil { + t.Fatal(err) + } + result := results[0] + if !result.Success || result.Error != "" || result.LocationJSON != nil || result.LocationError != "location request returned HTTP 503" { + t.Fatalf("result = %+v", result) + } + results, err = PingBatch(items, 1, server.URL) + if err != nil { + t.Fatal(err) + } + if !results[0].Success || results[0].LocationJSON != nil || results[0].LocationError != "" { + t.Fatalf("latency-only result = %+v", results[0]) + } +} + +func TestPingBatchLocationCanSucceedWhenLatencyFails(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodHead { + conn, _, err := w.(http.Hijacker).Hijack() + if err == nil { + conn.Close() + } + return + } + fmt.Fprint(w, `{"ip_address":"2001:db8::1","country":"US"}`) + })) + defer server.Close() + results, err := PingBatchWithLocation([]PingBatchItem{{XrayJSON: `{"outbounds":[{"protocol":"freedom"}]}`}}, 1, server.URL, server.URL) + if err != nil { + t.Fatal(err) + } + if results[0].Success || results[0].LocationJSON == nil || *results[0].LocationJSON != `{"ip_address":"2001:db8::1","country":"US"}` || results[0].LocationError != "" { + t.Fatalf("result = %+v", results[0]) + } +} + +func TestProbeLocationReturnsOriginalBody(t *testing.T) { + for _, body := range []string{"", " \n{\"provider_specific\": true}\n", strings.Repeat("x", 64*1024)} { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, body) })) + locationJSON, err := probeLocation(server.Client(), server.URL) + server.Close() + if err != nil || locationJSON != body { + t.Fatalf("location JSON length = %d, error = %v", len(locationJSON), err) + } + } +} + +func TestProbeLocationRejectsOversizedResponseWithoutLeakingInput(t *testing.T) { + body := "private-source" + strings.Repeat("x", 64*1024+1-len("private-source")) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, body) + })) + defer server.Close() + locationJSON, err := probeLocation(server.Client(), server.URL) + if err == nil || locationJSON != "" { + t.Fatalf("location JSON = %q, error = %v", locationJSON, err) + } + if strings.Contains(err.Error(), "private-source") { + t.Fatal("response body leaked") + } +} + +func TestProbeLocationHidesURLAndCredentialsOnRequestFailure(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { <-r.Context().Done() })) + defer server.Close() + client := server.Client() + client.Timeout = 5 * time.Millisecond + _, err := probeLocation(client, strings.Replace(server.URL, "://", "://private-user:private-password@", 1)) + if err == nil || err.Error() != "location request failed" { + t.Fatalf("error = %v", err) + } +} + +func TestPingBatchRejectsInvalidLocationURL(t *testing.T) { + _, err := PingBatchWithLocation([]PingBatchItem{{}}, 1, "https://example.com", "file:///private/file") + if err == nil || !strings.Contains(err.Error(), "location URL") { + t.Fatalf("error = %v", err) + } +} diff --git a/xray/runtime.go b/xray/runtime.go new file mode 100644 index 00000000..24483d6b --- /dev/null +++ b/xray/runtime.go @@ -0,0 +1,263 @@ +package xray + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "io" + "net" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/xtls/xray-core/core" + "github.com/xtls/xray-core/features/inbound" + "github.com/xtls/xray-core/features/policy" + "github.com/xtls/xray-core/features/stats" +) + +// RuntimeConfig is host metadata, never part of the Xray configuration. +type RuntimeConfig struct { + StatePath string `json:"statePath"` + InboundTag string `json:"inboundTag"` + Listen string `json:"listen,omitempty"` + Token string `json:"token,omitempty"` +} + +type runtimeSession struct { + ID string `json:"id"` + StartedAtMs int64 `json:"startedAtMs"` + EndedAtMs int64 `json:"endedAtMs"` + Uplink int64 `json:"uplink"` + Downlink int64 `json:"downlink"` +} + +// runtimeSnapshot contains only this session's raw inbound counter values. +// It contains no application totals, configuration, credentials, or control API. +type runtimeSnapshot struct { + Version int `json:"version"` + Session runtimeSession `json:"session"` + Available bool `json:"available"` + SampledAtMs int64 `json:"sampledAtMs"` + SavedAtMs int64 `json:"savedAtMs"` + Error string `json:"error"` +} + +type managedRuntime struct { + config RuntimeConfig + snapshot runtimeSnapshot + manager stats.Manager + stateLock *os.File + stopTicker, tickDone chan struct{} + httpServer *http.Server + httpListener net.Listener +} + +func prepareRuntime(config *RuntimeConfig) (*managedRuntime, error) { + if config == nil { + return nil, nil + } + if !filepath.IsAbs(config.StatePath) || strings.TrimSpace(config.InboundTag) == "" || len(config.InboundTag) > 256 { + return nil, errors.New("runtime requires an absolute statePath and inboundTag") + } + if err := validateRuntimeHTTP(config); err != nil { + return nil, err + } + var id [16]byte + if _, err := rand.Read(id[:]); err != nil { + return nil, err + } + stateLock, err := lockRuntimeState(config.StatePath) + if err != nil { + return nil, err + } + return &managedRuntime{ + config: *config, stateLock: stateLock, + snapshot: runtimeSnapshot{ + Version: 1, + Session: runtimeSession{ID: hex.EncodeToString(id[:]), StartedAtMs: time.Now().UnixMilli()}, + }, + }, nil +} + +func lockRuntimeState(path string) (*os.File, error) { + if !filepath.IsAbs(path) { + return nil, errors.New("runtime statePath must be absolute") + } + lockPath := path + ".lock" + if info, err := os.Lstat(lockPath); err == nil && !info.Mode().IsRegular() || err != nil && !errors.Is(err, os.ErrNotExist) { + return nil, errors.New("runtime state lock is unavailable") + } + file, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0600) + if err != nil { + return nil, errors.New("runtime state lock is unavailable") + } + if err := lockRuntimeFile(file); err != nil { + _ = file.Close() + return nil, errors.New("runtime state is in use") + } + return file, nil +} + +func (r *managedRuntime) attach(server *core.Instance) error { + manager, ok := server.GetFeature(stats.ManagerType()).(stats.Manager) + policies, policyOK := server.GetFeature(policy.ManagerType()).(policy.Manager) + inbounds, inboundOK := server.GetFeature(inbound.ManagerType()).(inbound.Manager) + if !ok || !policyOK || !inboundOK || !policies.ForSystem().Stats.InboundUplink || !policies.ForSystem().Stats.InboundDownlink { + return errors.New("runtime requires inbound uplink and downlink statistics") + } + if _, err := inbounds.GetHandler(context.Background(), r.config.InboundTag); err != nil { + return errors.New("runtime inboundTag does not exist") + } + // Registering zero counters handles an idle inbound without treating disabled statistics as zero. + for _, direction := range []string{"uplink", "downlink"} { + counter, err := manager.GetOrRegisterCounter(r.counterName(direction)) + if err != nil || counter == nil { + return errors.New("runtime requires a statistics manager") + } + } + r.manager = manager + return nil +} + +func (r *managedRuntime) counterName(direction string) string { + return "inbound>>>" + r.config.InboundTag + ">>>traffic>>>" + direction +} + +func (r *managedRuntime) start() error { + listener, err := r.listenHTTP() + if err != nil { + return err + } + r.sample() + if err := r.save(); err != nil { + if listener != nil { + _ = listener.Close() + } + return err + } + if listener != nil { + r.serveHTTP(listener) + } + r.stopTicker, r.tickDone = make(chan struct{}), make(chan struct{}) + go func() { + defer close(r.tickDone) + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + for { + select { + case <-ticker.C: + r.sample() + _ = r.save() + case <-r.stopTicker: + return + } + } + }() + return nil +} + +func (r *managedRuntime) sample() { + r.snapshot.SampledAtMs = time.Now().UnixMilli() + up, down := r.manager.GetCounter(r.counterName("uplink")), r.manager.GetCounter(r.counterName("downlink")) + r.snapshot.Available = up != nil && down != nil + if r.snapshot.Available { + u, d := up.Value(), down.Value() + r.snapshot.Available = u >= 0 && d >= 0 + if r.snapshot.Available { + // Preserve raw Value semantics, including a nonnegative counter rollback. + // Never reset counters or synthesize deltas/application totals here. + r.snapshot.Session.Uplink, r.snapshot.Session.Downlink = u, d + } + } +} + +func (r *managedRuntime) save() error { + candidate := r.snapshot + candidate.SavedAtMs = time.Now().UnixMilli() + candidate.Error = "" + if !candidate.Available { + candidate.Error = "counters_unavailable" + } + if err := writeRuntimeState(r.config.StatePath, candidate); err != nil { + r.snapshot.Error = "state_write_failed" + return errors.New("runtime state_write_failed") + } + r.snapshot = candidate + return nil +} + +func (r *managedRuntime) stop() error { + if r.stopTicker == nil { + return nil + } + var httpErr error + if r.httpServer != nil { + httpErr = r.httpServer.Close() + // Close also covers an immediate stop before Serve registers the listener. + _ = r.httpListener.Close() + r.httpServer = nil + r.httpListener = nil + } + close(r.stopTicker) + <-r.tickDone + r.stopTicker = nil + // The ticker has exited, so the final sample/write cannot race a periodic one. + r.sample() + r.snapshot.Session.EndedAtMs = time.Now().UnixMilli() + return errors.Join(httpErr, r.save()) +} + +func readRuntimeState(path string) (runtimeSnapshot, error) { + var state runtimeSnapshot + info, err := os.Lstat(path) + if err != nil || !info.Mode().IsRegular() || info.Size() > 64*1024 { + return state, errors.New("runtime state is not a readable regular file") + } + file, err := os.Open(path) + if err != nil { + return state, err + } + defer file.Close() + decoder := json.NewDecoder(io.LimitReader(file, 64*1024)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&state); err != nil { + return state, errors.New("runtime state is invalid") + } + var extra any + id, idErr := hex.DecodeString(state.Session.ID) + if decoder.Decode(&extra) != io.EOF || state.Version != 1 || idErr != nil || len(id) != 16 || + state.Session.ID != strings.ToLower(state.Session.ID) || + state.Session.StartedAtMs <= 0 || state.Session.EndedAtMs < 0 || state.SampledAtMs <= 0 || state.SavedAtMs <= 0 || + state.Session.Uplink < 0 || state.Session.Downlink < 0 { + return state, errors.New("runtime state is invalid") + } + return state, nil +} + +func writeRuntimeState(path string, state runtimeSnapshot) error { + if info, err := os.Lstat(path); err == nil && !info.Mode().IsRegular() || err != nil && !errors.Is(err, os.ErrNotExist) { + return errors.New("runtime state is not a regular file") + } + data, err := json.Marshal(state) + if err != nil { + return err + } + file, err := os.CreateTemp(filepath.Dir(path), ".runtime-*") + if err != nil { + return err + } + defer os.Remove(file.Name()) + if _, err = file.Write(data); err == nil { + err = file.Sync() + } + err = errors.Join(err, file.Close()) + if err != nil { + return err + } + return replaceRuntimeState(file.Name(), path) +} diff --git a/xray/runtime_file.go b/xray/runtime_file.go new file mode 100644 index 00000000..fa3029a5 --- /dev/null +++ b/xray/runtime_file.go @@ -0,0 +1,26 @@ +//go:build !windows + +package xray + +import ( + "os" + "path/filepath" + + "golang.org/x/sys/unix" +) + +func lockRuntimeFile(file *os.File) error { + return unix.Flock(int(file.Fd()), unix.LOCK_EX|unix.LOCK_NB) +} + +func replaceRuntimeState(source, target string) error { + directory, err := os.Open(filepath.Dir(target)) + if err != nil { + return err + } + defer directory.Close() + if err := os.Rename(source, target); err != nil { + return err + } + return directory.Sync() +} diff --git a/xray/runtime_file_windows.go b/xray/runtime_file_windows.go new file mode 100644 index 00000000..29791af3 --- /dev/null +++ b/xray/runtime_file_windows.go @@ -0,0 +1,23 @@ +package xray + +import ( + "os" + + "golang.org/x/sys/windows" +) + +func lockRuntimeFile(file *os.File) error { + return windows.LockFileEx(windows.Handle(file.Fd()), windows.LOCKFILE_EXCLUSIVE_LOCK|windows.LOCKFILE_FAIL_IMMEDIATELY, 0, 1, 0, &windows.Overlapped{}) +} + +func replaceRuntimeState(source, target string) error { + from, err := windows.UTF16PtrFromString(source) + if err != nil { + return err + } + to, err := windows.UTF16PtrFromString(target) + if err != nil { + return err + } + return windows.MoveFileEx(from, to, windows.MOVEFILE_REPLACE_EXISTING|windows.MOVEFILE_WRITE_THROUGH) +} diff --git a/xray/runtime_http.go b/xray/runtime_http.go new file mode 100644 index 00000000..86d9a9d8 --- /dev/null +++ b/xray/runtime_http.go @@ -0,0 +1,81 @@ +package xray + +import ( + "crypto/subtle" + "encoding/hex" + "encoding/json" + "errors" + "net" + "net/http" + "strconv" + "strings" + "time" +) + +func validRuntimeToken(value string) bool { + decoded, err := hex.DecodeString(value) + return err == nil && len(decoded) == 16 && value == strings.ToLower(value) +} + +func validateRuntimeHTTP(config *RuntimeConfig) error { + if config.Listen == "" && config.Token == "" { + return nil + } + host, portText, err := net.SplitHostPort(config.Listen) + port, portErr := strconv.Atoi(portText) + if err != nil || host != "127.0.0.1" || portErr != nil || port < 1 || port > 65535 || + strconv.Itoa(port) != portText || !validRuntimeToken(config.Token) { + return errors.New("runtime HTTP requires listen 127.0.0.1:1..65535 and a 32-character lowercase hex token") + } + return nil +} + +func (r *managedRuntime) listenHTTP() (net.Listener, error) { + if r.config.Listen == "" { + return nil, nil + } + listener, err := net.Listen("tcp4", r.config.Listen) + if err != nil { + return nil, errors.New("runtime HTTP listener is unavailable") + } + return listener, nil +} + +func (r *managedRuntime) serveHTTP(listener net.Listener) { + r.httpListener = listener + r.httpServer = &http.Server{ + Handler: http.HandlerFunc(r.handleHTTP), + ReadHeaderTimeout: 2 * time.Second, + ReadTimeout: 5 * time.Second, + WriteTimeout: 5 * time.Second, + IdleTimeout: 30 * time.Second, + MaxHeaderBytes: 8 * 1024, + } + server := r.httpServer + go func() { _ = server.Serve(listener) }() +} + +func (r *managedRuntime) handleHTTP(w http.ResponseWriter, request *http.Request) { + w.Header().Set("Cache-Control", "no-store") + if subtle.ConstantTimeCompare([]byte(request.Header.Get("Authorization")), []byte("Bearer "+r.config.Token)) != 1 { + w.Header().Set("WWW-Authenticate", "Bearer") + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + if request.URL.Path != "/runtime" { + http.NotFound(w, request) + return + } + if request.Method != http.MethodGet { + w.Header().Set("Allow", http.MethodGet) + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + snapshot, err := readRuntimeState(r.config.StatePath) + if err != nil { + http.Error(w, "runtime snapshot unavailable", http.StatusServiceUnavailable) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(snapshot) +} diff --git a/xray/runtime_http_test.go b/xray/runtime_http_test.go new file mode 100644 index 00000000..43df1378 --- /dev/null +++ b/xray/runtime_http_test.go @@ -0,0 +1,189 @@ +package xray + +import ( + "encoding/json" + "io" + "net" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +func runtimeHTTPConfig(t *testing.T) RuntimeConfig { + t.Helper() + config := runtimeConfig(t) + listener, err := net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + config.Listen, config.Token = listener.Addr().String(), strings.Repeat("a", 32) + _ = listener.Close() + return config +} + +func requestRuntime(t *testing.T, config RuntimeConfig, method, path, token string, status int) runtimeSnapshot { + t.Helper() + request, err := http.NewRequest(method, "http://"+config.Listen+path, nil) + if err != nil { + t.Fatal(err) + } + if token != "" { + request.Header.Set("Authorization", "Bearer "+token) + } + client := &http.Client{Timeout: 3 * time.Second} + response, err := client.Do(request) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + data, err := io.ReadAll(response.Body) + if err != nil || response.StatusCode != status { + t.Fatalf("runtime HTTP status %d, want %d: %s %v", response.StatusCode, status, data, err) + } + if response.Header.Get("Cache-Control") != "no-store" || response.Header.Get("Access-Control-Allow-Origin") != "" { + t.Fatal("runtime HTTP must disable caching without enabling CORS") + } + if strings.Contains(string(data), config.StatePath) || strings.Contains(string(data), config.Token) { + t.Fatal("runtime HTTP exposed host metadata") + } + var snapshot runtimeSnapshot + if status == http.StatusOK { + if response.Header.Get("Content-Type") != "application/json" || json.Unmarshal(data, &snapshot) != nil || snapshot.Version != 1 { + t.Fatalf("invalid runtime response: %s", data) + } + } + return snapshot +} + +func TestRuntimeHTTPAuthenticationCurrentSessionAndStop(t *testing.T) { + config := runtimeHTTPConfig(t) + runtime, up, down := runtimeFixture(t, config) + up.Add(17) + down.Add(23) + if err := runtime.start(); err != nil { + t.Fatal(err) + } + requestRuntime(t, config, http.MethodGet, "/runtime", "", http.StatusUnauthorized) + requestRuntime(t, config, http.MethodGet, "/runtime", strings.Repeat("b", 32), http.StatusUnauthorized) + current := requestRuntime(t, config, http.MethodGet, "/runtime", config.Token, http.StatusOK) + if current.Session.Uplink != 17 || current.Session.Downlink != 23 { + t.Fatalf("wrong current snapshot: %+v", current) + } + up.Add(100) + if saved := requestRuntime(t, config, http.MethodGet, "/runtime", config.Token, http.StatusOK); saved != current || up.Value() != 117 { + t.Fatal("HTTP must read the saved snapshot without sampling or resetting metrics") + } + requestRuntime(t, config, http.MethodPost, "/runtime", config.Token, http.StatusMethodNotAllowed) + requestRuntime(t, config, http.MethodGet, "/runtime/ack", config.Token, http.StatusNotFound) + requestRuntime(t, config, http.MethodPost, "/runtime/ack", config.Token, http.StatusNotFound) + requestRuntime(t, config, http.MethodGet, "/control", config.Token, http.StatusNotFound) + if err := os.Remove(config.StatePath); err != nil { + t.Fatal(err) + } + requestRuntime(t, config, http.MethodGet, "/runtime", config.Token, http.StatusServiceUnavailable) + runtime.sample() + if err := runtime.save(); err != nil { + t.Fatal(err) + } + if err := runtime.stop(); err != nil { + t.Fatal(err) + } + stopped := savedRuntime(t, config.StatePath) + if stopped.Session.Uplink != 117 || stopped.Session.EndedAtMs == 0 { + t.Fatal("HTTP shutdown lost the final saved sample") + } + connection, err := net.DialTimeout("tcp4", config.Listen, time.Second) + if err == nil { + _ = connection.Close() + t.Fatal("runtime HTTP listener survived stop") + } +} + +func TestRuntimeHTTPRejectsCurrentSnapshotSymlink(t *testing.T) { + config := runtimeHTTPConfig(t) + runtime, _, _ := runtimeFixture(t, config) + if err := runtime.start(); err != nil { + t.Fatal(err) + } + state := savedRuntime(t, config.StatePath) + outside := filepath.Join(t.TempDir(), "runtime.json") + if err := writeRuntimeState(outside, state); err != nil { + t.Fatal(err) + } + if err := os.Remove(config.StatePath); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, config.StatePath); err != nil { + t.Skipf("symbolic links unavailable: %v", err) + } + requestRuntime(t, config, http.MethodGet, "/runtime", config.Token, http.StatusServiceUnavailable) + if savedRuntime(t, outside) != state { + t.Fatal("HTTP changed a snapshot outside its state path") + } +} + +func TestRuntimeHTTPStartFailureClosesListener(t *testing.T) { + config := runtimeHTTPConfig(t) + runtime, _, _ := runtimeFixture(t, config) + occupied, err := net.Listen("tcp4", config.Listen) + if err != nil { + t.Fatal(err) + } + defer occupied.Close() + if err := runtime.start(); err == nil { + t.Fatal("occupied statistics port did not reject startup") + } + if _, err := os.Lstat(config.StatePath); !os.IsNotExist(err) { + t.Fatal("failed bind replaced the saved state") + } + _ = occupied.Close() + runtime.config.StatePath = filepath.Join(filepath.Dir(config.StatePath), "missing", "runtime.json") + if err := runtime.start(); err == nil { + t.Fatal("initial save failure did not reject startup") + } + listener, err := net.Listen("tcp4", config.Listen) + if err != nil { + t.Fatalf("initial save failure leaked the HTTP listener: %v", err) + } + _ = listener.Close() + runtime.config.StatePath = config.StatePath + if err := runtime.start(); err != nil { + t.Fatal(err) + } + if err := runtime.stop(); err != nil { + t.Fatal(err) + } + listener, err = net.Listen("tcp4", config.Listen) + if err != nil { + t.Fatalf("immediate stop leaked the HTTP listener: %v", err) + } + _ = listener.Close() +} + +func TestRuntimeHTTPConcurrentReads(t *testing.T) { + config := runtimeHTTPConfig(t) + runtime, up, _ := runtimeFixture(t, config) + if err := runtime.start(); err != nil { + t.Fatal(err) + } + var workers sync.WaitGroup + workers.Go(func() { + for range 30 { + up.Add(1) + runtime.sample() + if err := runtime.save(); err != nil { + t.Error(err) + } + } + }) + workers.Go(func() { + for range 30 { + requestRuntime(t, config, http.MethodGet, "/runtime", config.Token, http.StatusOK) + } + }) + workers.Wait() +} diff --git a/xray/runtime_test.go b/xray/runtime_test.go new file mode 100644 index 00000000..a8206d2c --- /dev/null +++ b/xray/runtime_test.go @@ -0,0 +1,385 @@ +package xray + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + appstats "github.com/xtls/xray-core/app/stats" + "github.com/xtls/xray-core/features/stats" +) + +func runtimeConfig(t *testing.T) RuntimeConfig { + t.Helper() + return RuntimeConfig{ + StatePath: filepath.Join(t.TempDir(), "runtime.json"), + InboundTag: "tunIn", + } +} + +func runtimeFixture(t *testing.T, config RuntimeConfig) (*managedRuntime, stats.Counter, stats.Counter) { + t.Helper() + runtime, err := prepareRuntime(&config) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _ = runtime.stop() + _ = runtime.stateLock.Close() + }) + manager, err := appstats.NewManager(context.Background(), &appstats.Config{}) + if err != nil { + t.Fatal(err) + } + runtime.manager = manager + up, _ := manager.RegisterCounter(runtime.counterName("uplink")) + down, _ := manager.RegisterCounter(runtime.counterName("downlink")) + return runtime, up, down +} + +func saveRuntimeSample(t *testing.T, runtime *managedRuntime) runtimeSnapshot { + t.Helper() + runtime.sample() + if err := runtime.save(); err != nil { + t.Fatal(err) + } + return savedRuntime(t, runtime.config.StatePath) +} + +func savedRuntime(t *testing.T, path string) runtimeSnapshot { + t.Helper() + snapshot, err := readRuntimeState(path) + if err != nil || snapshot.Version != 1 { + t.Fatalf("read snapshot: %+v %v", snapshot, err) + } + return snapshot +} + +func TestRuntimeStoresRawCountersWithoutResetOrTotals(t *testing.T) { + config := runtimeConfig(t) + runtime, up, down := runtimeFixture(t, config) + up.Add(100) + down.Add(200) + first := saveRuntimeSample(t, runtime) + repeated := saveRuntimeSample(t, runtime) + if first.Session.Uplink != 100 || first.Session.Downlink != 200 || first.Session != repeated.Session || up.Value() != 100 || down.Value() != 200 { + t.Fatalf("sampling duplicated or reset raw counters: %+v %+v", first, repeated) + } + // A nonnegative rollback remains a raw value, not a synthetic accumulated delta. + up.Set(2) + rollback := saveRuntimeSample(t, runtime) + up.Add(3) + resumed := saveRuntimeSample(t, runtime) + if rollback.Session.Uplink != 2 || resumed.Session.Uplink != 5 || !resumed.Available { + t.Fatalf("rollback changed raw counter semantics: %+v %+v", rollback, resumed) + } + up.Set(-1) + negative := saveRuntimeSample(t, runtime) + if negative.Available || negative.Error != "counters_unavailable" || negative.Session != resumed.Session { + t.Fatalf("negative counter corrupted the last valid sample: %+v", negative) + } + _ = runtime.manager.UnregisterCounter(runtime.counterName("uplink")) + missing := saveRuntimeSample(t, runtime) + if missing.Available || missing.Error != "counters_unavailable" || missing.Session != resumed.Session { + t.Fatalf("missing counter became a fabricated zero: %+v", missing) + } + if info, err := os.Stat(config.StatePath); err != nil || info.Mode().Perm() != 0600 { + t.Fatalf("private file mode: %v %v", info, err) + } + encoded, _ := json.Marshal(first) + var fields map[string]json.RawMessage + _ = json.Unmarshal(encoded, &fields) + if len(fields) != 6 || fields["ledger"] != nil || fields["totalUplink"] != nil || fields["resetGeneration"] != nil || strings.Contains(string(encoded), config.StatePath) { + t.Fatalf("unexpected runtime snapshot fields: %s", encoded) + } + metadata, _ := json.Marshal(config) + var metadataFields map[string]json.RawMessage + _ = json.Unmarshal(metadata, &metadataFields) + if len(metadataFields) != 2 || metadataFields["planId"] != nil || metadataFields["controlAddress"] != nil || metadataFields["controlToken"] != nil { + t.Fatalf("runtime metadata exposed a control surface: %s", metadata) + } +} + +func TestRuntimeReplacesPreviousSessionOnStart(t *testing.T) { + config := runtimeConfig(t) + runtime, up, down := runtimeFixture(t, config) + if err := runtime.start(); err != nil { + t.Fatal(err) + } + initial := savedRuntime(t, config.StatePath) + if initial.Session.Uplink != 0 || initial.Session.Downlink != 0 || !initial.Available || initial.Session.EndedAtMs != 0 { + t.Fatalf("new session was not saved at start: %+v", initial) + } + up.Add(17) + down.Add(23) + if err := runtime.stop(); err != nil { + t.Fatal(err) + } + stopped := savedRuntime(t, config.StatePath) + if stopped.Session.Uplink != 17 || stopped.Session.Downlink != 23 || stopped.Session.EndedAtMs == 0 { + t.Fatalf("stop did not save final raw counters: %+v", stopped) + } + _ = runtime.stateLock.Close() + // Preparation alone cannot overwrite the current snapshot. + for range 2 { + next, err := prepareRuntime(&config) + if err != nil { + t.Fatal(err) + } + _ = next.stateLock.Close() + if savedRuntime(t, config.StatePath) != stopped { + t.Fatal("preparation replaced the previous current snapshot") + } + } + next, _, _ := runtimeFixture(t, config) + if err := next.start(); err != nil { + t.Fatal(err) + } + current := savedRuntime(t, config.StatePath) + if current.Session.ID == stopped.Session.ID || current.Session.Uplink != 0 || current.Session.Downlink != 0 { + t.Fatalf("restart reused a session or inherited totals: %+v", current) + } + if _, err := os.Lstat(filepath.Join(filepath.Dir(config.StatePath), "runtime-sessions")); !os.IsNotExist(err) { + t.Fatal("restart created a runtime session archive") + } +} + +func TestRuntimeWriteFailuresPreserveSavedFile(t *testing.T) { + config := runtimeConfig(t) + runtime, up, _ := runtimeFixture(t, config) + up.Add(20) + committed := saveRuntimeSample(t, runtime) + runtime.config.StatePath = filepath.Join(filepath.Dir(config.StatePath), "missing", "runtime.json") + up.Add(5) + runtime.sample() + if err := runtime.save(); err == nil || runtime.snapshot.Error != "state_write_failed" || runtime.snapshot.SavedAtMs != committed.SavedAtMs { + t.Fatalf("failed save incorrectly advanced the watermark: %+v %v", runtime.snapshot, err) + } + if savedRuntime(t, config.StatePath) != committed { + t.Fatal("failed save changed the last saved file") + } + runtime.config.StatePath = config.StatePath + up.Add(8) + recovered := saveRuntimeSample(t, runtime) + if recovered.Session.Uplink != 33 || recovered.Error != "" { + t.Fatalf("retry synthesized or lost raw bytes: %+v", recovered) + } +} + +func TestRuntimeConfigAndStateBoundary(t *testing.T) { + config := runtimeConfig(t) + for _, mutate := range []func(*RuntimeConfig){ + func(c *RuntimeConfig) { c.StatePath = "relative.json" }, + func(c *RuntimeConfig) { c.InboundTag = "" }, + func(c *RuntimeConfig) { c.Listen = "127.0.0.1:12345" }, + func(c *RuntimeConfig) { c.Token = strings.Repeat("a", 32) }, + func(c *RuntimeConfig) { c.Listen, c.Token = "0.0.0.0:12345", strings.Repeat("a", 32) }, + func(c *RuntimeConfig) { c.Listen, c.Token = "localhost:12345", strings.Repeat("a", 32) }, + func(c *RuntimeConfig) { c.Listen, c.Token = "[::1]:12345", strings.Repeat("a", 32) }, + func(c *RuntimeConfig) { c.Listen, c.Token = "127.0.0.1:0", strings.Repeat("a", 32) }, + func(c *RuntimeConfig) { c.Listen, c.Token = "127.0.0.1:65536", strings.Repeat("a", 32) }, + func(c *RuntimeConfig) { c.Listen, c.Token = "127.0.0.1:12345", strings.Repeat("A", 32) }, + func(c *RuntimeConfig) { c.Listen, c.Token = "127.0.0.1:12345", "secret" }, + } { + invalid := config + mutate(&invalid) + if r, err := prepareRuntime(&invalid); err == nil { + _ = r.stateLock.Close() + t.Fatal("invalid runtime metadata accepted") + } + } + for _, text := range []string{ + `{`, `{"version":9}`, + `{"version":1,"session":{"id":"../../outside","startedAtMs":1},"sampledAtMs":1,"savedAtMs":1}`, + `{"version":1,"session":{"id":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","startedAtMs":1,"uplink":-1},"sampledAtMs":1,"savedAtMs":1}`, + `{"version":1,"session":{"id":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","planId":"old","startedAtMs":1},"sampledAtMs":1,"savedAtMs":1}`, + } { + if err := os.WriteFile(config.StatePath, []byte(text), 0600); err != nil { + t.Fatal(err) + } + if _, err := readRuntimeState(config.StatePath); err == nil { + t.Fatal("invalid saved session was accepted") + } + if saved, err := os.ReadFile(config.StatePath); err != nil || string(saved) != text { + t.Fatal("invalid saved session was overwritten") + } + } + runtime, err := prepareRuntime(&config) + if err != nil { + t.Fatalf("saved state unnecessarily blocked preparation: %v", err) + } + _ = runtime.stateLock.Close() +} + +func TestManagedRuntimeStartFailureAndStop(t *testing.T) { + t.Cleanup(func() { _ = StopXray() }) + config := runtimeHTTPConfig(t) + if err := RunXrayWithRuntime(minimalConfig, &config); err == nil || GetXrayState() { + t.Fatal("missing statistics were accepted") + } + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + _, port, _ := net.SplitHostPort(listener.Addr().String()) + xrayJSON := fmt.Sprintf(`{"log":{"loglevel":"none"},"stats":{},"policy":{"system":{"statsInboundUplink":true,"statsInboundDownlink":true}},"inbounds":[{"tag":"tunIn","listen":"127.0.0.1","port":%s,"protocol":"socks","settings":{"udp":false}}],"outbounds":[{"protocol":"freedom","tag":"direct"}]}`, port) + if err := RunXrayWithRuntime(xrayJSON, &config); err == nil || GetXrayState() { + t.Fatal("occupied inbound port did not fail startup") + } + _ = listener.Close() + statisticsListener, err := net.Listen("tcp4", config.Listen) + if err != nil { + t.Fatal(err) + } + if err := RunXrayWithRuntime(xrayJSON, &config); err == nil || GetXrayState() { + t.Fatal("occupied statistics port did not close the constructed core") + } + _ = statisticsListener.Close() + validPath := config.StatePath + config.StatePath = filepath.Join(filepath.Dir(validPath), "missing", "runtime.json") + if err := RunXrayWithRuntime(xrayJSON, &config); err == nil || GetXrayState() { + t.Fatal("unwritable runtime directory did not fail startup") + } + config.StatePath = validPath + if err := RunXrayWithRuntime(xrayJSON, &config); err != nil { + t.Fatal(err) + } + snapshot := savedRuntime(t, config.StatePath) + if !snapshot.Available || snapshot.Session.Uplink != 0 || snapshot.Session.EndedAtMs != 0 { + t.Fatalf("idle statistics should be available zero: %+v", snapshot) + } + // Even a final persistence error must close the core and release the owner lock. + coreRuntime.config.StatePath = filepath.Join(filepath.Dir(validPath), "missing", "runtime.json") + if err := StopXray(); err == nil || GetXrayState() { + t.Fatalf("failed final save did not close core: %v", err) + } + statisticsListener, err = net.Listen("tcp4", config.Listen) + if err != nil { + t.Fatalf("failed final save leaked the statistics listener: %v", err) + } + _ = statisticsListener.Close() + next, err := prepareRuntime(&config) + if err != nil { + t.Fatalf("stop did not release ownership: %v", err) + } + _ = next.stateLock.Close() +} + +func TestRuntimePeriodicSaveAndSingleOwner(t *testing.T) { + config := runtimeConfig(t) + runtime, up, down := runtimeFixture(t, config) + if err := runtime.start(); err != nil { + t.Fatal(err) + } + initial := savedRuntime(t, config.StatePath) + if other, err := prepareRuntime(&config); err == nil || err.Error() != "runtime state is in use" { + if other != nil { + _ = other.stateLock.Close() + } + t.Fatalf("two writers acquired the same path: %v", err) + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + command := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestRuntimeChild$") + command.Env = append(os.Environ(), "LIBXRAY_TEST_RUNTIME_PATH="+config.StatePath, "LIBXRAY_TEST_RUNTIME_ACTION=lock") + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("cross-process owner lock failed: %v: %s", err, output) + } + up.Add(31) + down.Add(47) + // Exercise the real 30s timer without adding a production interval option. + deadline := time.Now().Add(35 * time.Second) + for time.Now().Before(deadline) { + snapshot := savedRuntime(t, config.StatePath) + if snapshot.Session.Uplink == 31 && snapshot.Session.Downlink == 47 && snapshot.SavedAtMs > initial.SavedAtMs { + return + } + time.Sleep(100 * time.Millisecond) + } + t.Fatal("host timer did not save counters without any UI/control request") +} + +func TestRuntimeKilledOwnerStateIsReplacedOnRestart(t *testing.T) { + config := runtimeConfig(t) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + command := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestRuntimeChild$") + command.Env = append(os.Environ(), "LIBXRAY_TEST_RUNTIME_PATH="+config.StatePath, "LIBXRAY_TEST_RUNTIME_ACTION=kill") + pipe, err := command.StdoutPipe() + if err != nil { + t.Fatal(err) + } + if err := command.Start(); err != nil { + t.Fatal(err) + } + scanner := bufio.NewScanner(pipe) + ready := false + for scanner.Scan() { + if scanner.Text() == "READY" { + ready = true + break + } + } + if !ready { + _ = command.Process.Kill() + _ = command.Wait() + t.Fatalf("child did not reach unsaved tail: %v", scanner.Err()) + } + if err := command.Process.Kill(); err != nil { + t.Fatal(err) + } + if err := command.Wait(); err == nil { + t.Fatal("child was not forcibly terminated") + } + saved := savedRuntime(t, config.StatePath) + if saved.Session.Uplink != 17 || saved.Session.Downlink != 23 || saved.Session.EndedAtMs != 0 { + t.Fatalf("kill invented a final sample or ending: %+v", saved) + } + runtime, _, _ := runtimeFixture(t, config) + if err := runtime.start(); err != nil { + t.Fatal(err) + } + if current := savedRuntime(t, config.StatePath); current.Session.ID == saved.Session.ID || current.Session.Uplink != 0 || current.Session.Downlink != 0 { + t.Fatalf("restart reused killed owner's counters: %+v", current) + } + if _, err := os.Lstat(filepath.Join(filepath.Dir(config.StatePath), "runtime-sessions")); !os.IsNotExist(err) { + t.Fatal("restart archived the killed owner's saved state") + } +} + +func TestRuntimeChild(t *testing.T) { + path := os.Getenv("LIBXRAY_TEST_RUNTIME_PATH") + if path == "" { + return + } + config := RuntimeConfig{StatePath: path, InboundTag: "tunIn"} + if os.Getenv("LIBXRAY_TEST_RUNTIME_ACTION") == "lock" { + if other, err := prepareRuntime(&config); err == nil || err.Error() != "runtime state is in use" { + if other != nil { + _ = other.stateLock.Close() + } + t.Fatalf("another process acquired active session ownership: %v", err) + } + return + } + runtime, up, down := runtimeFixture(t, config) + if err := runtime.start(); err != nil { + t.Fatal(err) + } + up.Add(17) + down.Add(23) + saveRuntimeSample(t, runtime) + up.Add(101) + down.Add(103) + fmt.Fprintln(os.Stdout, "READY") + select {} +} diff --git a/xray/validation.go b/xray/validation.go index 3d0a442f..23c6ff4c 100644 --- a/xray/validation.go +++ b/xray/validation.go @@ -1,15 +1,21 @@ package xray -// Test Xray Config. -// xrayJSON is the serialized Xray JSON configuration. +import ( + "errors" + "strings" + + "github.com/xtls/xray-core/core" +) + +// TestXray only builds the configuration; it does not instantiate handlers. +// The core builder can read local assets/certificates and apply root env values. +// Success does not guarantee that the configuration can start. func TestXray(xrayJSON string) error { - server, err := newXrayInstance(xrayJSON) - if err != nil { - return err - } - err = server.Close() - if err != nil { - return err + coreServerMu.Lock() + defer coreServerMu.Unlock() + if coreServer != nil { + return errors.New("testXray requires an isolated process without a managed Xray instance") } - return nil + _, err := core.LoadConfig("json", strings.NewReader(xrayJSON)) + return err } diff --git a/xray/xray.go b/xray/xray.go index ec3f2145..fc39bff1 100644 --- a/xray/xray.go +++ b/xray/xray.go @@ -14,6 +14,7 @@ import ( var ( coreServerMu sync.Mutex coreServer *core.Instance + coreRuntime *managedRuntime ) var ErrAlreadyRunning = errors.New("xray is already running") @@ -35,23 +36,52 @@ func newXrayInstance(xrayJSON string) (*core.Instance, error) { // Run Xray instance. // xrayJSON is the serialized Xray JSON configuration. func RunXray(xrayJSON string) (err error) { + return RunXrayWithRuntime(xrayJSON, nil) +} + +// RunXrayWithRuntime optionally saves this session's raw inbound counters. +func RunXrayWithRuntime(xrayJSON string, config *RuntimeConfig) (err error) { coreServerMu.Lock() defer coreServerMu.Unlock() if coreServer != nil { return ErrAlreadyRunning } + runtime, err := prepareRuntime(config) + if err != nil { + return err + } + if runtime != nil { + defer func() { + if err != nil { + _ = runtime.stateLock.Close() + } + }() + } memory.InitForceFree() server, err := newXrayInstance(xrayJSON) if err != nil { return } + if runtime != nil { + if err = runtime.attach(server); err != nil { + _ = server.Close() + return err + } + } if err = server.Start(); err != nil { _ = server.Close() return } + if runtime != nil { + if err = runtime.start(); err != nil { + _ = server.Close() + return err + } + } coreServer = server + coreRuntime = runtime debug.FreeOSMemory() return nil @@ -69,7 +99,13 @@ func StopXray() error { coreServerMu.Lock() defer coreServerMu.Unlock() if coreServer != nil { - err := coreServer.Close() + var runtimeErr error + if coreRuntime != nil { + defer coreRuntime.stateLock.Close() + runtimeErr = coreRuntime.stop() + coreRuntime = nil + } + err := errors.Join(runtimeErr, coreServer.Close()) coreServer = nil if err != nil { return err diff --git a/xray/xray_test.go b/xray/xray_test.go index 192f825e..f766b029 100644 --- a/xray/xray_test.go +++ b/xray/xray_test.go @@ -2,6 +2,8 @@ package xray import ( "errors" + "os" + "strings" "sync" "testing" ) @@ -12,6 +14,32 @@ const minimalConfig = `{ "outbounds": [{"protocol": "freedom", "tag": "direct"}] }` +func TestTemporaryOperationsRejectManagedOverlap(t *testing.T) { + if err := RunXray(minimalConfig); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = StopXray() }) + const key = "XRAY_LIBXRAY_TEMPORARY_OVERLAP_TEST" + t.Setenv(key, "original") + config := `{"env":{"` + key + `":"changed"},"outbounds":[{"protocol":"freedom"}]}` + for name, operation := range map[string]func() error{ + "testXray": func() error { return TestXray(config) }, + "pingBatch": func() error { + _, err := PingBatch([]PingBatchItem{{XrayJSON: config}}, 10, "http://127.0.0.1:1/") + return err + }, + } { + t.Run(name, func(t *testing.T) { + if err := operation(); err == nil || !strings.Contains(err.Error(), "isolated process") { + t.Fatalf("managed overlap was not rejected: %v", err) + } + if os.Getenv(key) != "original" || !GetXrayState() { + t.Fatal("temporary operation changed the active instance or process environment") + } + }) + } +} + func TestRunXrayRejectsDuplicateStart(t *testing.T) { t.Cleanup(func() { if err := StopXray(); err != nil { From 1b6604b42ba70e459032ad443b8bbac302b558e3 Mon Sep 17 00:00:00 2001 From: yiguodev <147401898+yiguodev@users.noreply.github.com> Date: Tue, 8 Sep 2026 19:56:09 +0800 Subject: [PATCH 17/38] refactor: remove runtime accounting and simplify share responses (#151) * refactor: remove cross-process runtime file locking * refactor: remove managed runtime traffic accounting * refactor: return share outbounds without parsing statistics * docs: keep pull requests scoped to their repository --- AGENTS.md | 13 +- README.md | 136 ++----------- desktop_bin/main.go | 26 +-- desktop_bin/main_test.go | 4 - invoke.go | 2 +- invoke_model.go | 10 +- invoke_probes_test.go | 47 ++--- invoke_test.go | 77 ++----- readme/README.zh_CN.md | 110 ++-------- share/convert_share.go | 48 ++--- share/convert_share_test.go | 68 +++---- share/marshal_share.go | 12 +- share/marshal_share_test.go | 8 +- share/test_helpers_test.go | 5 +- xray/runtime.go | 263 ------------------------ xray/runtime_file.go | 26 --- xray/runtime_file_windows.go | 23 --- xray/runtime_http.go | 81 -------- xray/runtime_http_test.go | 189 ----------------- xray/runtime_test.go | 385 ----------------------------------- xray/xray.go | 46 +---- 21 files changed, 134 insertions(+), 1445 deletions(-) delete mode 100644 xray/runtime.go delete mode 100644 xray/runtime_file.go delete mode 100644 xray/runtime_file_windows.go delete mode 100644 xray/runtime_http.go delete mode 100644 xray/runtime_http_test.go delete mode 100644 xray/runtime_test.go diff --git a/AGENTS.md b/AGENTS.md index 883d63a1..196c6286 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,10 +24,8 @@ and the models/dispatch in `invoke_model.go` and `invoke.go`. - When changing [batch probes](README.md#pingbatch), preserve input order, per-item failure isolation, and raw `locationJson`; provider parsing belongs to the App. -- Before changing persistence or HTTP access, read - [managed runtime accounting](README.md#managed-runtime-accounting). - Save only the current session's inbound counters. Native metrics provides live - readings; runtime HTTP provides saved snapshots. The App owns totals and reset. +- Traffic comes directly from [Xray metrics](README.md#metrics). The library + manages the Core lifecycle only; it does not sample or persist traffic. - When changing [age subscriptions](README.md#age-encrypted-subscriptions), keep key generation/decryption in libXray and HTTP/persistence in the App. Never log secret keys, decrypted subscriptions, or requests containing them. @@ -56,6 +54,13 @@ python3 build/main.py apple go Other targets and local-core options are documented in [build usage](README.md#usage). +## Pull requests + +Use English for PR titles and descriptions. Keep titles, descriptions, and +comments self-contained: do not mention or link to another repository's PR, +including companion, dependency, or merge-order references. Describe required +interface or build behavior directly. + ## Verification Run `git diff --check` for all changes. Match further verification to the change; diff --git a/README.md b/README.md index 6f965e7c..617f42c7 100644 --- a/README.md +++ b/README.md @@ -74,13 +74,11 @@ 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 [-runtime ] +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. -Optional `-runtime` reads the host metadata object described under "Managed -runtime accounting", without a wrapping `runtime` key; it does not replace `-config`. > [!WARNING] > **Use only one Go runtime per process.** Go does not support loading multiple @@ -205,8 +203,7 @@ Design notes: fields supported by libXray share links; unsupported and generated empty fields are omitted. Opaque XHTTP `extra` and FinalMask mask `settings` JSON remain unchanged. - Every successful response returns the projected config together with - `usableCount` and `failedCount`. + Every successful response contains only the projected `outbounds` list. Its optional `age.secretKey` decrypts official age ASCII armor in memory before the existing parser runs. Plaintext input remains unchanged. 7. Xray-core keeps its system dialer DNS client and outbound manager in @@ -329,23 +326,16 @@ convert VMessQRCode to Xray Json. `convertShareLinksToXrayJson` has one response shape. Its payload contains `text` and optional `age`. Every successful conversion returns -`data: {"config":{"outbounds":[...]},"usableCount":2,"failedCount":1}`. - -Counts describe this input only, not added/changed nodes. Each root JSON -`outbounds` element or YAML `proxies` element is one candidate. In detected -share-link lists, each URI-like row is one candidate; blank lines, comments and -text headers are ignored. Base64 and age wrappers use the inner format's -candidates. Malformed individual elements are skipped without discarding other -valid elements. `usableCount` equals the final projected, buildable -outbound count; parse, build and unsupported-projection failures count toward -`failedCount`. No per-node hash comparison or deduplication is performed. - -A recognized container with zero usable nodes returns `success: false` with -structured counts and `config: {"outbounds":[]}`. An unrecognized format, -malformed whole document, invalid container or decryption failure returns -`data: null`; counts are not guessed. Error text never includes rejected -candidates or decrypted subscription text. Callers must not import/replace a -subscription when no usable nodes remain. +`data: {"outbounds":[...]}`. There is no statistics or nested config wrapper. + +Invalid individual elements are skipped without discarding other valid nodes. +The list preserves source order and includes only projected, buildable outbounds. +No per-node hash comparison, deduplication or failed-node counting is performed. + +No usable nodes, an unrecognized format, a malformed document, an invalid +container or a decryption failure returns `success: false` with `data: null`. +Error text never includes rejected candidates or decrypted subscription text. +Callers must not import/replace a subscription when no usable nodes remain. ### age-encrypted subscriptions @@ -487,105 +477,6 @@ that the network is reachable. Callers must handle actual startup failures. Starts the managed Xray instance from the supplied JSON text. Use `stopXray` to stop that instance. `runXrayFromJson` is no longer a separate method. -### Managed runtime accounting - -`runXray.payload.runtime` is optional API v3 host metadata. Omitting it -preserves the original lifecycle and writes no runtime snapshots. Hosts opt in -with this object (also the complete content of the desktop `-runtime` file): - -```json -{ - "statePath": "/private/app/run/runtime.json", - "inboundTag": "tunIn", - "listen": "127.0.0.1:49228", - "token": "538fc3253a3e433491bc2d653fc74214" -} -``` - -The host supplies an existing private directory and an absolute `statePath`. -`inboundTag` must be nonempty and at most 256 bytes. Metadata stays separate -from Xray JSON, so user configuration cannot override it. The named inbound -must exist, with uplink/downlink system statistics and a statistics manager enabled. -`listen` and `token` may both be omitted to save snapshots without HTTP. When -enabled, `listen` must be `127.0.0.1:` with port 1–65535, and the host must -generate a fresh random 32-character lowercase hex `token`. Keep it private; -do not reuse the example token. Invalid metadata, an occupied HTTP port, or an -initial save failure rejects startup; any constructed core and statistics -listener are closed. - -The saved file contains only the current session's raw inbound counter values: - -```json -{ - "version": 1, - "session": { - "id": "2a7e2e49b947a802d8b39af4fbc48f52", - "startedAtMs": 1788300000000, - "endedAtMs": 0, - "uplink": 120, - "downlink": 800 - }, - "available": true, - "sampledAtMs": 1788300030000, - "savedAtMs": 1788300030000, - "error": "" -} -``` - -Timestamps are Unix milliseconds. Each new start generates a random -32-character lowercase hex session ID, even when replaying identical metadata. -`endedAtMs: 0` means no final stop was saved; it is not proof that the VPN is -running. The host saves an initial snapshot, samples/saves every 30 seconds, -and attempts a final sample/save before closing the core on `stopXray`. - -Sampling reads the named inbound's `Value()`, never resets it and never adds -outbound/node counters. Repeated samples do not accumulate bytes. A nonnegative -counter rollback is recorded as the smaller raw value, not a synthetic delta. -Missing or negative counters set `available: false` and -`error: "counters_unavailable"`, retaining the last valid nonnegative values. -Idle valid counters report available zero. There are no application-wide totals, -reset generations, or VPN control HTTP methods. -`resetRuntime` is not an Invoke method. Applications may read existing Xray -metrics for live rates; their own totals/reset policy stays outside libXray. - -Starting a new session atomically replaces the previous `runtime.json`; libXray -does not archive or merge earlier sessions. Traffic not read by the App before -replacement is intentionally lost. Each session starts from zero and receives a -new ID. - -Snapshot files use a mode-0600 same-directory temporary file, sync, and atomic -replacement (Windows uses `MoveFileEx` with replace-existing and write-through). -The private parent directory/Windows ACL remains the host's responsibility. -Failed saves leave the previous complete disk snapshot for later retry; a final -save error is returned but never prevents core shutdown. An error after rename -can have an uncertain persistence outcome, so consumers must re-read saved -snapshots through HTTP when available. -This is reference data, not billing: crashes, forced termination, or replacement -before the App reads the file can lose traffic, with no strict loss bound. - -A nonblocking OS lock on `statePath + ".lock"` is held until core close, -preventing another process from writing the current session. -Hosts must use one consistent canonical path and leave the lock file in place. -App code reads snapshots through HTTP instead of opening the host's files, so -macOS System Extension files can remain root-owned. This does not provide -graceful final settlement when Windows forcibly terminates a job. - -#### Snapshot HTTP - -The optional statistics listener starts with the managed session and closes on -stop, including when the final save fails. It uses a separate loopback port -from Xray's native metrics; it provides no VPN start/stop/configuration methods. -Every request requires `Authorization: Bearer `. Responses use -`Cache-Control: no-store`; CORS is not enabled. - -- `GET /runtime` returns the current saved snapshot directly. - -Requests read the host's saved atomic snapshot without sampling, resetting -counters, or updating the save time. Use native metrics for live rates. A -missing, corrupt, or non-regular snapshot returns service unavailable. Requests -have bounded read/write timeouts. While stopped, HTTP is unavailable; libXray -never owns App totals or clear/reset policy. - ### metrics Refer to the following configuration: @@ -615,7 +506,8 @@ http://localhost:49227/debug/vars ``` Metrics only needs the `listen` field in this wrapper. Query `/debug/vars` -directly with an HTTP client instead of going through libXray. +directly with an HTTP client instead of going through libXray. Counters belong +to the current Xray instance; libXray does not sample or persist traffic. ### validation diff --git a/desktop_bin/main.go b/desktop_bin/main.go index 8ea0a6f6..33ba90af 100644 --- a/desktop_bin/main.go +++ b/desktop_bin/main.go @@ -3,7 +3,6 @@ package main import ( - "encoding/json" "errors" "flag" "fmt" @@ -20,7 +19,6 @@ type runOptions struct { dns string interfaceName string configPath string - runtimePath string } func parseRunOptions(args []string) (runOptions, error) { @@ -34,7 +32,6 @@ func parseRunOptions(args []string) (runOptions, error) { 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") - flags.StringVar(&options.runtimePath, "runtime", "", "optional host runtime metadata JSON path") if err := flags.Parse(args[1:]); err != nil { return options, err } @@ -52,31 +49,12 @@ func run(options runOptions) error { if err != nil { return err } - var runtime *xray.RuntimeConfig - if options.runtimePath != "" { - file, err := os.Open(options.runtimePath) - if err != nil { - return err - } - data, readErr := io.ReadAll(io.LimitReader(file, 64*1024+1)) - closeErr := file.Close() - if err := errors.Join(readErr, closeErr); err != nil { - return err - } - if len(data) > 64*1024 { - return errors.New("runtime metadata exceeds 64 KiB") - } - runtime = new(xray.RuntimeConfig) - if err := json.Unmarshal(data, runtime); err != nil { - return errors.New("invalid runtime metadata") - } - } if err := dns.SetDNS(options.dns, options.interfaceName); err != nil { return err } defer dns.ResetDNS() - if err := xray.RunXrayWithRuntime(string(config), runtime); err != nil { + if err := xray.RunXray(string(config)); err != nil { return err } @@ -88,7 +66,7 @@ func run(options runOptions) error { } func printUsage() { - fmt.Fprintln(os.Stdout, "Usage: xray run -dns -interface -config [-runtime ]") + fmt.Fprintln(os.Stdout, "Usage: xray run -dns -interface -config ") } func main() { diff --git a/desktop_bin/main_test.go b/desktop_bin/main_test.go index becb4cf8..8d77f4d0 100644 --- a/desktop_bin/main_test.go +++ b/desktop_bin/main_test.go @@ -10,7 +10,6 @@ func TestParseRunOptions(t *testing.T) { "-dns", "8.8.8.8:53", "-interface", "Ethernet", "-config", `C:\run\xray.json`, - "-runtime", `C:\run\runtime.json`, }) if err != nil { t.Fatal(err) @@ -18,9 +17,6 @@ func TestParseRunOptions(t *testing.T) { if options.dns != "8.8.8.8:53" || options.interfaceName != "Ethernet" || options.configPath != `C:\run\xray.json` { t.Fatalf("unexpected options: %#v", options) } - if options.runtimePath != `C:\run\runtime.json` { - t.Fatalf("unexpected runtime path: %q", options.runtimePath) - } if _, err := parseRunOptions([]string{"run", "-config", "xray.json"}); err == nil { t.Fatal("missing DNS protection options were accepted") diff --git a/invoke.go b/invoke.go index 4ae1a147..ac3573ad 100644 --- a/invoke.go +++ b/invoke.go @@ -233,6 +233,6 @@ func invokeRunXray(payload json.RawMessage) string { if err != nil { return encodeInvokeNoDataResponse(err) } - err = xray.RunXrayWithRuntime(request.XrayJson, request.Runtime) + err = xray.RunXray(request.XrayJson) return encodeInvokeNoDataResponse(err) } diff --git a/invoke_model.go b/invoke_model.go index 05fb45b4..b723ac71 100644 --- a/invoke_model.go +++ b/invoke_model.go @@ -3,9 +3,6 @@ package libXray import ( "encoding/json" - - "github.com/xtls/libxray/share" - "github.com/xtls/libxray/xray" ) type LibXrayMethod string @@ -49,8 +46,6 @@ type ConvertShareLinksToXrayJsonRequest struct { Age *AgeDecryptConfig `json:"age,omitempty"` } -type ConvertShareLinksToXrayJsonResponse = share.ConvertShareLinksResult - type AgeKeyType string const ( @@ -106,12 +101,9 @@ type PingBatchItemResponse struct { } type RunXrayRequest struct { - XrayJson string `json:"xrayJson,omitempty"` - Runtime *RuntimeConfig `json:"runtime,omitempty"` + XrayJson string `json:"xrayJson,omitempty"` } -type RuntimeConfig = xray.RuntimeConfig - type TestXrayRequest struct { XrayJson string `json:"xrayJson,omitempty"` } diff --git a/invoke_probes_test.go b/invoke_probes_test.go index 56f795f6..8e8caff9 100644 --- a/invoke_probes_test.go +++ b/invoke_probes_test.go @@ -9,38 +9,27 @@ import ( "testing" ) -func TestInvokeShareStatsResponseShape(t *testing.T) { +func TestInvokeShareOutboundsResponseShape(t *testing.T) { const validLink = "vless://12345678-abcd-abcd-abcd-123456789abc@example.com:443?encryption=none&security=tls&sni=example.com" - for _, test := range []struct { - text string - usable, failed int - success bool - }{ - {validLink + "\nvless://bad@example.com:443", 1, 1, true}, - {"vless://bad@example.com:443", 0, 1, false}, - } { - response := invokeForTest(t, LibXrayMethodConvertShareLinksToXrayJson, ConvertShareLinksToXrayJsonRequest{Text: test.text}) - if response.Success != test.success { - t.Fatalf("success = %v, error = %s", response.Success, response.Err) - } - var result ConvertShareLinksToXrayJsonResponse - if err := json.Unmarshal(response.Data, &result); err != nil { - t.Fatal(err) - } - if result.UsableCount != test.usable || result.FailedCount != test.failed || len(result.Config) == 0 { - t.Fatalf("result = %+v", result) - } - var root map[string]json.RawMessage - if err := json.Unmarshal(response.Data, &root); err != nil { - t.Fatal(err) - } - if len(root) != 3 || root["config"] == nil || root["usableCount"] == nil || root["failedCount"] == nil { - t.Fatalf("data = %s", response.Data) - } + response := invokeForTest(t, LibXrayMethodConvertShareLinksToXrayJson, ConvertShareLinksToXrayJsonRequest{ + Text: validLink + "\nvless://bad@example.com:443", + }) + if !response.Success { + t.Fatal(response.Err) + } + var root map[string]json.RawMessage + if err := json.Unmarshal(response.Data, &root); err != nil { + t.Fatal(err) + } + if len(root) != 1 || root["outbounds"] == nil { + t.Fatalf("data = %s, want only outbounds", response.Data) + } + if config := decodeShareConfig(t, response); len(config.OutboundConfigs) != 1 { + t.Fatalf("outbounds = %d, want 1", len(config.OutboundConfigs)) } - for _, text := range []string{`{"outbounds":`, "-----BEGIN AGE ENCRYPTED FILE-----\ninvalid"} { + for _, text := range []string{"vless://bad@example.com:443", `{"outbounds":[]}`, `{"outbounds":`, "-----BEGIN AGE ENCRYPTED FILE-----\ninvalid"} { response := invokeForTest(t, LibXrayMethodConvertShareLinksToXrayJson, ConvertShareLinksToXrayJsonRequest{Text: text}) - if response.Success || string(response.Data) != "null" { + if response.Success || string(response.Data) != "null" || response.Err == "" { t.Fatalf("response = %+v", response) } } diff --git a/invoke_test.go b/invoke_test.go index 76527de0..b0057d30 100644 --- a/invoke_test.go +++ b/invoke_test.go @@ -77,14 +77,9 @@ func decodeDataObject[T any](t *testing.T, response testResponse) T { return value } -func decodeShareConfig(t *testing.T, response testResponse) (ConvertShareLinksToXrayJsonResponse, conf.Config) { +func decodeShareConfig(t *testing.T, response testResponse) conf.Config { t.Helper() - result := decodeDataObject[ConvertShareLinksToXrayJsonResponse](t, response) - var config conf.Config - if err := json.Unmarshal(result.Config, &config); err != nil { - t.Fatal(err) - } - return result, config + return decodeDataObject[conf.Config](t, response) } func writeGeoSiteDatForTest(t *testing.T, path string) { @@ -286,43 +281,6 @@ func TestInvokeRunXray(t *testing.T) { requireNoDataObject(t, response) } -func TestInvokeRunXrayRuntimeIsOptionalTypedMetadata(t *testing.T) { - defer xrayStopForTest(t) - request := RunXrayRequest{ - XrayJson: `{"log":{"loglevel":"none"},"outbounds":[{"protocol":"freedom"}]}`, - Runtime: &RuntimeConfig{ - StatePath: "relative.json", InboundTag: "tunIn", - }, - } - encoded, err := json.Marshal(request) - if err != nil || !strings.Contains(string(encoded), `"runtime":{"statePath":`) { - t.Fatalf("runtime metadata is missing from the request: %v", err) - } - response := invokeForTest(t, LibXrayMethodRunXray, request) - if response.Success || !strings.Contains(response.Err, "absolute statePath") { - t.Fatalf("runtime validation was bypassed: %+v", response) - } - response = invokeRawForTest(t, `{"apiVersion":3,"method":"runXray","payload":{"xrayJson":"{}","runtime":"invalid"}}`) - if response.Success { - t.Fatal("untyped runtime metadata was accepted") - } -} - -func TestInvokeRejectsRemovedRuntimeControl(t *testing.T) { - path := filepath.Join(t.TempDir(), "runtime.json") - data := []byte(`{"fixture":"must not change"}`) - if err := os.WriteFile(path, data, 0600); err != nil { - t.Fatal(err) - } - response := invokeForTest(t, LibXrayMethod("resetRuntime"), map[string]string{"statePath": path}) - if response.Success || response.Err != "unknown method" || string(response.Data) != "null" { - t.Fatalf("removed runtime control was accepted: %+v", response) - } - if saved, err := os.ReadFile(path); err != nil || !bytes.Equal(saved, data) { - t.Fatalf("unknown method modified a file: %s %v", saved, err) - } -} - func TestInvokeRunXrayAppliesConfigEnv(t *testing.T) { const key = "XRAY_LIBXRAY_CONFIG_ENV_TEST" t.Setenv(key, "") @@ -413,10 +371,7 @@ func TestInvokeConvertShareLinksFiltersBuildInvalidOutbounds(t *testing.T) { if !response.Success { t.Fatalf("ConvertShareLinksToXrayJson failed: %s", response.Err) } - result, config := decodeShareConfig(t, response) - if result.UsableCount != 1 || result.FailedCount != 1 { - t.Fatalf("result = %+v, want 1 usable and 1 failed", result) - } + config := decodeShareConfig(t, response) if len(config.OutboundConfigs) != 1 { t.Fatalf("outbounds = %d, want 1", len(config.OutboundConfigs)) } @@ -442,21 +397,21 @@ func TestInvokeConvertShareLinksReturnsProjectedObject(t *testing.T) { t.Fatalf("ConvertShareLinksToXrayJson failed: %s", response.Err) } - result, config := decodeShareConfig(t, response) + config := decodeShareConfig(t, response) var root map[string]json.RawMessage - if err := json.Unmarshal(result.Config, &root); err != nil { - t.Fatalf("config is not an object: %s", result.Config) + if err := json.Unmarshal(response.Data, &root); err != nil { + t.Fatalf("config is not an object: %s", response.Data) } if len(root) != 1 || root["outbounds"] == nil { - t.Fatalf("config root = %s, want only outbounds", result.Config) + t.Fatalf("config root = %s, want only outbounds", response.Data) } for _, field := range []string{"publicKey", "target", "dest", "proxySettings", "sockopt"} { - if bytes.Contains(result.Config, []byte(`"`+field+`"`)) { - t.Fatalf("config contains unsupported field %q: %s", field, result.Config) + if bytes.Contains(response.Data, []byte(`"`+field+`"`)) { + t.Fatalf("config contains unsupported field %q: %s", field, response.Data) } } - if !bytes.Contains(result.Config, []byte(`"password":"`+publicKey+`"`)) { - t.Fatalf("config did not canonicalize REALITY password: %s", result.Config) + if !bytes.Contains(response.Data, []byte(`"password":"`+publicKey+`"`)) { + t.Fatalf("config did not canonicalize REALITY password: %s", response.Data) } if len(config.OutboundConfigs) != 1 { @@ -513,10 +468,7 @@ func TestInvokeAgeKeyGenerationAndConversion(t *testing.T) { if !converted.Success { t.Fatalf("ConvertShareLinksToXrayJson failed: %s", converted.Err) } - result, config := decodeShareConfig(t, converted) - if result.UsableCount != 1 || result.FailedCount != 0 { - t.Fatalf("result = %+v, want 1 usable and 0 failed", result) - } + config := decodeShareConfig(t, converted) if len(config.OutboundConfigs) != 1 { t.Fatalf("outbounds = %d, want 1", len(config.OutboundConfigs)) } @@ -565,9 +517,8 @@ func TestInvokeConvertShareLinksFailsWhenAllOutboundsAreBuildInvalid(t *testing. if !strings.Contains(response.Err, "no valid outbound found") { t.Fatalf("error = %q", response.Err) } - result, config := decodeShareConfig(t, response) - if result.UsableCount != 0 || result.FailedCount != 1 || len(config.OutboundConfigs) != 0 { - t.Fatalf("result = %+v, config = %+v", result, config) + if string(response.Data) != "null" { + t.Fatalf("data = %s, want null", response.Data) } } diff --git a/readme/README.zh_CN.md b/readme/README.zh_CN.md index 41d3f310..fe59ac89 100644 --- a/readme/README.zh_CN.md +++ b/readme/README.zh_CN.md @@ -42,11 +42,11 @@ Linux 和 Windows 构建还会生成 `bin/xray` 或 `bin/xray.exe`。该会话 C 会保护 Go DNS 查询不被 VPN 路由重新捕获,并且只接受以下命令: ```shell -xray run -dns -interface <网卡名> -config [-runtime ] +xray run -dns -interface <网卡名> -config ``` -前三个参数都必须提供。`-dns` 必须是 IP endpoint,`-config` 直接指向 Xray -JSON 配置。可选 `-runtime` 的 JSON 对象见“托管运行统计”,不含外层 `runtime`。 +三个参数都必须提供。`-dns` 必须是 IP endpoint,`-config` 直接指向 Xray +JSON 配置。 > [!WARNING] > **每个进程只能使用一个 Go runtime。** Go 不支持在同一进程中加载多个独立构建的 @@ -150,7 +150,7 @@ void CGoFree(char* value); 3. `SetTunFd` 已删除。如果 fd 只能在运行时获得,请在调用 `runXray` 前把 `xray.tun.fd` 写入 Xray 配置根 `env` 对象。 4. `countGeoData` 不依赖 Xray 配置,因此通过 method payload 的 `datDir` 传入数据目录。 5. 完整的 UTF-8 编码 Invoke 请求和响应 JSON 包体限制为 16 MiB。任一方向超过限制时,Invoke 将返回 `success: false`、`data: null` 和对应的大小限制错误。 -6. `convertShareLinksToXrayJson` 会使用当前 Xray-core 配置构建器校验每个已解析的 outbound。无效 outbound 会被忽略;如果没有剩余的有效 outbound,该方法返回失败。校验不会创建或启动 Xray instance。Xray JSON 输入仅作为节点来源,只保留根级 `outbounds`,忽略其他根字段。响应仅包含 libXray 分享链接支持的字段,不支持的字段和生成的空字段会被省略;XHTTP `extra` 与 FinalMask mask `settings` 中的原始 JSON 保持不变。每次成功响应都会返回投影后的配置及 `usableCount` 和 `failedCount`。可选的 `age.secretKey` 会在现有解析流程前于内存中解密官方 age ASCII armor;明文输入保持原有行为。 +6. `convertShareLinksToXrayJson` 会使用当前 Xray-core 配置构建器校验每个已解析的 outbound。无效 outbound 会被忽略;如果没有剩余的有效 outbound,该方法返回失败。校验不会创建或启动 Xray instance。Xray JSON 输入仅作为节点来源,只保留根级 `outbounds`,忽略其他根字段。响应仅包含 libXray 分享链接支持的字段,不支持的字段和生成的空字段会被省略;XHTTP `extra` 与 FinalMask mask `settings` 中的原始 JSON 保持不变。每次成功响应仅包含投影后的 `outbounds` 列表。可选的 `age.secretKey` 会在现有解析流程前于内存中解密官方 age ASCII armor;明文输入保持原有行为。 7. Xray-core 的系统拨号 DNS client 和 outbound manager 属于进程级状态。`pingBatch`、`testXray` 及对应导出的 Go 入口均取得受管理生命周期锁,在加载/构建配置前拒绝同进程已运行的 `runXray` instance。批量测速在全部 worker 和临时核心关闭后才释放锁,这些操作也彼此串行。由管理 API 之外创建的 instance 不在检测或恢复范围内;可能与它们重叠的调用仍须使用独立进程。 支持的 method: @@ -242,18 +242,13 @@ libXray 使用 `tag` 存储节点名称。`sendThrough` 保留 Xray 原生语义 `convertShareLinksToXrayJson` 只有一种响应结构。payload 包含 `text` 和可选的 `age`。每次转换成功均返回 -`data: {"config":{"outbounds":[...]},"usableCount":2,"failedCount":1}`。 - -数量只描述本次输入,不区分新增和更新。JSON 根 `outbounds` 的每个元素、YAML -`proxies` 的每个元素各算一个候选。已识别的分享链接列表中,每条 URI 形式的行 -算一个候选,空行、注释和文本标题忽略。Base64 / age 包装使用内部格式的候选 -数量。类型错误的单项会被跳过,不丢弃其余有效元素。`usableCount` 与最终投影且 -可构建的 outbound 数量相同;解析失败、构建失败和投影不支持的候选均计入 -`failedCount`。不做节点 hash 比较或去重。 - -已识别容器中没有可用节点时,返回 `success: false`,保留结构化数量和 -`config: {"outbounds":[]}`。无法识别格式、整份文档语法错误、容器错误或解密 -失败时返回 `data: null`,不猜测数量。错误文案不含被拒绝的候选或解密明文。 +`data: {"outbounds":[...]}`,不包含统计或额外的 `config` 包装。 + +无效单项会被跳过,不丢弃其余有效节点。列表按源顺序保留投影且可构建的 +outbound,不做节点 hash 比较、去重或失败节点计数。 + +没有可用节点、无法识别格式、整份文档语法错误、容器错误或解密失败时, +返回 `success: false` 与 `data: null`。错误文案不含被拒绝的候选或解密明文。 调用方不得在可用节点为零时导入或覆盖订阅。 ### age 加密订阅 @@ -383,90 +378,9 @@ Geodata assets 声明只校验 HTTPS URL 和已存在的本地文件,下载器 使用传入的 Xray JSON 文本启动由 libXray 管理的 Xray instance,并通过 `stopXray` 停止。`runXrayFromJson` 不再作为独立 method 存在。 -### 托管运行统计 - -API v3 的 `runXray.payload.runtime` 为可选宿主元数据。省略时保留原生命周期, -不写运行快照。宿主传入以下对象;Desktop 的 `-runtime` 文件也直接使用此对象, -不含外层 `runtime`,原始 Xray 配置仍通过独立的 `-config` 传入。 - -```json -{ - "statePath": "/private/app/run/runtime.json", - "inboundTag": "tunIn", - "listen": "127.0.0.1:49228", - "token": "538fc3253a3e433491bc2d653fc74214" -} -``` - -宿主提供已存在的私有目录和绝对 `statePath`。`inboundTag` 非空且不超过 256 字节。 -元数据独立于 Xray JSON,用户配置不能覆盖。指定入站必须存在,并启用上下行系统统计 -和 stats manager。 -`listen` / `token` 可同时省略,保留仅落盘、不启用 HTTP 的行为。启用时 `listen` -只能是 `127.0.0.1:`,端口范围 1–65535;宿主须生成新的 32 位小写十六进制 -随机 `token` 并保密,不能复用示例值。元数据无效、HTTP 端口被占用或首次保存失败 -均拒绝启动,并关闭已构建的核心和统计监听器。 - -落盘文件仅包含本次会话的原始入站计数: - -```json -{ - "version": 1, - "session": { - "id": "2a7e2e49b947a802d8b39af4fbc48f52", - "startedAtMs": 1788300000000, - "endedAtMs": 0, - "uplink": 120, - "downlink": 800 - }, - "available": true, - "sampledAtMs": 1788300030000, - "savedAtMs": 1788300030000, - "error": "" -} -``` - -时间为 Unix 毫秒。每次新启动生成 32 位小写十六进制随机 session ID,即使重放相同 -元数据也不复用。`endedAtMs: 0` 只表示没有保存最终停止快照,不能用来判断 VPN -仍在运行。宿主启动时先保存新快照,此后每 30 秒采样保存,`stopXray` 在关闭核心前 -尽力完成最终采样保存。 - -采样直接读取指定入站的 `Value()`,不重置计数,不叠加节点或 outbound 计数。 -重复采样不累加字节;非负计数回退时保存实际较小值,不合成差额。计数缺失或为负时, -`available: false`、`error: "counters_unavailable"`,保留上次合法的非负值。 -有效入站尚无流量时为可用的 0。不维护 App 总量、重置代次,也不提供 VPN 控制 HTTP -方法。`resetRuntime` 不是 Invoke method。App 可通过已有 Xray metrics -读取实时速率;App 累计与重置策略由 App 自行管理,不属于 libXray。 - -启动新会话时会原子覆盖之前的 `runtime.json`;libXray 不归档或合并旧会话。 -若 App 未在覆盖前读取流量,该数据将直接丢失。每个会话都从零开始,并生成新的 ID。 - -快照文件使用同目录 0600 临时文件,sync 后原子替换;Windows 使用 -`MoveFileEx` 的替换和 write-through 标志。私有父目录/Windows ACL 由宿主管理。 -保存失败保留上次完整磁盘快照供后续重试;最终保存失败向调用方报告,但仍关闭核心。 -rename 后发生 I/O 错误时结果可能不确定,消费者应在 HTTP 可用时重新读取已保存的快照。这是参考数据, -不是计费账本:崩溃、强杀或 App 读取前被新会话覆盖都可能丢失流量,不承诺严格的 -丢失上限。 - -`statePath + ".lock"` 的非阻塞操作系统文件锁保持至核心关闭,防止跨进程同时 -改写当前会话。宿主须使用一致的规范路径并保留锁文件。App 经 HTTP 读取快照, -无需打开宿主文件,因此 macOS System Extension 文件可继续归 root 所有。此能力 -不能让 Windows Job 强制终止获得正常最终结算。 - -#### 快照 HTTP - -可选统计监听器随托管会话启动,在停止时关闭,最终保存失败也会关闭。它使用独立于 -Xray 原生 metrics 的回环端口,不提供 VPN 启停或配置方法。所有请求必须携带 -`Authorization: Bearer `;响应使用 `Cache-Control: no-store`,不启用 CORS。 - -- `GET /runtime` 直接返回当前已保存的快照。 - -请求只读取宿主已保存的原子快照,不触发采样、计数重置或保存时间更新;实时速率仍使用 -原生 metrics。快照缺失、损坏或不是常规文件时返回服务不可用。请求有读写超时限制。 -停止期间 HTTP 不可用;libXray 不维护 App 累计值或清零策略。 - ### metrics -统计。 +App 直接通过 Xray metrics HTTP API 读取当前 instance 的流量计数;libXray 不采样或保存流量。 参考如下配置: diff --git a/share/convert_share.go b/share/convert_share.go index 74c19b73..2e9a797f 100644 --- a/share/convert_share.go +++ b/share/convert_share.go @@ -10,49 +10,36 @@ import ( "gopkg.in/yaml.v3" ) -// ConvertShareLinksResult counts source candidates, not lines or changes to a subscription. -// Config contains exactly the projected, buildable outbounds counted as usable. -type ConvertShareLinksResult struct { - Config json.RawMessage `json:"config"` - UsableCount int `json:"usableCount"` - FailedCount int `json:"failedCount"` -} - // ConvertShareLinksToXrayJson parses share links or an Age-encrypted subscription. -// A recognized candidate container with no usable nodes returns both its counts -// and an error. Whole-document/decryption failures return no invented counts. -func ConvertShareLinksToXrayJson(links, secretKey string) (*ConvertShareLinksResult, error) { +// The returned JSON contains only projected, buildable outbounds. +func ConvertShareLinksToXrayJson(links, secretKey string) (json.RawMessage, error) { text, encrypted, err := decryptShareText(links, secretKey) if err != nil { return nil, err } - config, candidates, err := parseShareCandidates(text, true) + config, err := parseShareCandidates(text, true) if err != nil { if encrypted { return nil, ErrAgePlaintextUnsupported } return nil, err } - result := &ConvertShareLinksResult{Config: json.RawMessage(`{"outbounds":[]}`), FailedCount: candidates} config, err = filterBuildableOutbounds(config) if err == nil { var raw json.RawMessage - var usable int - raw, usable, err = marshalShareConfigJSON(config) + raw, err = marshalShareConfigJSON(config) if err == nil { - result.Config, result.UsableCount, result.FailedCount = raw, usable, candidates-usable - return result, nil + return raw, nil } } if encrypted { - return result, ErrAgePlaintextUnsupported + return nil, ErrAgePlaintextUnsupported } - // Builder errors can contain credentials or whole source values. Counts do - // not require those diagnostics; never echo rejected candidates. - return result, errors.New("no valid outbound found") + // Builder errors can contain credentials or whole source values. + return nil, errors.New("no valid outbound found") } -func parseShareCandidates(links string, allowBase64 bool) (*conf.Config, int, error) { +func parseShareCandidates(links string, allowBase64 bool) (*conf.Config, error) { text := strings.TrimSpace(FixWindowsReturn(links)) config := &conf.Config{} if strings.HasPrefix(text, "{") { @@ -60,7 +47,7 @@ func parseShareCandidates(links string, allowBase64 bool) (*conf.Config, int, er Outbounds []json.RawMessage `json:"outbounds"` } if err := json.Unmarshal([]byte(text), &document); err != nil || document.Outbounds == nil { - return nil, 0, errors.New("invalid share JSON outbounds") + return nil, errors.New("invalid share JSON outbounds") } for _, raw := range document.Outbounds { var outbound conf.OutboundDetourConfig @@ -68,19 +55,16 @@ func parseShareCandidates(links string, allowBase64 bool) (*conf.Config, int, er config.OutboundConfigs = append(config.OutboundConfigs, outbound) } } - return config, len(document.Outbounds), nil + return config, nil } if hasShareSchemeLine(text) { - candidates := 0 for raw := range strings.SplitSeq(text, "\n") { line := strings.TrimSpace(raw) - // Subscription comments/headers are not node candidates. A URI-like - // row is one candidate, including an unsupported or malformed URI. + // Ignore subscription comments and text headers. scheme, _, found := strings.Cut(line, "://") if !found || strings.ContainsAny(scheme, " \t#") { continue } - candidates++ parsed, err := url.Parse(line) if err != nil { continue @@ -90,7 +74,7 @@ func parseShareCandidates(links string, allowBase64 bool) (*conf.Config, int, er config.OutboundConfigs = append(config.OutboundConfigs, *outbound) } } - return config, candidates, nil + return config, nil } if allowBase64 { if decoded, err := decodeBase64Text(text); err == nil { @@ -102,7 +86,7 @@ func parseShareCandidates(links string, allowBase64 bool) (*conf.Config, int, er Proxies []yaml.Node `yaml:"proxies"` } if err := yaml.Unmarshal([]byte(text), &document); err != nil || document.Proxies == nil { - return nil, 0, errors.New("invalid share YAML proxies") + return nil, errors.New("invalid share YAML proxies") } for _, node := range document.Proxies { var proxy ClashProxy @@ -114,7 +98,7 @@ func parseShareCandidates(links string, allowBase64 bool) (*conf.Config, int, er config.OutboundConfigs = append(config.OutboundConfigs, *outbound) } } - return config, len(document.Proxies), nil + return config, nil } - return nil, 0, errors.New("unsupported share format") + return nil, errors.New("unsupported share format") } diff --git a/share/convert_share_test.go b/share/convert_share_test.go index 7a96fc91..5848b2b8 100644 --- a/share/convert_share_test.go +++ b/share/convert_share_test.go @@ -7,10 +7,10 @@ import ( "testing" ) -const statsValidOutbound = `{"protocol":"vless","tag":"Keep","settings":{"address":"example.com","port":443,"id":"12345678-abcd-abcd-abcd-123456789abc","encryption":"none"},"streamSettings":{"security":"tls","tlsSettings":{"serverName":"example.com"}}}` +const validShareOutbound = `{"protocol":"vless","tag":"Keep","settings":{"address":"example.com","port":443,"id":"12345678-abcd-abcd-abcd-123456789abc","encryption":"none"},"streamSettings":{"security":"tls","tlsSettings":{"serverName":"example.com"}}}` -func TestConvertShareLinksCountActualCandidates(t *testing.T) { - jsonText := `{"outbounds":[` + statsValidOutbound + `,{"protocol":"freedom"},{"protocol":42},null,{"protocol":"vless","settings":{"id":"invalid"}}]}` +func TestConvertShareLinksSkipsInvalidCandidates(t *testing.T) { + jsonText := `{"outbounds":[` + validShareOutbound + `,{"protocol":"freedom"},{"protocol":42},null,{"protocol":"vless","settings":{"id":"invalid"}}]}` yamlText := `proxies: - {name: Keep, type: vless, server: example.com, port: 443, uuid: 12345678-abcd-abcd-abcd-123456789abc, tls: true, servername: example.com} - {name: Unsupported, type: unknown} @@ -19,27 +19,26 @@ func TestConvertShareLinksCountActualCandidates(t *testing.T) { - null ` for _, test := range []struct { - name, text string - usable, failed int + name, text string }{ - {"links with headers", "Subscription export\n# comment\n\n" + ageTestShareLink + "\nvless://bad@example.com:443?encryption=none\nunknown://example.com", 1, 2}, - {"JSON elements", jsonText, 1, 4}, - {"YAML elements", yamlText, 1, 4}, - {"base64 JSON", base64.StdEncoding.EncodeToString([]byte(jsonText)), 1, 4}, - {"base64 YAML", base64.RawURLEncoding.EncodeToString([]byte(yamlText)), 1, 4}, - {"base64 links", base64.StdEncoding.EncodeToString([]byte(ageTestShareLink + "\nvless://bad@example.com:443")), 1, 1}, + {"links with headers", "Subscription export\n# comment\n\n" + ageTestShareLink + "\nvless://bad@example.com:443?encryption=none\nunknown://example.com"}, + {"JSON elements", jsonText}, + {"YAML elements", yamlText}, + {"base64 JSON", base64.StdEncoding.EncodeToString([]byte(jsonText))}, + {"base64 YAML", base64.RawURLEncoding.EncodeToString([]byte(yamlText))}, + {"base64 links", base64.StdEncoding.EncodeToString([]byte(ageTestShareLink + "\nvless://bad@example.com:443"))}, } { t.Run(test.name, func(t *testing.T) { result, err := ConvertShareLinksToXrayJson(test.text, "") if err != nil { t.Fatal(err) } - assertShareStats(t, result, test.usable, test.failed) + assertShareOutbounds(t, result, 1) }) } } -func TestConvertShareLinksAllInvalidRetainsCountsWithoutSource(t *testing.T) { +func TestConvertShareLinksAllInvalidReturnsNoData(t *testing.T) { for _, input := range []string{ `{"outbounds":[{"protocol":"freedom"}]}`, "vless://secret-not-a-uuid@example.com:443?encryption=none", @@ -49,16 +48,20 @@ func TestConvertShareLinksAllInvalidRetainsCountsWithoutSource(t *testing.T) { if err == nil || err.Error() != "no valid outbound found" { t.Fatalf("error = %v", err) } - assertShareStats(t, result, 0, 1) + if result != nil { + t.Fatalf("unexpected data: %s", result) + } } result, err := ConvertShareLinksToXrayJson(`{"outbounds":[]}`, "") if err == nil { t.Fatal("empty array succeeded") } - assertShareStats(t, result, 0, 0) + if result != nil { + t.Fatalf("unexpected data: %s", result) + } } -func TestConvertShareLinksMalformedDocumentHasNoCounts(t *testing.T) { +func TestConvertShareLinksMalformedDocumentReturnsNoData(t *testing.T) { for _, input := range []string{ `{"outbounds":[`, `{"outbounds":"private-source"}`, `{"outbounds":null}`, "proxies: [", "proxies: private-source", "not a subscription", @@ -73,37 +76,31 @@ func TestConvertShareLinksMalformedDocumentHasNoCounts(t *testing.T) { } } -func TestConvertShareLinksAgeCountsInnerCandidatesAndRedactsErrors(t *testing.T) { +func TestConvertShareLinksAgeSkipsInvalidCandidatesAndRedactsErrors(t *testing.T) { pair, err := GenerateAgeKeyPair(AgeKeyTypeX25519) if err != nil { t.Fatal(err) } for _, input := range []string{ ageTestShareLink + "\nvless://secret-not-a-uuid@example.com:443", - `{"outbounds":[` + statsValidOutbound + `,{"protocol":false}]}`, + `{"outbounds":[` + validShareOutbound + `,{"protocol":false}]}`, } { result, err := ConvertShareLinksToXrayJson(encryptAgeForTest(t, pair, input), pair.SecretKey) if err != nil { t.Fatal(err) } - assertShareStats(t, result, 1, 1) + assertShareOutbounds(t, result, 1) } - for _, test := range []struct { - text string - hasCounts bool - }{ - {"vless://secret-not-a-uuid@example.com:443", true}, - {`{"outbounds":"private-source"}`, false}, + for _, input := range []string{ + "vless://secret-not-a-uuid@example.com:443", + `{"outbounds":"private-source"}`, } { - result, err := ConvertShareLinksToXrayJson(encryptAgeForTest(t, pair, test.text), pair.SecretKey) + result, err := ConvertShareLinksToXrayJson(encryptAgeForTest(t, pair, input), pair.SecretKey) if err != ErrAgePlaintextUnsupported { t.Fatalf("error = %v", err) } - if (result != nil) != test.hasCounts { - t.Fatalf("counts = %+v", result) - } if result != nil { - assertShareStats(t, result, 0, 1) + t.Fatalf("unexpected data: %s", result) } } result, err := ConvertShareLinksToXrayJson(encryptAgeForTest(t, pair, ageTestShareLink), "private-invalid-key") @@ -112,18 +109,15 @@ func TestConvertShareLinksAgeCountsInnerCandidatesAndRedactsErrors(t *testing.T) } } -func assertShareStats(t *testing.T, result *ConvertShareLinksResult, usable, failed int) { +func assertShareOutbounds(t *testing.T, result json.RawMessage, count int) { t.Helper() - if result == nil || result.UsableCount != usable || result.FailedCount != failed { - t.Fatalf("stats = %+v, want usable %d failed %d", result, usable, failed) - } var config struct { Outbounds []json.RawMessage `json:"outbounds"` } - if err := json.Unmarshal(result.Config, &config); err != nil { + if err := json.Unmarshal(result, &config); err != nil { t.Fatal(err) } - if config.Outbounds == nil || len(config.Outbounds) != usable { - t.Fatalf("config count = %d, want %d", len(config.Outbounds), usable) + if len(config.Outbounds) != count { + t.Fatalf("outbounds = %d, want %d", len(config.Outbounds), count) } } diff --git a/share/marshal_share.go b/share/marshal_share.go index 835f425f..9ffb5773 100644 --- a/share/marshal_share.go +++ b/share/marshal_share.go @@ -10,16 +10,16 @@ import ( "github.com/xtls/xray-core/infra/conf" ) -func marshalShareConfigJSON(config *conf.Config) (json.RawMessage, int, error) { +func marshalShareConfigJSON(config *conf.Config) (json.RawMessage, error) { if config == nil { - return nil, 0, fmt.Errorf("no valid outbound found") + return nil, fmt.Errorf("no valid outbound found") } outbounds := make([]map[string]any, 0, len(config.OutboundConfigs)) for _, outbound := range config.OutboundConfigs { source, err := marshalShareJSONObject(outbound) if err != nil { - return nil, 0, err + return nil, err } projected, supported := projectShareOutbound(source) if !supported { @@ -32,14 +32,14 @@ func marshalShareConfigJSON(config *conf.Config) (json.RawMessage, int, error) { } if len(outbounds) == 0 { - return nil, 0, fmt.Errorf("no valid outbound found") + return nil, fmt.Errorf("no valid outbound found") } raw, err := json.Marshal(map[string]any{"outbounds": outbounds}) if err != nil { - return nil, 0, fmt.Errorf("failed to marshal share config: %w", err) + return nil, fmt.Errorf("failed to marshal share config: %w", err) } - return raw, len(outbounds), nil + return raw, nil } func marshalShareJSONObject(value any) (map[string]any, error) { diff --git a/share/marshal_share_test.go b/share/marshal_share_test.go index 9b8dff6a..ffdecd62 100644 --- a/share/marshal_share_test.go +++ b/share/marshal_share_test.go @@ -53,7 +53,7 @@ func TestMarshalShareConfigProjectsSupportedFields(t *testing.T) { var config conf.Config require.NoError(t, json.Unmarshal([]byte(input), &config)) - raw, _, err := marshalShareConfigJSON(&config) + raw, err := marshalShareConfigJSON(&config) require.NoError(t, err) const expected = `{ @@ -78,7 +78,7 @@ func TestMarshalShareConfigPreservesHysteriaPortHopping(t *testing.T) { "hy2://auth@host:443?up=50+mbps&down=100+mbps&ports=20000-40000&hop-interval=30&sni=example.com&fp=chrome", ) require.NoError(t, err) - raw, _, err := marshalShareConfigJSON(config) + raw, err := marshalShareConfigJSON(config) require.NoError(t, err) var document map[string]any @@ -98,7 +98,7 @@ func TestMarshalShareConfigKeepsKCPWithoutSettings(t *testing.T) { link := "vmess://" + base64.StdEncoding.EncodeToString([]byte(qr)) config, err := convertShareLinksForTest(link) require.NoError(t, err) - raw, _, err := marshalShareConfigJSON(config) + raw, err := marshalShareConfigJSON(config) require.NoError(t, err) assert.Contains(t, string(raw), `"network":"kcp"`) @@ -121,7 +121,7 @@ func TestMarshalShareConfigSupportedProtocolsBuild(t *testing.T) { t.Run(protocol, func(t *testing.T) { config, err := convertShareLinksForTest(link) require.NoError(t, err) - raw, _, err := marshalShareConfigJSON(config) + raw, err := marshalShareConfigJSON(config) require.NoError(t, err) requireProjectedOutboundsBuild(t, raw) }) diff --git a/share/test_helpers_test.go b/share/test_helpers_test.go index 139ec322..0d2f015e 100644 --- a/share/test_helpers_test.go +++ b/share/test_helpers_test.go @@ -15,8 +15,7 @@ func convertShareLinksForTest(links string) (*conf.Config, error) { } func parseShareCandidatesForTest(links string) (*conf.Config, error) { - config, _, err := parseShareCandidates(links, true) - return config, err + return parseShareCandidates(links, true) } func convertShareLinksWithKeyForTest(links, secretKey string) (*conf.Config, error) { @@ -25,7 +24,7 @@ func convertShareLinksWithKeyForTest(links, secretKey string) (*conf.Config, err return nil, err } var config conf.Config - if err := json.Unmarshal(result.Config, &config); err != nil { + if err := json.Unmarshal(result, &config); err != nil { return nil, err } return &config, nil diff --git a/xray/runtime.go b/xray/runtime.go deleted file mode 100644 index 24483d6b..00000000 --- a/xray/runtime.go +++ /dev/null @@ -1,263 +0,0 @@ -package xray - -import ( - "context" - "crypto/rand" - "encoding/hex" - "encoding/json" - "errors" - "io" - "net" - "net/http" - "os" - "path/filepath" - "strings" - "time" - - "github.com/xtls/xray-core/core" - "github.com/xtls/xray-core/features/inbound" - "github.com/xtls/xray-core/features/policy" - "github.com/xtls/xray-core/features/stats" -) - -// RuntimeConfig is host metadata, never part of the Xray configuration. -type RuntimeConfig struct { - StatePath string `json:"statePath"` - InboundTag string `json:"inboundTag"` - Listen string `json:"listen,omitempty"` - Token string `json:"token,omitempty"` -} - -type runtimeSession struct { - ID string `json:"id"` - StartedAtMs int64 `json:"startedAtMs"` - EndedAtMs int64 `json:"endedAtMs"` - Uplink int64 `json:"uplink"` - Downlink int64 `json:"downlink"` -} - -// runtimeSnapshot contains only this session's raw inbound counter values. -// It contains no application totals, configuration, credentials, or control API. -type runtimeSnapshot struct { - Version int `json:"version"` - Session runtimeSession `json:"session"` - Available bool `json:"available"` - SampledAtMs int64 `json:"sampledAtMs"` - SavedAtMs int64 `json:"savedAtMs"` - Error string `json:"error"` -} - -type managedRuntime struct { - config RuntimeConfig - snapshot runtimeSnapshot - manager stats.Manager - stateLock *os.File - stopTicker, tickDone chan struct{} - httpServer *http.Server - httpListener net.Listener -} - -func prepareRuntime(config *RuntimeConfig) (*managedRuntime, error) { - if config == nil { - return nil, nil - } - if !filepath.IsAbs(config.StatePath) || strings.TrimSpace(config.InboundTag) == "" || len(config.InboundTag) > 256 { - return nil, errors.New("runtime requires an absolute statePath and inboundTag") - } - if err := validateRuntimeHTTP(config); err != nil { - return nil, err - } - var id [16]byte - if _, err := rand.Read(id[:]); err != nil { - return nil, err - } - stateLock, err := lockRuntimeState(config.StatePath) - if err != nil { - return nil, err - } - return &managedRuntime{ - config: *config, stateLock: stateLock, - snapshot: runtimeSnapshot{ - Version: 1, - Session: runtimeSession{ID: hex.EncodeToString(id[:]), StartedAtMs: time.Now().UnixMilli()}, - }, - }, nil -} - -func lockRuntimeState(path string) (*os.File, error) { - if !filepath.IsAbs(path) { - return nil, errors.New("runtime statePath must be absolute") - } - lockPath := path + ".lock" - if info, err := os.Lstat(lockPath); err == nil && !info.Mode().IsRegular() || err != nil && !errors.Is(err, os.ErrNotExist) { - return nil, errors.New("runtime state lock is unavailable") - } - file, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0600) - if err != nil { - return nil, errors.New("runtime state lock is unavailable") - } - if err := lockRuntimeFile(file); err != nil { - _ = file.Close() - return nil, errors.New("runtime state is in use") - } - return file, nil -} - -func (r *managedRuntime) attach(server *core.Instance) error { - manager, ok := server.GetFeature(stats.ManagerType()).(stats.Manager) - policies, policyOK := server.GetFeature(policy.ManagerType()).(policy.Manager) - inbounds, inboundOK := server.GetFeature(inbound.ManagerType()).(inbound.Manager) - if !ok || !policyOK || !inboundOK || !policies.ForSystem().Stats.InboundUplink || !policies.ForSystem().Stats.InboundDownlink { - return errors.New("runtime requires inbound uplink and downlink statistics") - } - if _, err := inbounds.GetHandler(context.Background(), r.config.InboundTag); err != nil { - return errors.New("runtime inboundTag does not exist") - } - // Registering zero counters handles an idle inbound without treating disabled statistics as zero. - for _, direction := range []string{"uplink", "downlink"} { - counter, err := manager.GetOrRegisterCounter(r.counterName(direction)) - if err != nil || counter == nil { - return errors.New("runtime requires a statistics manager") - } - } - r.manager = manager - return nil -} - -func (r *managedRuntime) counterName(direction string) string { - return "inbound>>>" + r.config.InboundTag + ">>>traffic>>>" + direction -} - -func (r *managedRuntime) start() error { - listener, err := r.listenHTTP() - if err != nil { - return err - } - r.sample() - if err := r.save(); err != nil { - if listener != nil { - _ = listener.Close() - } - return err - } - if listener != nil { - r.serveHTTP(listener) - } - r.stopTicker, r.tickDone = make(chan struct{}), make(chan struct{}) - go func() { - defer close(r.tickDone) - ticker := time.NewTicker(30 * time.Second) - defer ticker.Stop() - for { - select { - case <-ticker.C: - r.sample() - _ = r.save() - case <-r.stopTicker: - return - } - } - }() - return nil -} - -func (r *managedRuntime) sample() { - r.snapshot.SampledAtMs = time.Now().UnixMilli() - up, down := r.manager.GetCounter(r.counterName("uplink")), r.manager.GetCounter(r.counterName("downlink")) - r.snapshot.Available = up != nil && down != nil - if r.snapshot.Available { - u, d := up.Value(), down.Value() - r.snapshot.Available = u >= 0 && d >= 0 - if r.snapshot.Available { - // Preserve raw Value semantics, including a nonnegative counter rollback. - // Never reset counters or synthesize deltas/application totals here. - r.snapshot.Session.Uplink, r.snapshot.Session.Downlink = u, d - } - } -} - -func (r *managedRuntime) save() error { - candidate := r.snapshot - candidate.SavedAtMs = time.Now().UnixMilli() - candidate.Error = "" - if !candidate.Available { - candidate.Error = "counters_unavailable" - } - if err := writeRuntimeState(r.config.StatePath, candidate); err != nil { - r.snapshot.Error = "state_write_failed" - return errors.New("runtime state_write_failed") - } - r.snapshot = candidate - return nil -} - -func (r *managedRuntime) stop() error { - if r.stopTicker == nil { - return nil - } - var httpErr error - if r.httpServer != nil { - httpErr = r.httpServer.Close() - // Close also covers an immediate stop before Serve registers the listener. - _ = r.httpListener.Close() - r.httpServer = nil - r.httpListener = nil - } - close(r.stopTicker) - <-r.tickDone - r.stopTicker = nil - // The ticker has exited, so the final sample/write cannot race a periodic one. - r.sample() - r.snapshot.Session.EndedAtMs = time.Now().UnixMilli() - return errors.Join(httpErr, r.save()) -} - -func readRuntimeState(path string) (runtimeSnapshot, error) { - var state runtimeSnapshot - info, err := os.Lstat(path) - if err != nil || !info.Mode().IsRegular() || info.Size() > 64*1024 { - return state, errors.New("runtime state is not a readable regular file") - } - file, err := os.Open(path) - if err != nil { - return state, err - } - defer file.Close() - decoder := json.NewDecoder(io.LimitReader(file, 64*1024)) - decoder.DisallowUnknownFields() - if err := decoder.Decode(&state); err != nil { - return state, errors.New("runtime state is invalid") - } - var extra any - id, idErr := hex.DecodeString(state.Session.ID) - if decoder.Decode(&extra) != io.EOF || state.Version != 1 || idErr != nil || len(id) != 16 || - state.Session.ID != strings.ToLower(state.Session.ID) || - state.Session.StartedAtMs <= 0 || state.Session.EndedAtMs < 0 || state.SampledAtMs <= 0 || state.SavedAtMs <= 0 || - state.Session.Uplink < 0 || state.Session.Downlink < 0 { - return state, errors.New("runtime state is invalid") - } - return state, nil -} - -func writeRuntimeState(path string, state runtimeSnapshot) error { - if info, err := os.Lstat(path); err == nil && !info.Mode().IsRegular() || err != nil && !errors.Is(err, os.ErrNotExist) { - return errors.New("runtime state is not a regular file") - } - data, err := json.Marshal(state) - if err != nil { - return err - } - file, err := os.CreateTemp(filepath.Dir(path), ".runtime-*") - if err != nil { - return err - } - defer os.Remove(file.Name()) - if _, err = file.Write(data); err == nil { - err = file.Sync() - } - err = errors.Join(err, file.Close()) - if err != nil { - return err - } - return replaceRuntimeState(file.Name(), path) -} diff --git a/xray/runtime_file.go b/xray/runtime_file.go deleted file mode 100644 index fa3029a5..00000000 --- a/xray/runtime_file.go +++ /dev/null @@ -1,26 +0,0 @@ -//go:build !windows - -package xray - -import ( - "os" - "path/filepath" - - "golang.org/x/sys/unix" -) - -func lockRuntimeFile(file *os.File) error { - return unix.Flock(int(file.Fd()), unix.LOCK_EX|unix.LOCK_NB) -} - -func replaceRuntimeState(source, target string) error { - directory, err := os.Open(filepath.Dir(target)) - if err != nil { - return err - } - defer directory.Close() - if err := os.Rename(source, target); err != nil { - return err - } - return directory.Sync() -} diff --git a/xray/runtime_file_windows.go b/xray/runtime_file_windows.go deleted file mode 100644 index 29791af3..00000000 --- a/xray/runtime_file_windows.go +++ /dev/null @@ -1,23 +0,0 @@ -package xray - -import ( - "os" - - "golang.org/x/sys/windows" -) - -func lockRuntimeFile(file *os.File) error { - return windows.LockFileEx(windows.Handle(file.Fd()), windows.LOCKFILE_EXCLUSIVE_LOCK|windows.LOCKFILE_FAIL_IMMEDIATELY, 0, 1, 0, &windows.Overlapped{}) -} - -func replaceRuntimeState(source, target string) error { - from, err := windows.UTF16PtrFromString(source) - if err != nil { - return err - } - to, err := windows.UTF16PtrFromString(target) - if err != nil { - return err - } - return windows.MoveFileEx(from, to, windows.MOVEFILE_REPLACE_EXISTING|windows.MOVEFILE_WRITE_THROUGH) -} diff --git a/xray/runtime_http.go b/xray/runtime_http.go deleted file mode 100644 index 86d9a9d8..00000000 --- a/xray/runtime_http.go +++ /dev/null @@ -1,81 +0,0 @@ -package xray - -import ( - "crypto/subtle" - "encoding/hex" - "encoding/json" - "errors" - "net" - "net/http" - "strconv" - "strings" - "time" -) - -func validRuntimeToken(value string) bool { - decoded, err := hex.DecodeString(value) - return err == nil && len(decoded) == 16 && value == strings.ToLower(value) -} - -func validateRuntimeHTTP(config *RuntimeConfig) error { - if config.Listen == "" && config.Token == "" { - return nil - } - host, portText, err := net.SplitHostPort(config.Listen) - port, portErr := strconv.Atoi(portText) - if err != nil || host != "127.0.0.1" || portErr != nil || port < 1 || port > 65535 || - strconv.Itoa(port) != portText || !validRuntimeToken(config.Token) { - return errors.New("runtime HTTP requires listen 127.0.0.1:1..65535 and a 32-character lowercase hex token") - } - return nil -} - -func (r *managedRuntime) listenHTTP() (net.Listener, error) { - if r.config.Listen == "" { - return nil, nil - } - listener, err := net.Listen("tcp4", r.config.Listen) - if err != nil { - return nil, errors.New("runtime HTTP listener is unavailable") - } - return listener, nil -} - -func (r *managedRuntime) serveHTTP(listener net.Listener) { - r.httpListener = listener - r.httpServer = &http.Server{ - Handler: http.HandlerFunc(r.handleHTTP), - ReadHeaderTimeout: 2 * time.Second, - ReadTimeout: 5 * time.Second, - WriteTimeout: 5 * time.Second, - IdleTimeout: 30 * time.Second, - MaxHeaderBytes: 8 * 1024, - } - server := r.httpServer - go func() { _ = server.Serve(listener) }() -} - -func (r *managedRuntime) handleHTTP(w http.ResponseWriter, request *http.Request) { - w.Header().Set("Cache-Control", "no-store") - if subtle.ConstantTimeCompare([]byte(request.Header.Get("Authorization")), []byte("Bearer "+r.config.Token)) != 1 { - w.Header().Set("WWW-Authenticate", "Bearer") - http.Error(w, "unauthorized", http.StatusUnauthorized) - return - } - if request.URL.Path != "/runtime" { - http.NotFound(w, request) - return - } - if request.Method != http.MethodGet { - w.Header().Set("Allow", http.MethodGet) - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - snapshot, err := readRuntimeState(r.config.StatePath) - if err != nil { - http.Error(w, "runtime snapshot unavailable", http.StatusServiceUnavailable) - return - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(snapshot) -} diff --git a/xray/runtime_http_test.go b/xray/runtime_http_test.go deleted file mode 100644 index 43df1378..00000000 --- a/xray/runtime_http_test.go +++ /dev/null @@ -1,189 +0,0 @@ -package xray - -import ( - "encoding/json" - "io" - "net" - "net/http" - "os" - "path/filepath" - "strings" - "sync" - "testing" - "time" -) - -func runtimeHTTPConfig(t *testing.T) RuntimeConfig { - t.Helper() - config := runtimeConfig(t) - listener, err := net.Listen("tcp4", "127.0.0.1:0") - if err != nil { - t.Fatal(err) - } - config.Listen, config.Token = listener.Addr().String(), strings.Repeat("a", 32) - _ = listener.Close() - return config -} - -func requestRuntime(t *testing.T, config RuntimeConfig, method, path, token string, status int) runtimeSnapshot { - t.Helper() - request, err := http.NewRequest(method, "http://"+config.Listen+path, nil) - if err != nil { - t.Fatal(err) - } - if token != "" { - request.Header.Set("Authorization", "Bearer "+token) - } - client := &http.Client{Timeout: 3 * time.Second} - response, err := client.Do(request) - if err != nil { - t.Fatal(err) - } - defer response.Body.Close() - data, err := io.ReadAll(response.Body) - if err != nil || response.StatusCode != status { - t.Fatalf("runtime HTTP status %d, want %d: %s %v", response.StatusCode, status, data, err) - } - if response.Header.Get("Cache-Control") != "no-store" || response.Header.Get("Access-Control-Allow-Origin") != "" { - t.Fatal("runtime HTTP must disable caching without enabling CORS") - } - if strings.Contains(string(data), config.StatePath) || strings.Contains(string(data), config.Token) { - t.Fatal("runtime HTTP exposed host metadata") - } - var snapshot runtimeSnapshot - if status == http.StatusOK { - if response.Header.Get("Content-Type") != "application/json" || json.Unmarshal(data, &snapshot) != nil || snapshot.Version != 1 { - t.Fatalf("invalid runtime response: %s", data) - } - } - return snapshot -} - -func TestRuntimeHTTPAuthenticationCurrentSessionAndStop(t *testing.T) { - config := runtimeHTTPConfig(t) - runtime, up, down := runtimeFixture(t, config) - up.Add(17) - down.Add(23) - if err := runtime.start(); err != nil { - t.Fatal(err) - } - requestRuntime(t, config, http.MethodGet, "/runtime", "", http.StatusUnauthorized) - requestRuntime(t, config, http.MethodGet, "/runtime", strings.Repeat("b", 32), http.StatusUnauthorized) - current := requestRuntime(t, config, http.MethodGet, "/runtime", config.Token, http.StatusOK) - if current.Session.Uplink != 17 || current.Session.Downlink != 23 { - t.Fatalf("wrong current snapshot: %+v", current) - } - up.Add(100) - if saved := requestRuntime(t, config, http.MethodGet, "/runtime", config.Token, http.StatusOK); saved != current || up.Value() != 117 { - t.Fatal("HTTP must read the saved snapshot without sampling or resetting metrics") - } - requestRuntime(t, config, http.MethodPost, "/runtime", config.Token, http.StatusMethodNotAllowed) - requestRuntime(t, config, http.MethodGet, "/runtime/ack", config.Token, http.StatusNotFound) - requestRuntime(t, config, http.MethodPost, "/runtime/ack", config.Token, http.StatusNotFound) - requestRuntime(t, config, http.MethodGet, "/control", config.Token, http.StatusNotFound) - if err := os.Remove(config.StatePath); err != nil { - t.Fatal(err) - } - requestRuntime(t, config, http.MethodGet, "/runtime", config.Token, http.StatusServiceUnavailable) - runtime.sample() - if err := runtime.save(); err != nil { - t.Fatal(err) - } - if err := runtime.stop(); err != nil { - t.Fatal(err) - } - stopped := savedRuntime(t, config.StatePath) - if stopped.Session.Uplink != 117 || stopped.Session.EndedAtMs == 0 { - t.Fatal("HTTP shutdown lost the final saved sample") - } - connection, err := net.DialTimeout("tcp4", config.Listen, time.Second) - if err == nil { - _ = connection.Close() - t.Fatal("runtime HTTP listener survived stop") - } -} - -func TestRuntimeHTTPRejectsCurrentSnapshotSymlink(t *testing.T) { - config := runtimeHTTPConfig(t) - runtime, _, _ := runtimeFixture(t, config) - if err := runtime.start(); err != nil { - t.Fatal(err) - } - state := savedRuntime(t, config.StatePath) - outside := filepath.Join(t.TempDir(), "runtime.json") - if err := writeRuntimeState(outside, state); err != nil { - t.Fatal(err) - } - if err := os.Remove(config.StatePath); err != nil { - t.Fatal(err) - } - if err := os.Symlink(outside, config.StatePath); err != nil { - t.Skipf("symbolic links unavailable: %v", err) - } - requestRuntime(t, config, http.MethodGet, "/runtime", config.Token, http.StatusServiceUnavailable) - if savedRuntime(t, outside) != state { - t.Fatal("HTTP changed a snapshot outside its state path") - } -} - -func TestRuntimeHTTPStartFailureClosesListener(t *testing.T) { - config := runtimeHTTPConfig(t) - runtime, _, _ := runtimeFixture(t, config) - occupied, err := net.Listen("tcp4", config.Listen) - if err != nil { - t.Fatal(err) - } - defer occupied.Close() - if err := runtime.start(); err == nil { - t.Fatal("occupied statistics port did not reject startup") - } - if _, err := os.Lstat(config.StatePath); !os.IsNotExist(err) { - t.Fatal("failed bind replaced the saved state") - } - _ = occupied.Close() - runtime.config.StatePath = filepath.Join(filepath.Dir(config.StatePath), "missing", "runtime.json") - if err := runtime.start(); err == nil { - t.Fatal("initial save failure did not reject startup") - } - listener, err := net.Listen("tcp4", config.Listen) - if err != nil { - t.Fatalf("initial save failure leaked the HTTP listener: %v", err) - } - _ = listener.Close() - runtime.config.StatePath = config.StatePath - if err := runtime.start(); err != nil { - t.Fatal(err) - } - if err := runtime.stop(); err != nil { - t.Fatal(err) - } - listener, err = net.Listen("tcp4", config.Listen) - if err != nil { - t.Fatalf("immediate stop leaked the HTTP listener: %v", err) - } - _ = listener.Close() -} - -func TestRuntimeHTTPConcurrentReads(t *testing.T) { - config := runtimeHTTPConfig(t) - runtime, up, _ := runtimeFixture(t, config) - if err := runtime.start(); err != nil { - t.Fatal(err) - } - var workers sync.WaitGroup - workers.Go(func() { - for range 30 { - up.Add(1) - runtime.sample() - if err := runtime.save(); err != nil { - t.Error(err) - } - } - }) - workers.Go(func() { - for range 30 { - requestRuntime(t, config, http.MethodGet, "/runtime", config.Token, http.StatusOK) - } - }) - workers.Wait() -} diff --git a/xray/runtime_test.go b/xray/runtime_test.go deleted file mode 100644 index a8206d2c..00000000 --- a/xray/runtime_test.go +++ /dev/null @@ -1,385 +0,0 @@ -package xray - -import ( - "bufio" - "context" - "encoding/json" - "fmt" - "net" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" - "time" - - appstats "github.com/xtls/xray-core/app/stats" - "github.com/xtls/xray-core/features/stats" -) - -func runtimeConfig(t *testing.T) RuntimeConfig { - t.Helper() - return RuntimeConfig{ - StatePath: filepath.Join(t.TempDir(), "runtime.json"), - InboundTag: "tunIn", - } -} - -func runtimeFixture(t *testing.T, config RuntimeConfig) (*managedRuntime, stats.Counter, stats.Counter) { - t.Helper() - runtime, err := prepareRuntime(&config) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { - _ = runtime.stop() - _ = runtime.stateLock.Close() - }) - manager, err := appstats.NewManager(context.Background(), &appstats.Config{}) - if err != nil { - t.Fatal(err) - } - runtime.manager = manager - up, _ := manager.RegisterCounter(runtime.counterName("uplink")) - down, _ := manager.RegisterCounter(runtime.counterName("downlink")) - return runtime, up, down -} - -func saveRuntimeSample(t *testing.T, runtime *managedRuntime) runtimeSnapshot { - t.Helper() - runtime.sample() - if err := runtime.save(); err != nil { - t.Fatal(err) - } - return savedRuntime(t, runtime.config.StatePath) -} - -func savedRuntime(t *testing.T, path string) runtimeSnapshot { - t.Helper() - snapshot, err := readRuntimeState(path) - if err != nil || snapshot.Version != 1 { - t.Fatalf("read snapshot: %+v %v", snapshot, err) - } - return snapshot -} - -func TestRuntimeStoresRawCountersWithoutResetOrTotals(t *testing.T) { - config := runtimeConfig(t) - runtime, up, down := runtimeFixture(t, config) - up.Add(100) - down.Add(200) - first := saveRuntimeSample(t, runtime) - repeated := saveRuntimeSample(t, runtime) - if first.Session.Uplink != 100 || first.Session.Downlink != 200 || first.Session != repeated.Session || up.Value() != 100 || down.Value() != 200 { - t.Fatalf("sampling duplicated or reset raw counters: %+v %+v", first, repeated) - } - // A nonnegative rollback remains a raw value, not a synthetic accumulated delta. - up.Set(2) - rollback := saveRuntimeSample(t, runtime) - up.Add(3) - resumed := saveRuntimeSample(t, runtime) - if rollback.Session.Uplink != 2 || resumed.Session.Uplink != 5 || !resumed.Available { - t.Fatalf("rollback changed raw counter semantics: %+v %+v", rollback, resumed) - } - up.Set(-1) - negative := saveRuntimeSample(t, runtime) - if negative.Available || negative.Error != "counters_unavailable" || negative.Session != resumed.Session { - t.Fatalf("negative counter corrupted the last valid sample: %+v", negative) - } - _ = runtime.manager.UnregisterCounter(runtime.counterName("uplink")) - missing := saveRuntimeSample(t, runtime) - if missing.Available || missing.Error != "counters_unavailable" || missing.Session != resumed.Session { - t.Fatalf("missing counter became a fabricated zero: %+v", missing) - } - if info, err := os.Stat(config.StatePath); err != nil || info.Mode().Perm() != 0600 { - t.Fatalf("private file mode: %v %v", info, err) - } - encoded, _ := json.Marshal(first) - var fields map[string]json.RawMessage - _ = json.Unmarshal(encoded, &fields) - if len(fields) != 6 || fields["ledger"] != nil || fields["totalUplink"] != nil || fields["resetGeneration"] != nil || strings.Contains(string(encoded), config.StatePath) { - t.Fatalf("unexpected runtime snapshot fields: %s", encoded) - } - metadata, _ := json.Marshal(config) - var metadataFields map[string]json.RawMessage - _ = json.Unmarshal(metadata, &metadataFields) - if len(metadataFields) != 2 || metadataFields["planId"] != nil || metadataFields["controlAddress"] != nil || metadataFields["controlToken"] != nil { - t.Fatalf("runtime metadata exposed a control surface: %s", metadata) - } -} - -func TestRuntimeReplacesPreviousSessionOnStart(t *testing.T) { - config := runtimeConfig(t) - runtime, up, down := runtimeFixture(t, config) - if err := runtime.start(); err != nil { - t.Fatal(err) - } - initial := savedRuntime(t, config.StatePath) - if initial.Session.Uplink != 0 || initial.Session.Downlink != 0 || !initial.Available || initial.Session.EndedAtMs != 0 { - t.Fatalf("new session was not saved at start: %+v", initial) - } - up.Add(17) - down.Add(23) - if err := runtime.stop(); err != nil { - t.Fatal(err) - } - stopped := savedRuntime(t, config.StatePath) - if stopped.Session.Uplink != 17 || stopped.Session.Downlink != 23 || stopped.Session.EndedAtMs == 0 { - t.Fatalf("stop did not save final raw counters: %+v", stopped) - } - _ = runtime.stateLock.Close() - // Preparation alone cannot overwrite the current snapshot. - for range 2 { - next, err := prepareRuntime(&config) - if err != nil { - t.Fatal(err) - } - _ = next.stateLock.Close() - if savedRuntime(t, config.StatePath) != stopped { - t.Fatal("preparation replaced the previous current snapshot") - } - } - next, _, _ := runtimeFixture(t, config) - if err := next.start(); err != nil { - t.Fatal(err) - } - current := savedRuntime(t, config.StatePath) - if current.Session.ID == stopped.Session.ID || current.Session.Uplink != 0 || current.Session.Downlink != 0 { - t.Fatalf("restart reused a session or inherited totals: %+v", current) - } - if _, err := os.Lstat(filepath.Join(filepath.Dir(config.StatePath), "runtime-sessions")); !os.IsNotExist(err) { - t.Fatal("restart created a runtime session archive") - } -} - -func TestRuntimeWriteFailuresPreserveSavedFile(t *testing.T) { - config := runtimeConfig(t) - runtime, up, _ := runtimeFixture(t, config) - up.Add(20) - committed := saveRuntimeSample(t, runtime) - runtime.config.StatePath = filepath.Join(filepath.Dir(config.StatePath), "missing", "runtime.json") - up.Add(5) - runtime.sample() - if err := runtime.save(); err == nil || runtime.snapshot.Error != "state_write_failed" || runtime.snapshot.SavedAtMs != committed.SavedAtMs { - t.Fatalf("failed save incorrectly advanced the watermark: %+v %v", runtime.snapshot, err) - } - if savedRuntime(t, config.StatePath) != committed { - t.Fatal("failed save changed the last saved file") - } - runtime.config.StatePath = config.StatePath - up.Add(8) - recovered := saveRuntimeSample(t, runtime) - if recovered.Session.Uplink != 33 || recovered.Error != "" { - t.Fatalf("retry synthesized or lost raw bytes: %+v", recovered) - } -} - -func TestRuntimeConfigAndStateBoundary(t *testing.T) { - config := runtimeConfig(t) - for _, mutate := range []func(*RuntimeConfig){ - func(c *RuntimeConfig) { c.StatePath = "relative.json" }, - func(c *RuntimeConfig) { c.InboundTag = "" }, - func(c *RuntimeConfig) { c.Listen = "127.0.0.1:12345" }, - func(c *RuntimeConfig) { c.Token = strings.Repeat("a", 32) }, - func(c *RuntimeConfig) { c.Listen, c.Token = "0.0.0.0:12345", strings.Repeat("a", 32) }, - func(c *RuntimeConfig) { c.Listen, c.Token = "localhost:12345", strings.Repeat("a", 32) }, - func(c *RuntimeConfig) { c.Listen, c.Token = "[::1]:12345", strings.Repeat("a", 32) }, - func(c *RuntimeConfig) { c.Listen, c.Token = "127.0.0.1:0", strings.Repeat("a", 32) }, - func(c *RuntimeConfig) { c.Listen, c.Token = "127.0.0.1:65536", strings.Repeat("a", 32) }, - func(c *RuntimeConfig) { c.Listen, c.Token = "127.0.0.1:12345", strings.Repeat("A", 32) }, - func(c *RuntimeConfig) { c.Listen, c.Token = "127.0.0.1:12345", "secret" }, - } { - invalid := config - mutate(&invalid) - if r, err := prepareRuntime(&invalid); err == nil { - _ = r.stateLock.Close() - t.Fatal("invalid runtime metadata accepted") - } - } - for _, text := range []string{ - `{`, `{"version":9}`, - `{"version":1,"session":{"id":"../../outside","startedAtMs":1},"sampledAtMs":1,"savedAtMs":1}`, - `{"version":1,"session":{"id":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","startedAtMs":1,"uplink":-1},"sampledAtMs":1,"savedAtMs":1}`, - `{"version":1,"session":{"id":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","planId":"old","startedAtMs":1},"sampledAtMs":1,"savedAtMs":1}`, - } { - if err := os.WriteFile(config.StatePath, []byte(text), 0600); err != nil { - t.Fatal(err) - } - if _, err := readRuntimeState(config.StatePath); err == nil { - t.Fatal("invalid saved session was accepted") - } - if saved, err := os.ReadFile(config.StatePath); err != nil || string(saved) != text { - t.Fatal("invalid saved session was overwritten") - } - } - runtime, err := prepareRuntime(&config) - if err != nil { - t.Fatalf("saved state unnecessarily blocked preparation: %v", err) - } - _ = runtime.stateLock.Close() -} - -func TestManagedRuntimeStartFailureAndStop(t *testing.T) { - t.Cleanup(func() { _ = StopXray() }) - config := runtimeHTTPConfig(t) - if err := RunXrayWithRuntime(minimalConfig, &config); err == nil || GetXrayState() { - t.Fatal("missing statistics were accepted") - } - listener, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatal(err) - } - defer listener.Close() - _, port, _ := net.SplitHostPort(listener.Addr().String()) - xrayJSON := fmt.Sprintf(`{"log":{"loglevel":"none"},"stats":{},"policy":{"system":{"statsInboundUplink":true,"statsInboundDownlink":true}},"inbounds":[{"tag":"tunIn","listen":"127.0.0.1","port":%s,"protocol":"socks","settings":{"udp":false}}],"outbounds":[{"protocol":"freedom","tag":"direct"}]}`, port) - if err := RunXrayWithRuntime(xrayJSON, &config); err == nil || GetXrayState() { - t.Fatal("occupied inbound port did not fail startup") - } - _ = listener.Close() - statisticsListener, err := net.Listen("tcp4", config.Listen) - if err != nil { - t.Fatal(err) - } - if err := RunXrayWithRuntime(xrayJSON, &config); err == nil || GetXrayState() { - t.Fatal("occupied statistics port did not close the constructed core") - } - _ = statisticsListener.Close() - validPath := config.StatePath - config.StatePath = filepath.Join(filepath.Dir(validPath), "missing", "runtime.json") - if err := RunXrayWithRuntime(xrayJSON, &config); err == nil || GetXrayState() { - t.Fatal("unwritable runtime directory did not fail startup") - } - config.StatePath = validPath - if err := RunXrayWithRuntime(xrayJSON, &config); err != nil { - t.Fatal(err) - } - snapshot := savedRuntime(t, config.StatePath) - if !snapshot.Available || snapshot.Session.Uplink != 0 || snapshot.Session.EndedAtMs != 0 { - t.Fatalf("idle statistics should be available zero: %+v", snapshot) - } - // Even a final persistence error must close the core and release the owner lock. - coreRuntime.config.StatePath = filepath.Join(filepath.Dir(validPath), "missing", "runtime.json") - if err := StopXray(); err == nil || GetXrayState() { - t.Fatalf("failed final save did not close core: %v", err) - } - statisticsListener, err = net.Listen("tcp4", config.Listen) - if err != nil { - t.Fatalf("failed final save leaked the statistics listener: %v", err) - } - _ = statisticsListener.Close() - next, err := prepareRuntime(&config) - if err != nil { - t.Fatalf("stop did not release ownership: %v", err) - } - _ = next.stateLock.Close() -} - -func TestRuntimePeriodicSaveAndSingleOwner(t *testing.T) { - config := runtimeConfig(t) - runtime, up, down := runtimeFixture(t, config) - if err := runtime.start(); err != nil { - t.Fatal(err) - } - initial := savedRuntime(t, config.StatePath) - if other, err := prepareRuntime(&config); err == nil || err.Error() != "runtime state is in use" { - if other != nil { - _ = other.stateLock.Close() - } - t.Fatalf("two writers acquired the same path: %v", err) - } - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - command := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestRuntimeChild$") - command.Env = append(os.Environ(), "LIBXRAY_TEST_RUNTIME_PATH="+config.StatePath, "LIBXRAY_TEST_RUNTIME_ACTION=lock") - if output, err := command.CombinedOutput(); err != nil { - t.Fatalf("cross-process owner lock failed: %v: %s", err, output) - } - up.Add(31) - down.Add(47) - // Exercise the real 30s timer without adding a production interval option. - deadline := time.Now().Add(35 * time.Second) - for time.Now().Before(deadline) { - snapshot := savedRuntime(t, config.StatePath) - if snapshot.Session.Uplink == 31 && snapshot.Session.Downlink == 47 && snapshot.SavedAtMs > initial.SavedAtMs { - return - } - time.Sleep(100 * time.Millisecond) - } - t.Fatal("host timer did not save counters without any UI/control request") -} - -func TestRuntimeKilledOwnerStateIsReplacedOnRestart(t *testing.T) { - config := runtimeConfig(t) - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - command := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestRuntimeChild$") - command.Env = append(os.Environ(), "LIBXRAY_TEST_RUNTIME_PATH="+config.StatePath, "LIBXRAY_TEST_RUNTIME_ACTION=kill") - pipe, err := command.StdoutPipe() - if err != nil { - t.Fatal(err) - } - if err := command.Start(); err != nil { - t.Fatal(err) - } - scanner := bufio.NewScanner(pipe) - ready := false - for scanner.Scan() { - if scanner.Text() == "READY" { - ready = true - break - } - } - if !ready { - _ = command.Process.Kill() - _ = command.Wait() - t.Fatalf("child did not reach unsaved tail: %v", scanner.Err()) - } - if err := command.Process.Kill(); err != nil { - t.Fatal(err) - } - if err := command.Wait(); err == nil { - t.Fatal("child was not forcibly terminated") - } - saved := savedRuntime(t, config.StatePath) - if saved.Session.Uplink != 17 || saved.Session.Downlink != 23 || saved.Session.EndedAtMs != 0 { - t.Fatalf("kill invented a final sample or ending: %+v", saved) - } - runtime, _, _ := runtimeFixture(t, config) - if err := runtime.start(); err != nil { - t.Fatal(err) - } - if current := savedRuntime(t, config.StatePath); current.Session.ID == saved.Session.ID || current.Session.Uplink != 0 || current.Session.Downlink != 0 { - t.Fatalf("restart reused killed owner's counters: %+v", current) - } - if _, err := os.Lstat(filepath.Join(filepath.Dir(config.StatePath), "runtime-sessions")); !os.IsNotExist(err) { - t.Fatal("restart archived the killed owner's saved state") - } -} - -func TestRuntimeChild(t *testing.T) { - path := os.Getenv("LIBXRAY_TEST_RUNTIME_PATH") - if path == "" { - return - } - config := RuntimeConfig{StatePath: path, InboundTag: "tunIn"} - if os.Getenv("LIBXRAY_TEST_RUNTIME_ACTION") == "lock" { - if other, err := prepareRuntime(&config); err == nil || err.Error() != "runtime state is in use" { - if other != nil { - _ = other.stateLock.Close() - } - t.Fatalf("another process acquired active session ownership: %v", err) - } - return - } - runtime, up, down := runtimeFixture(t, config) - if err := runtime.start(); err != nil { - t.Fatal(err) - } - up.Add(17) - down.Add(23) - saveRuntimeSample(t, runtime) - up.Add(101) - down.Add(103) - fmt.Fprintln(os.Stdout, "READY") - select {} -} diff --git a/xray/xray.go b/xray/xray.go index fc39bff1..f6fe078c 100644 --- a/xray/xray.go +++ b/xray/xray.go @@ -14,7 +14,6 @@ import ( var ( coreServerMu sync.Mutex coreServer *core.Instance - coreRuntime *managedRuntime ) var ErrAlreadyRunning = errors.New("xray is already running") @@ -35,53 +34,22 @@ func newXrayInstance(xrayJSON string) (*core.Instance, error) { // Run Xray instance. // xrayJSON is the serialized Xray JSON configuration. -func RunXray(xrayJSON string) (err error) { - return RunXrayWithRuntime(xrayJSON, nil) -} - -// RunXrayWithRuntime optionally saves this session's raw inbound counters. -func RunXrayWithRuntime(xrayJSON string, config *RuntimeConfig) (err error) { +func RunXray(xrayJSON string) error { coreServerMu.Lock() defer coreServerMu.Unlock() if coreServer != nil { return ErrAlreadyRunning } - runtime, err := prepareRuntime(config) - if err != nil { - return err - } - if runtime != nil { - defer func() { - if err != nil { - _ = runtime.stateLock.Close() - } - }() - } - memory.InitForceFree() server, err := newXrayInstance(xrayJSON) if err != nil { - return - } - if runtime != nil { - if err = runtime.attach(server); err != nil { - _ = server.Close() - return err - } + return err } - if err = server.Start(); err != nil { _ = server.Close() - return - } - if runtime != nil { - if err = runtime.start(); err != nil { - _ = server.Close() - return err - } + return err } coreServer = server - coreRuntime = runtime debug.FreeOSMemory() return nil @@ -99,13 +67,7 @@ func StopXray() error { coreServerMu.Lock() defer coreServerMu.Unlock() if coreServer != nil { - var runtimeErr error - if coreRuntime != nil { - defer coreRuntime.stateLock.Close() - runtimeErr = coreRuntime.stop() - coreRuntime = nil - } - err := errors.Join(runtimeErr, coreServer.Close()) + err := coreServer.Close() coreServer = nil if err != nil { return err From 50b95979f5db551bd273165cf469e5daaf791341 Mon Sep 17 00:00:00 2001 From: yiguodev <147401898+yiguodev@users.noreply.github.com> Date: Wed, 9 Sep 2026 12:29:01 +0800 Subject: [PATCH 18/38] Update Xray-core to v26.9.9 (#152) * Update Xray-core to v26.9.9 * refactor(share): remove Clash and Hysteria2 URI support --- README.md | 27 +- build/app/build.py | 4 +- go.mod | 51 +-- go.sum | 136 +++++--- invoke_test.go | 21 ++ readme/README.zh_CN.md | 23 +- share/clash_meta.go | 642 ----------------------------------- share/clash_meta_test.go | 374 -------------------- share/convert_share.go | 20 -- share/convert_share_test.go | 42 ++- share/generate_share.go | 86 ----- share/generate_share_test.go | 161 ++------- share/hysteria_mask.go | 58 ---- share/marshal_share.go | 18 - share/marshal_share_test.go | 31 +- share/parse_share.go | 74 ---- share/parse_share_test.go | 179 ---------- share/stream.go | 2 +- xray/ping_batch.go | 9 +- xray/ping_batch_test.go | 24 +- 20 files changed, 244 insertions(+), 1738 deletions(-) delete mode 100644 share/clash_meta.go delete mode 100644 share/clash_meta_test.go delete mode 100644 share/hysteria_mask.go diff --git a/README.md b/README.md index 617f42c7..d3d7db39 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ Compile script. It is recommended to always use this script to compile libXray. depends on git and go. -By default, the build script does not clone [Xray-core](https://github.com/XTLS/Xray-core). It uses Go modules and pins Xray-core to release tag `v26.7.28` through its pseudo-version. +By default, the build script does not clone [Xray-core](https://github.com/XTLS/Xray-core). It uses Go modules and pins Xray-core to release tag `v26.9.9` through its pseudo-version. Pass the optional `local` argument to use an existing local checkout at `../Xray-core` through a Go module `replace`. ### Usage @@ -308,19 +308,25 @@ Get free ports. libXray stores outbound names in `tag`. `sendThrough` keeps its native Xray meaning as the local bind address. -### clash_meta - -Parse Clash.Meta configuration. - ### generate_share -convert Xray Json to VMessAEAD/VLESS sharing protocol. +Convert Xray JSON to VMess AEAD / VLESS share links following +[Xray-core discussion #716](https://github.com/XTLS/Xray-core/discussions/716). +SS, SOCKS and Trojan share links are also supported. VMess always generates +an AEAD URI, not the legacy QR-code format. + +Outbounds without a supported share-link format are skipped. Conversion fails +if no share links can be generated. ### parse_share -convert VMessAEAD/VLESS sharing protocol to Xray Json. +Parse VMess AEAD / VLESS, SS, SOCKS and Trojan share links, plus legacy +`vmessQrCode` links, into Xray JSON. -convert VMessQRCode to Xray Json. +Xray JSON node input and Base64 / Age subscription wrappers remain supported. +Clash/Mihomo configurations and `hysteria2://` / `hy2://` URIs are not supported. +This restriction concerns share-link conversion, not Hysteria2 in native +Xray JSON configurations. #### Parsing result @@ -424,9 +430,8 @@ The top-level response succeeds when the batch itself was accepted. Each item has its own result; `delay` is `10000` for an error and `11000` for a timeout. `delay` is always present, including a successful zero-millisecond result. The result array has the same length and order as the input config array. -Outbound dependencies referenced by -`streamSettings.sockopt.dialerProxy` or `proxySettings.tag` are included -automatically. +Outbound dependencies referenced by `streamSettings.sockopt.dialerProxy` are +included automatically. Xray-core rejects the removed `proxySettings` field. `locationUrl` is optional and must be an absolute HTTP(S) URL. When omitted, no location request is made and no location fields are returned. When supplied, diff --git a/build/app/build.py b/build/app/build.py index d516d3d8..725bd0eb 100644 --- a/build/app/build.py +++ b/build/app/build.py @@ -9,8 +9,8 @@ LIBXRAY_MOD_NAME = "github.com/xtls/libxray" XRAY_CORE_MOD_NAME = "github.com/xtls/xray-core" -# Go modules resolve the Xray-core v26.7.28 release tag through this version. -DEFAULT_XRAY_CORE_VERSION = "v1.260327.1-0.20260728075948-5ca6f4b7d4dc" +# Go modules resolve the Xray-core v26.9.9 release tag through this version. +DEFAULT_XRAY_CORE_VERSION = "v1.260327.1-0.20260908222543-52a412d9e2f5" LOCAL_XRAY_CORE_DIR_NAME = "Xray-core" diff --git a/go.mod b/go.mod index 434c640d..11cdb6e1 100644 --- a/go.mod +++ b/go.mod @@ -4,36 +4,41 @@ go 1.27.1 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 + github.com/stretchr/testify v1.12.1 + github.com/xtls/xray-core v1.260327.1-0.20260908222543-52a412d9e2f5 + golang.org/x/mobile v0.0.0-20260908204917-8b95e45f8d3e + golang.org/x/sys v0.48.0 + google.golang.org/protobuf v1.36.12 ) require ( github.com/andybalholm/brotli v1.0.6 // indirect - github.com/apernet/quic-go v0.59.1-0.20260425001925-6c6cc9bcb716 // indirect - github.com/cloudflare/circl v1.6.4 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect + github.com/apernet/quic-go v0.61.1-0.20260806010916-184d081eef3e // indirect + github.com/cloudflare/circl v1.6.5 // indirect github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344 // indirect github.com/google/btree v1.1.2 // indirect + github.com/google/gopacket v1.1.19 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect + github.com/huin/goupnp v1.2.0 // indirect + github.com/jackpal/go-nat-pmp v1.0.2 // indirect github.com/juju/ratelimit v1.0.2 // indirect github.com/klauspost/compress v1.17.4 // indirect github.com/klauspost/cpuid/v2 v2.4.0 // indirect + github.com/koron/go-ssdp v0.0.4 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/libp2p/go-nat v1.0.1-0.20250821073202-01afc089f138 // indirect + github.com/libp2p/go-netroute v0.2.1 // indirect github.com/metacubex/hkdf v0.1.0 // indirect github.com/metacubex/hpke v0.1.0 // indirect github.com/metacubex/mlkem v0.1.0 // indirect - github.com/miekg/dns v1.1.72 // indirect + github.com/miekg/dns v1.1.73 // indirect github.com/pelletier/go-toml v1.9.5 // indirect - github.com/pion/dtls/v3 v3.1.4 // indirect + github.com/pion/dtls/v3 v3.1.5 // indirect github.com/pion/logging v0.2.4 // indirect - github.com/pion/stun/v3 v3.1.6 // indirect - github.com/pion/transport/v4 v4.0.2 // indirect + github.com/pion/stun/v3 v3.1.7 // indirect + github.com/pion/transport/v4 v4.1.0 // indirect github.com/pires/go-proxyproto v0.15.0 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af // indirect github.com/robfig/cron/v3 v3.0.1 // indirect @@ -42,22 +47,22 @@ require ( github.com/vishvananda/netlink v1.3.1 // indirect github.com/vishvananda/netns v0.0.5 // indirect github.com/wlynxg/anet v0.0.5 // indirect - github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f // indirect + github.com/xtls/reality v0.0.0-20260908062103-8cdf7bf9c7f0 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect - golang.org/x/crypto v0.54.0 // indirect + golang.org/x/crypto v0.57.0 // indirect golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect - golang.org/x/mobile v0.0.0-20260709172247-6129f5bee9d5 // indirect - 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/text v0.40.0 // indirect + golang.org/x/mod v0.41.0 // indirect + golang.org/x/net v0.59.0 // indirect + golang.org/x/sync v0.23.0 // indirect + golang.org/x/text v0.42.0 // indirect golang.org/x/time v0.14.0 // indirect - golang.org/x/tools v0.48.0 // indirect + golang.org/x/tools v0.50.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect golang.zx2c4.com/wireguard/windows v1.0.1 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect - google.golang.org/grpc v1.82.1 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/grpc v1.83.2 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect lukechampine.com/blake3 v1.4.1 // indirect diff --git a/go.sum b/go.sum index 98dee9c7..80aa10af 100644 --- a/go.sum +++ b/go.sum @@ -1,13 +1,12 @@ github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI= github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= -github.com/apernet/quic-go v0.59.1-0.20260425001925-6c6cc9bcb716 h1:J1O+xpLuJWkdYbw5JPGwBqIHs2J8tiEP7Py9lPqkN2I= -github.com/apernet/quic-go v0.59.1-0.20260425001925-6c6cc9bcb716/go.mod h1:Npbg8qBtAZlsAB3FWmqwlVh5jtVG6a4DlYsOylUpvzA= +github.com/apernet/quic-go v0.61.1-0.20260806010916-184d081eef3e h1:5mgtR5gwIgBKMiGI1QdXldZZ+SNor06Nbu1wCBulQBg= +github.com/apernet/quic-go v0.61.1-0.20260806010916-184d081eef3e/go.mod h1:x7qxEvX6MCVtDuBKHj3E+88+BtrbEMuAL5qGUKItjW8= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cloudflare/circl v1.6.4 h1:pOXuDTCEYyzydgUpQ0CQz3LsinKjiSk6nNP5Lt5K64U= -github.com/cloudflare/circl v1.6.4/go.mod h1:YxarevkLlbaHuWsxG6vmYNWBEsSp4pnp7j+4VljMavY= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/cloudflare/circl v1.6.5 h1:O64F26HEqNhznd/hrC5KZXVKYuKM2rx4deZDTc4ihQA= +github.com/cloudflare/circl v1.6.5/go.mod h1:h5LNyxAc5nTue9DS5jT+48en2PSDYt3zdGnz5OstK6c= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344 h1:Arcl6UOIS/kgO2nW3A65HN+7CMjSDP/gofXL4CZt1V4= github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344/go.mod h1:GIjDIg/heH5DOkXY3YJ/wNhfHsQHoXGjl8G8amsYQ1I= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= @@ -22,20 +21,32 @@ github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU= github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8= +github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/huin/goupnp v1.2.0 h1:uOKW26NG1hsSSbXIZ1IR7XP9Gjd1U8pnLaCMgntmkmY= +github.com/huin/goupnp v1.2.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8= +github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus= +github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc= github.com/juju/ratelimit v1.0.2 h1:sRxmtRiajbvrcLQT7S+JbqU0ntsb9W2yhSdNN8tWfaI= github.com/juju/ratelimit v1.0.2/go.mod h1:qapgC/Gy+xNh9UxzV13HGGl/6UXNN+ct+vwSgWNm/qk= github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4= github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw= github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU= +github.com/koron/go-ssdp v0.0.4 h1:1IDwrghSKYM7yLf7XCzbByg2sJ/JcNOZRXS2jczTwz0= +github.com/koron/go-ssdp v0.0.4/go.mod h1:oDXq+E5IL5q0U8uSBcoAXzTzInwy5lEgC91HoKtbmZk= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/libp2p/go-nat v1.0.1-0.20250821073202-01afc089f138 h1:YohuNPT/1k3VcThCQlBZ43PCPWPfMRS1zcxWBF2SLK8= +github.com/libp2p/go-nat v1.0.1-0.20250821073202-01afc089f138/go.mod h1:TXQg5tfSy+bUjnhT5728j5j/MBj7keIYqqZ1+8k/ui8= +github.com/libp2p/go-netroute v0.2.1 h1:V8kVrpD8GK0Riv15/7VN6RbUQ3URNZVosw7H2v9tksU= +github.com/libp2p/go-netroute v0.2.1/go.mod h1:hraioZr0fhBjG0ZRXJJ6Zj2IVEVNx6tDTFQfSmcq7mQ= github.com/metacubex/age v0.0.0-20260603010618-28d156b4ea78 h1:LqWr0vb9zDNuQS+jJd4fnRYk/SEI7KJ7TDe/L4WFK48= github.com/metacubex/age v0.0.0-20260603010618-28d156b4ea78/go.mod h1:BTBG/iVY7rg3qq5WdVCg0GFk58CSvCDSbjy8I7kEx/c= github.com/metacubex/hkdf v0.1.0 h1:fPA6VzXK8cU1foc/TOmGCDmSa7pZbxlnqhl3RNsthaA= @@ -44,22 +55,22 @@ github.com/metacubex/hpke v0.1.0 h1:gu2jUNhraehWi0P/z5HX2md3d7L1FhPQE6/Q0E9r9xQ= github.com/metacubex/hpke v0.1.0/go.mod h1:vfDm6gfgrwlXUxKDkWbcE44hXtmc1uxLDm2BcR11b3U= github.com/metacubex/mlkem v0.1.0 h1:wFClitonSFcmipzzQvax75beLQU+D7JuC+VK1RzSL8I= github.com/metacubex/mlkem v0.1.0/go.mod h1:amhaXZVeYNShuy9BILcR7P0gbeo/QLZsnqCdL8U2PDQ= -github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= -github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= +github.com/miekg/dns v1.1.73 h1:uhT8nJxmTrPJYClxVxTCX+CVn6qnzSiybRk72Z6DgrE= +github.com/miekg/dns v1.1.73/go.mod h1:RW2Obtfd5NZHvOFe3zYG0W8koWOQtAzyHaLo8vASBuQ= github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= -github.com/pion/dtls/v3 v3.1.4 h1:QhvtMflMfu9Kf0RcDC5BJBle4caPskByrKQR6uuYqpY= -github.com/pion/dtls/v3 v3.1.4/go.mod h1:cr/qotLISUw/9C1m83ZPNZtj9WnXkYLpfCptPqbkInc= +github.com/pion/dtls/v3 v3.1.5 h1:9xJtVsHwMYeSjPp5Hh1FTis4DchnQWtnOa5o+6ygqfc= +github.com/pion/dtls/v3 v3.1.5/go.mod h1:gz1K4jg6c+fq86oQMH4pilpCEOEPwmEr2jY+VcF/mkU= github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8= github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so= -github.com/pion/stun/v3 v3.1.6 h1:WnhsD0eHCiwCfKNkVx0VJJwr2Y3eV4Ueih3KJ+dfZy8= -github.com/pion/stun/v3 v3.1.6/go.mod h1:zRUghXSQU32Lx5orJsz3uYMkIihweXb3mu5gIns02fs= -github.com/pion/transport/v4 v4.0.2 h1:ifYlPqNwsy6aKQ9y8yzxXlHae5431ZrH2avkD/Rn6Tk= -github.com/pion/transport/v4 v4.0.2/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM= +github.com/pion/stun/v3 v3.1.7 h1:uRXMTlGLf89WgItGNyZ6aR5jMTX0NBbybXADpQCzn+E= +github.com/pion/stun/v3 v3.1.7/go.mod h1:Nq77RW4aRrSNrltf2ksUJLjxWeipj4lnlgdsYIxC8g8= +github.com/pion/transport/v4 v4.1.0 h1:8S+nF2reM2cJuqC6g78OVy2BBgmbdns+acx3jA97BvQ= +github.com/pion/transport/v4 v4.1.0/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM= github.com/pires/go-proxyproto v0.15.0 h1:dTshmNbFm/D+0+sbrxUuddPOZ5Y0B7c5NhtsBkm6LqI= github.com/pires/go-proxyproto v0.15.0/go.mod h1:OXsCrKwrK2tXS9YrI5tkHx5xaQlO8FH3lFW76orFh24= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0= +github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af h1:er2acxbi3N1nvEq6HXHUAR1nTWEJmQfqiGR8EVT9rfs= @@ -72,60 +83,75 @@ github.com/sagernet/sing v0.5.1 h1:mhL/MZVq0TjuvHcpYcFtmSD1BFOxZ/+8ofbNZcg1k1Y= github.com/sagernet/sing v0.5.1/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak= github.com/sagernet/sing-shadowsocks v0.2.7 h1:zaopR1tbHEw5Nk6FAkM05wCslV6ahVegEZaKMv9ipx8= github.com/sagernet/sing-shadowsocks v0.2.7/go.mod h1:0rIKJZBR65Qi0zwdKezt4s57y/Tl1ofkaq6NlkzVuyE= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0= github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4= github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= -github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f h1:iy2JRioxmUpoJ3SzbFPyTxHZMbR/rSHP7dOOgYaq1O8= -github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f/go.mod h1:DsJblcWDGt76+FVqBVwbwRhxyyNJsGV48gJLch0OOWI= -github.com/xtls/xray-core v1.260327.1-0.20260728075948-5ca6f4b7d4dc h1:fkOkmgHWbF2Q8MdV9VxrsyxRz4OndcrUXUkh1ANBTg0= -github.com/xtls/xray-core v1.260327.1-0.20260728075948-5ca6f4b7d4dc/go.mod h1:wukQoBGnQ6GaLTGuKwv8rCTgf80QxPj+6iznDZHQEWo= +github.com/xtls/reality v0.0.0-20260908062103-8cdf7bf9c7f0 h1:rb+fKQFhz+5I2PPuQsNYxI5mUU840XWYtRF0ZBjvkws= +github.com/xtls/reality v0.0.0-20260908062103-8cdf7bf9c7f0/go.mod h1:DsJblcWDGt76+FVqBVwbwRhxyyNJsGV48gJLch0OOWI= +github.com/xtls/xray-core v1.260327.1-0.20260908222543-52a412d9e2f5 h1:BsUC2sCXcdVCb09SUh1iWku0ci779t4bUIlKUor1ZRI= +github.com/xtls/xray-core v1.260327.1-0.20260908222543-52a412d9e2f5/go.mod h1:obbr2WDmr/cpQ/YLe1k0HTULnFXCO0rTWIeSrHoFk3o= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= -go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= -go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= -go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= -go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= -go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= -go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= -go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= -go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= -go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M= go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= -golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= -golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM= golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= -golang.org/x/mobile v0.0.0-20260709172247-6129f5bee9d5 h1:Mn1OzFmF0ZKX/ZayHz/UdnWHufPp1wlD9lZ5U8LRDFY= -golang.org/x/mobile v0.0.0-20260709172247-6129f5bee9d5/go.mod h1:YX+n47s+53POxN3dx9cIGxG3hGUm/lD64hvrRJFbcSA= -golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= -golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= -golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= -golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= -golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= -golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mobile v0.0.0-20260908204917-8b95e45f8d3e h1:zYnHsDmxfxKH/x9DjYNAdoIdMsP8enB1Ncb10b3pdHk= +golang.org/x/mobile v0.0.0-20260908204917-8b95e45f8d3e/go.mod h1:+g6JKqLjAprgkdZKuuBhkhIN9zvSktraeSABDN2M7p8= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= +golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= +golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= -golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= -golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= -golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= -golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU= +golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0= golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A= @@ -134,20 +160,18 @@ golang.zx2c4.com/wireguard/windows v1.0.1 h1:eOxiDVbywPC+ZQqvdCK7x+ZwWXKbYv50TtH golang.zx2c4.com/wireguard/windows v1.0.1/go.mod h1:+fbT3FFdX4zzYDLwJh5+HPEcNN/3HyNdzhNSVsQM+zs= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= -google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= +google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 h1:Lk6hARj5UPY47dBep70OD/TIMwikJ5fGUGX0Rm3Xigk= gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0/go.mod h1:QkHjoMIBaYtpVufgwv3keYAbln78mBoCuShZrPrer1Q= lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= diff --git a/invoke_test.go b/invoke_test.go index b0057d30..9b4a32bf 100644 --- a/invoke_test.go +++ b/invoke_test.go @@ -383,6 +383,27 @@ func TestInvokeConvertShareLinksFiltersBuildInvalidOutbounds(t *testing.T) { } } +func TestInvokeConvertShareLinksRejectsRemovedFormats(t *testing.T) { + for _, test := range []struct { + name, text string + }{ + {"Clash", "proxies:\n - {type: vless, server: example.com, port: 443, uuid: 12345678-abcd-abcd-abcd-123456789abc}"}, + {"Hysteria2", "hysteria2://password@example.com:443"}, + {"Hy2", "hy2://password@example.com:443"}, + } { + t.Run(test.name, func(t *testing.T) { + response := invokeForTest(t, LibXrayMethodConvertShareLinksToXrayJson, + ConvertShareLinksToXrayJsonRequest{Text: test.text}) + if response.Success || response.Err != "unsupported share format" { + t.Fatalf("success = %v, error = %q", response.Success, response.Err) + } + if string(response.Data) != "null" { + t.Fatalf("data = %s, want null", response.Data) + } + }) + } +} + func TestInvokeConvertShareLinksReturnsProjectedObject(t *testing.T) { const publicKey = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" link := "vless://12345678-abcd-abcd-abcd-123456789abc@example.com:443" + diff --git a/readme/README.zh_CN.md b/readme/README.zh_CN.md index fe59ac89..da892a1c 100644 --- a/readme/README.zh_CN.md +++ b/readme/README.zh_CN.md @@ -16,7 +16,7 @@ 依赖 git 和 go。 -默认情况下,编译脚本不会 clone [Xray-core](https://github.com/XTLS/Xray-core),而是通过 Go modules 的 pseudo-version 将 Xray-core 固定到发布版本 `v26.7.28`。 +默认情况下,编译脚本不会 clone [Xray-core](https://github.com/XTLS/Xray-core),而是通过 Go modules 的 pseudo-version 将 Xray-core 固定到发布版本 `v26.9.9`。 传入可选参数 `local` 时,会通过 Go module `replace` 改用已有的本地仓库 `../Xray-core`。 ### 使用方式 @@ -224,19 +224,22 @@ LibXray.resetDNS(); libXray 使用 `tag` 存储节点名称。`sendThrough` 保留 Xray 原生语义,用于指定本地绑定地址。 -### clash_meta - -解析 Clash.Meta 配置。 - ### generate_share -转换 Xray Json 为 VMessAEAD/VLESS 分享协议。 +按照 [Xray-core 讨论 #716](https://github.com/XTLS/Xray-core/discussions/716) +将 Xray JSON 转换为 VMess AEAD / VLESS 分享链接,同时支持 SS、SOCKS 和 Trojan。 +VMess 始终生成 AEAD URI,不生成旧版二维码格式。 + +没有对应分享格式的 outbound 会被跳过;无法生成任何分享链接时返回失败。 ### parse_share -转换 VMessAEAD/VLESS 分享协议为 Xray Json。 +将 VMess AEAD / VLESS、SS、SOCKS、Trojan 分享链接以及旧版 `vmessQrCode` +链接解析为 Xray JSON。 -转换 VMessQRCode 为 Xray Json。 +保留 Xray JSON 节点输入以及 Base64 / Age 订阅包装。Clash/Mihomo 配置和 +`hysteria2://` / `hy2://` URI 不受支持。 +这一限制仅针对分享链接转换,不影响原生 Xray JSON 配置中的 Hysteria2。 #### 解析结果 @@ -335,8 +338,8 @@ outbound,不做节点 hash 比较、去重或失败节点计数。 失败。`delay` 为 `10000` 表示错误,`11000` 表示超时。结果数组与输入配置数组 长度相同且顺序一致。 `delay` 始终输出,包含成功的 0 毫秒结果。 -通过 `streamSettings.sockopt.dialerProxy` 或 `proxySettings.tag` 引用的 -outbound 依赖会被自动包含。 +通过 `streamSettings.sockopt.dialerProxy` 引用的 outbound 依赖会被自动包含。 +已废弃的 `proxySettings` 字段由 Xray-core 拒绝。 `locationUrl` 为可选的绝对 HTTP(S) 地址。省略时不请求位置、不返回位置字段; 传入时,每个完成准备的配置先执行测速 HEAD,再执行位置 GET,两者使用同一个 diff --git a/share/clash_meta.go b/share/clash_meta.go deleted file mode 100644 index d746d060..00000000 --- a/share/clash_meta.go +++ /dev/null @@ -1,642 +0,0 @@ -package share - -import ( - "fmt" - - "github.com/xtls/xray-core/infra/conf" -) - -// https://github.com/MetaCubeX/mihomo/blob/Alpha/docs/config.yaml - -type ClashProxy struct { - Name string `yaml:"name,omitempty"` - Type string `yaml:"type,omitempty"` - Server string `yaml:"server,omitempty"` - Port uint16 `yaml:"port,omitempty"` - Uuid string `yaml:"uuid,omitempty"` - Cipher string `yaml:"cipher,omitempty"` - Username string `yaml:"username,omitempty"` - Password string `yaml:"password,omitempty"` - Encryption string `yaml:"encryption,omitempty"` - - Ports string `yaml:"ports,omitempty"` - HopInterval int32 `yaml:"hop-interval,omitempty"` - Up string `yaml:"up,omitempty"` - Down string `yaml:"down,omitempty"` - Obfs string `yaml:"obfs,omitempty"` - ObfsPassword string `yaml:"obfs-password,omitempty"` - - Udp bool `yaml:"udp,omitempty"` - UdpOverTcp bool `yaml:"udp-over-tcp,omitempty"` - - Tls bool `yaml:"tls,omitempty"` - Servername string `yaml:"servername,omitempty"` - Sni string `yaml:"sni,omitempty"` - Alpn []string `yaml:"alpn,omitempty"` - - Fingerprint string `yaml:"fingerprint,omitempty"` - ClientFingerprint string `yaml:"client-fingerprint,omitempty"` - Flow string `yaml:"flow,omitempty"` - RealityOpts *ClashProxyRealityOpts `yaml:"reality-opts,omitempty"` - - Network string `yaml:"network,omitempty"` - Plugin string `yaml:"plugin,omitempty"` - PluginOpts *ClashProxyPluginOpts `yaml:"plugin-opts,omitempty"` - EchOpts *ClashProxyEchOpts `yaml:"ech-opts,omitempty"` - WsOpts *ClashProxyWsOpts `yaml:"ws-opts,omitempty"` - GrpcOpts *ClashProxyGrpcOpts `yaml:"grpc-opts,omitempty"` - XhttpOpts *ClashProxyXhttpOpts `yaml:"xhttp-opts,omitempty"` -} - -type ClashProxyEchOpts struct { - Enable bool `yaml:"enable,omitempty"` - Config string `yaml:"config,omitempty"` -} - -type ClashProxyRealityOpts struct { - PublicKey string `yaml:"public-key,omitempty"` - ShortId string `yaml:"short-id,omitempty"` -} - -type ClashProxyPluginOpts struct { - Mode string `yaml:"mode,omitempty"` - Tls bool `yaml:"tls,omitempty"` - Fingerprint string `yaml:"fingerprint,omitempty"` - EchOpts *ClashProxyEchOpts `yaml:"ech-opts,omitempty"` - Host string `yaml:"host,omitempty"` - Path string `yaml:"path,omitempty"` - Mux bool `yaml:"mux,omitempty"` -} - -type ClashProxyWsOpts struct { - Path string `yaml:"path,omitempty"` - Headers *ClashProxyWsOptsHeaders `yaml:"headers,omitempty"` -} - -type ClashProxyWsOptsHeaders struct { - Host string `yaml:"Host,omitempty"` -} - -type ClashProxyGrpcOpts struct { - GrpcServiceName string `yaml:"grpc-service-name,omitempty"` -} - -type ClashProxyXhttpOpts struct { - Path string `yaml:"path,omitempty"` - Host string `yaml:"host,omitempty"` - Mode string `yaml:"mode,omitempty"` - Headers map[string]string `yaml:"headers,omitempty"` - NoGrpcHeader bool `yaml:"no-grpc-header,omitempty"` - XPaddingBytes string `yaml:"x-padding-bytes,omitempty"` - ScMaxEachPostBytes string `yaml:"sc-max-each-post-bytes,omitempty"` - ReuseSettings *ClashProxyXhttpOptsXMUX `yaml:"reuse-settings,omitempty"` - DownloadSettings *ClashProxyXhttpOptsDownloadSettings `yaml:"download-settings,omitempty"` -} - -type ClashProxyXhttpOptsXMUX struct { - MaxConnections string `yaml:"max-connections,omitempty"` - MaxConcurrency string `yaml:"max-concurrency,omitempty"` - CMaxReuseTimes string `yaml:"c-max-reuse-times,omitempty"` - HMaxRequestTimes string `yaml:"h-max-request-times,omitempty"` - HMaxReusableSecs string `yaml:"h-max-reusable-secs,omitempty"` -} - -type ClashProxyXhttpOptsDownloadSettings struct { - Path string `yaml:"path,omitempty"` - Host string `yaml:"host,omitempty"` - Mode string `yaml:"mode,omitempty"` - Headers map[string]string `yaml:"headers,omitempty"` - NoGrpcHeader bool `yaml:"no-grpc-header,omitempty"` - XPaddingBytes string `yaml:"x-padding-bytes,omitempty"` - ScMaxEachPostBytes string `yaml:"sc-max-each-post-bytes,omitempty"` - ReuseSettings *ClashProxyXhttpOptsXMUX `yaml:"reuse-settings,omitempty"` - // proxy part - Server string `yaml:"server,omitempty"` - Port uint16 `yaml:"port,omitempty"` - Tls bool `yaml:"tls,omitempty"` - Alpn []string `yaml:"alpn,omitempty"` - EchOpts *ClashProxyEchOpts `yaml:"ech-opts,omitempty"` - RealityOpts *ClashProxyRealityOpts `yaml:"reality-opts,omitempty"` - Fingerprint string `yaml:"fingerprint,omitempty"` - Servername string `yaml:"servername,omitempty"` - ClientFingerprint string `yaml:"client-fingerprint,omitempty"` -} - -func (proxy ClashProxy) outbound() (*conf.OutboundDetourConfig, error) { - switch proxy.Type { - case "ss": - return proxy.shadowsocksOutbound() - case "vmess": - return proxy.vmessOutbound() - case "vless": - return proxy.vlessOutbound() - case "socks5": - return proxy.socksOutbound() - case "trojan": - return proxy.trojanOutbound() - case "hysteria2": - return proxy.hysteria2Outbound() - } - return nil, fmt.Errorf("unsupported proxy type: %s", proxy.Type) -} - -func (proxy ClashProxy) shadowsocksOutbound() (*conf.OutboundDetourConfig, error) { - outbound := &conf.OutboundDetourConfig{} - outbound.Protocol = "shadowsocks" - setOutboundName(outbound, proxy.Name) - - settings := conf.ShadowsocksClientConfig{} - - settings.Address = parseAddress(proxy.Server) - settings.Port = proxy.Port - - settings.Cipher = proxy.Cipher - settings.Password = proxy.Password - - settingsRawMessage, err := convertJsonToRawMessage(settings) - if err != nil { - return nil, err - } - outbound.Settings = &settingsRawMessage - - if len(proxy.Plugin) != 0 { - if proxy.Plugin != "v2ray-plugin" { - return nil, fmt.Errorf("unsupport ss plugin: obfs") - } - if proxy.PluginOpts == nil { - return nil, fmt.Errorf("unsupport ss plugin-opts: nil") - } - if proxy.PluginOpts.Mode != "websocket" { - return nil, fmt.Errorf("unsupport ss plugin-opts mode: %s", proxy.PluginOpts.Mode) - } - streamSetting := &conf.StreamConfig{} - streamSetting.Network = new(conf.TransportProtocol("websocket")) - - wsSettings := &conf.WebSocketConfig{} - if len(proxy.PluginOpts.Host) > 0 { - wsSettings.Host = proxy.PluginOpts.Host - } - if len(proxy.PluginOpts.Path) > 0 { - wsSettings.Path = proxy.PluginOpts.Path - } - streamSetting.WSSettings = wsSettings - - if proxy.PluginOpts.Tls { - tlsSettings := &conf.TLSConfig{} - tlsSettings.Fingerprint = proxy.PluginOpts.Fingerprint - - if proxy.PluginOpts.EchOpts != nil { - if proxy.PluginOpts.EchOpts.Enable { - tlsSettings.ECHConfigList = proxy.PluginOpts.EchOpts.Config - } - } - - streamSetting.TLSSettings = tlsSettings - } - - outbound.StreamSetting = streamSetting - } - return outbound, nil -} - -func (proxy ClashProxy) vmessOutbound() (*conf.OutboundDetourConfig, error) { - outbound := &conf.OutboundDetourConfig{} - outbound.Protocol = "vmess" - setOutboundName(outbound, proxy.Name) - - settings := conf.VMessOutboundConfig{} - - settings.Address = parseAddress(proxy.Server) - settings.Port = proxy.Port - - settings.ID = proxy.Uuid - settings.Security = proxy.Cipher - - settingsRawMessage, err := convertJsonToRawMessage(settings) - if err != nil { - return nil, err - } - outbound.Settings = &settingsRawMessage - - streamSettings, err := proxy.streamSettings(*outbound) - if err != nil { - return nil, err - } - outbound.StreamSetting = streamSettings - - return outbound, nil -} - -func (proxy ClashProxy) vlessOutbound() (*conf.OutboundDetourConfig, error) { - outbound := &conf.OutboundDetourConfig{} - outbound.Protocol = "vless" - setOutboundName(outbound, proxy.Name) - - settings := conf.VLessOutboundConfig{} - - settings.Address = parseAddress(proxy.Server) - settings.Port = proxy.Port - - settings.Id = proxy.Uuid - settings.Flow = proxy.Flow - if len(proxy.Encryption) > 0 { - settings.Encryption = proxy.Encryption - } else { - settings.Encryption = "none" - } - - settingsRawMessage, err := convertJsonToRawMessage(settings) - if err != nil { - return nil, err - } - outbound.Settings = &settingsRawMessage - - streamSettings, err := proxy.streamSettings(*outbound) - if err != nil { - return nil, err - } - outbound.StreamSetting = streamSettings - - return outbound, nil -} - -func (proxy ClashProxy) socksOutbound() (*conf.OutboundDetourConfig, error) { - outbound := &conf.OutboundDetourConfig{} - outbound.Protocol = "socks" - setOutboundName(outbound, proxy.Name) - - settings := conf.SocksClientConfig{} - - settings.Address = parseAddress(proxy.Server) - settings.Port = proxy.Port - - settings.Username = proxy.Username - settings.Password = proxy.Password - - settingsRawMessage, err := convertJsonToRawMessage(settings) - if err != nil { - return nil, err - } - outbound.Settings = &settingsRawMessage - - streamSettings, err := proxy.streamSettings(*outbound) - if err != nil { - return nil, err - } - outbound.StreamSetting = streamSettings - - return outbound, nil -} - -func (proxy ClashProxy) trojanOutbound() (*conf.OutboundDetourConfig, error) { - outbound := &conf.OutboundDetourConfig{} - outbound.Protocol = "trojan" - setOutboundName(outbound, proxy.Name) - - settings := conf.TrojanClientConfig{} - - settings.Address = parseAddress(proxy.Server) - settings.Port = proxy.Port - - settings.Password = proxy.Password - - settingsRawMessage, err := convertJsonToRawMessage(settings) - if err != nil { - return nil, err - } - outbound.Settings = &settingsRawMessage - - streamSettings, err := proxy.streamSettings(*outbound) - if err != nil { - return nil, err - } - outbound.StreamSetting = streamSettings - - return outbound, nil -} - -func (proxy ClashProxy) hysteria2Outbound() (*conf.OutboundDetourConfig, error) { - outbound := &conf.OutboundDetourConfig{} - outbound.Protocol = "hysteria" - setOutboundName(outbound, proxy.Name) - - settings := conf.HysteriaClientConfig{} - - settings.Version = 2 - settings.Address = parseAddress(proxy.Server) - settings.Port = proxy.Port - - settingsRawMessage, err := convertJsonToRawMessage(settings) - if err != nil { - return nil, err - } - outbound.Settings = &settingsRawMessage - - streamSettings, err := proxy.streamSettings(*outbound) - if err != nil { - return nil, err - } - outbound.StreamSetting = streamSettings - - return outbound, nil -} - -func (proxy ClashProxy) streamSettings(outbound conf.OutboundDetourConfig) (*conf.StreamConfig, error) { - streamSettings := &conf.StreamConfig{} - network := proxy.Network - if len(proxy.Network) == 0 { - network = "raw" - } - transportProtocol := conf.TransportProtocol(network) - streamSettings.Network = &transportProtocol - - // fix hysteria network - if proxy.Type == "hysteria2" { - network = "hysteria" - transportProtocol = "hysteria" - streamSettings.Network = &transportProtocol - } - - switch network { - case "ws": - if proxy.WsOpts != nil { - wsSettings := &conf.WebSocketConfig{} - if proxy.WsOpts.Headers != nil { - wsSettings.Host = proxy.WsOpts.Headers.Host - } - wsSettings.Path = proxy.WsOpts.Path - streamSettings.WSSettings = wsSettings - } - case "grpc": - if proxy.GrpcOpts != nil { - grpcSettings := &conf.GRPCConfig{} - grpcSettings.ServiceName = proxy.GrpcOpts.GrpcServiceName - streamSettings.GRPCSettings = grpcSettings - } - case "xhttp": - if proxy.XhttpOpts != nil { - xhttpSettings, err := parseXHTTPOpts(*proxy.XhttpOpts) - if err != nil { - return nil, err - } - streamSettings.XHTTPSettings = xhttpSettings - } - case "hysteria": - hysteriaSettings := &conf.HysteriaConfig{} - hysteriaSettings.Version = 2 - hysteriaSettings.Auth = proxy.Password - streamSettings.HysteriaSettings = hysteriaSettings - - var hopPtr *int32 - if proxy.HopInterval > 0 { - hopPtr = new(proxy.HopInterval) - } - fm, err := buildHy2FinalMask(proxy.Up, proxy.Down, proxy.Ports, hopPtr, proxy.Obfs, proxy.ObfsPassword) - if err != nil { - return nil, err - } - streamSettings.FinalMask = fm - } - proxy.parseSecurity(streamSettings, outbound) - return streamSettings, nil -} - -func (proxy ClashProxy) parseSecurity(streamSettings *conf.StreamConfig, outbound conf.OutboundDetourConfig) { - tlsSettings := &conf.TLSConfig{} - realitySettings := &conf.REALITYConfig{} - - if proxy.Tls { - streamSettings.Security = "tls" - } - - if proxy.EchOpts != nil { - if proxy.EchOpts.Enable { - tlsSettings.ECHConfigList = proxy.EchOpts.Config - } - } - - if proxy.RealityOpts != nil { - streamSettings.Security = "reality" - realitySettings.PublicKey = proxy.RealityOpts.PublicKey - realitySettings.ShortId = proxy.RealityOpts.ShortId - } - if len(proxy.Servername) > 0 { - tlsSettings.ServerName = proxy.Servername - realitySettings.ServerName = proxy.Servername - } - if len(proxy.Sni) > 0 { - tlsSettings.ServerName = proxy.Sni - realitySettings.ServerName = proxy.Sni - } - if len(proxy.Alpn) > 0 { - tlsSettings.ALPN = new(conf.StringList(proxy.Alpn)) - } - if len(proxy.Fingerprint) > 0 { - tlsSettings.Fingerprint = proxy.Fingerprint - realitySettings.Fingerprint = proxy.Fingerprint - } - if len(proxy.ClientFingerprint) > 0 { - tlsSettings.Fingerprint = proxy.ClientFingerprint - realitySettings.Fingerprint = proxy.ClientFingerprint - } - - if (outbound.Protocol == "trojan" || outbound.Protocol == "hysteria") && len(streamSettings.Security) == 0 { - streamSettings.Security = "tls" - } - - switch streamSettings.Security { - case "tls": - streamSettings.TLSSettings = tlsSettings - case "reality": - streamSettings.REALITYSettings = realitySettings - } -} - -func parseXHTTPOpts(xhttpOpts ClashProxyXhttpOpts) (*conf.SplitHTTPConfig, error) { - xhttpSettings := &conf.SplitHTTPConfig{} - xhttpSettings.Path = xhttpOpts.Path - xhttpSettings.Host = xhttpOpts.Host - xhttpSettings.Mode = xhttpOpts.Mode - - extra := &conf.SplitHTTPConfig{} - if len(xhttpOpts.Headers) > 0 { - extra.Headers = xhttpOpts.Headers - } - extra.NoGRPCHeader = xhttpOpts.NoGrpcHeader - if len(xhttpOpts.XPaddingBytes) > 0 { - int32Range, err := parseInt32RangeString(xhttpOpts.XPaddingBytes) - if err != nil { - return nil, err - } - extra.XPaddingBytes = *int32Range - } - if len(xhttpOpts.ScMaxEachPostBytes) > 0 { - int32Range, err := parseInt32RangeString(xhttpOpts.ScMaxEachPostBytes) - if err != nil { - return nil, err - } - extra.ScMaxEachPostBytes = *int32Range - } - if xhttpOpts.ReuseSettings != nil { - xmuxSettings, err := parseXHTTPXMUX(xhttpOpts.ReuseSettings) - if err != nil { - return nil, err - } - extra.Xmux = *xmuxSettings - } - if xhttpOpts.DownloadSettings != nil { - downloadSettings, err := parseXHTTPDownloadSettings(xhttpOpts.DownloadSettings) - if err != nil { - return nil, err - } - extra.DownloadSettings = downloadSettings - } - - extraRawMessage, err := convertJsonToRawMessage(extra) - if err != nil { - return nil, err - } - xhttpSettings.Extra = extraRawMessage - return xhttpSettings, nil -} - -func parseInt32RangeString(s string) (*conf.Int32Range, error) { - left, right, err := conf.ParseRangeString(s) - if err != nil { - return nil, err - } - return &conf.Int32Range{ - Left: int32(left), - Right: int32(right), - From: int32(left), - To: int32(right), - }, nil -} - -func parseXHTTPXMUX(reuseSettings *ClashProxyXhttpOptsXMUX) (*conf.XmuxConfig, error) { - xmuxSettings := &conf.XmuxConfig{} - if len(reuseSettings.MaxConnections) > 0 { - int32Range, err := parseInt32RangeString(reuseSettings.MaxConnections) - if err != nil { - return nil, err - } - xmuxSettings.MaxConnections = *int32Range - } - if len(reuseSettings.MaxConcurrency) > 0 { - int32Range, err := parseInt32RangeString(reuseSettings.MaxConcurrency) - if err != nil { - return nil, err - } - xmuxSettings.MaxConcurrency = *int32Range - } - if len(reuseSettings.CMaxReuseTimes) > 0 { - int32Range, err := parseInt32RangeString(reuseSettings.CMaxReuseTimes) - if err != nil { - return nil, err - } - xmuxSettings.CMaxReuseTimes = *int32Range - } - if len(reuseSettings.HMaxRequestTimes) > 0 { - int32Range, err := parseInt32RangeString(reuseSettings.HMaxRequestTimes) - if err != nil { - return nil, err - } - xmuxSettings.HMaxRequestTimes = *int32Range - } - if len(reuseSettings.HMaxReusableSecs) > 0 { - int32Range, err := parseInt32RangeString(reuseSettings.HMaxReusableSecs) - if err != nil { - return nil, err - } - xmuxSettings.HMaxReusableSecs = *int32Range - } - return xmuxSettings, nil -} - -func parseXHTTPDownloadSettings(downloadSettings *ClashProxyXhttpOptsDownloadSettings) (*conf.StreamConfig, error) { - streamSettings := &conf.StreamConfig{} - - streamSettings.Address = parseAddress(downloadSettings.Server) - streamSettings.Port = downloadSettings.Port - - streamSettings.Network = new(conf.TransportProtocol("xhttp")) - - xhttpSettings := &conf.SplitHTTPConfig{} - xhttpSettings.Path = downloadSettings.Path - xhttpSettings.Host = downloadSettings.Host - xhttpSettings.Mode = downloadSettings.Mode - - if len(downloadSettings.Headers) > 0 { - xhttpSettings.Headers = downloadSettings.Headers - } - xhttpSettings.NoGRPCHeader = downloadSettings.NoGrpcHeader - if len(downloadSettings.XPaddingBytes) > 0 { - int32Range, err := parseInt32RangeString(downloadSettings.XPaddingBytes) - if err != nil { - return nil, err - } - xhttpSettings.XPaddingBytes = *int32Range - } - if len(downloadSettings.ScMaxEachPostBytes) > 0 { - int32Range, err := parseInt32RangeString(downloadSettings.ScMaxEachPostBytes) - if err != nil { - return nil, err - } - xhttpSettings.ScMaxEachPostBytes = *int32Range - } - if downloadSettings.ReuseSettings != nil { - xmuxSettings, err := parseXHTTPXMUX(downloadSettings.ReuseSettings) - if err != nil { - return nil, err - } - xhttpSettings.Xmux = *xmuxSettings - } - streamSettings.XHTTPSettings = xhttpSettings - - parseXHTTPDownloadSettingsSecurity(streamSettings, downloadSettings) - - return streamSettings, nil -} - -func parseXHTTPDownloadSettingsSecurity(streamSettings *conf.StreamConfig, proxy *ClashProxyXhttpOptsDownloadSettings) { - tlsSettings := &conf.TLSConfig{} - realitySettings := &conf.REALITYConfig{} - - if proxy.Tls { - streamSettings.Security = "tls" - } - - if proxy.EchOpts != nil { - if proxy.EchOpts.Enable { - tlsSettings.ECHConfigList = proxy.EchOpts.Config - } - } - - if proxy.RealityOpts != nil { - streamSettings.Security = "reality" - realitySettings.PublicKey = proxy.RealityOpts.PublicKey - realitySettings.ShortId = proxy.RealityOpts.ShortId - } - if len(proxy.Servername) > 0 { - tlsSettings.ServerName = proxy.Servername - realitySettings.ServerName = proxy.Servername - } - if len(proxy.Alpn) > 0 { - tlsSettings.ALPN = new(conf.StringList(proxy.Alpn)) - } - if len(proxy.Fingerprint) > 0 { - tlsSettings.Fingerprint = proxy.Fingerprint - realitySettings.Fingerprint = proxy.Fingerprint - } - if len(proxy.ClientFingerprint) > 0 { - tlsSettings.Fingerprint = proxy.ClientFingerprint - realitySettings.Fingerprint = proxy.ClientFingerprint - } - - switch streamSettings.Security { - case "tls": - streamSettings.TLSSettings = tlsSettings - case "reality": - streamSettings.REALITYSettings = realitySettings - } -} diff --git a/share/clash_meta_test.go b/share/clash_meta_test.go deleted file mode 100644 index d24b0e2d..00000000 --- a/share/clash_meta_test.go +++ /dev/null @@ -1,374 +0,0 @@ -package share - -import ( - "encoding/json" - "fmt" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/xtls/xray-core/infra/conf" -) - -func clashHysteria2YAML(fields string) string { - return fmt.Sprintf(`proxies: - - name: test-hy2 - type: hysteria2 - server: host - port: 443 - password: authpass -%s`, fields) -} - -func parseClashHy2(t *testing.T, yaml string) *conf.OutboundDetourConfig { - t.Helper() - config, err := parseShareCandidatesForTest(yaml) - require.NoError(t, err) - require.Len(t, config.OutboundConfigs, 1) - assert.Equal(t, "test-hy2", config.OutboundConfigs[0].Tag) - assert.Nil(t, config.OutboundConfigs[0].SendThrough) - return &config.OutboundConfigs[0] -} - -func parseClashYAML(t *testing.T, yaml string) *conf.Config { - t.Helper() - cfg, err := parseShareCandidatesForTest(yaml) - require.NoError(t, err) - return cfg -} - -func TestClashHysteria2_WithBandwidthPortHoppingSalamander(t *testing.T) { - yaml := clashHysteria2YAML(` up: 100 mbps - down: 200 mbps - ports: "20000-40000" - hop-interval: 30 - obfs: salamander - obfs-password: secret - sni: example.com`) - - outbound := parseClashHy2(t, yaml) - assert.Equal(t, "hysteria", outbound.Protocol) - - var settings conf.HysteriaClientConfig - require.NoError(t, json.Unmarshal(*outbound.Settings, &settings)) - assert.Equal(t, int32(2), settings.Version) - - ss := outbound.StreamSetting - require.NotNil(t, ss) - - // HysteriaSettings has version + auth only - require.NotNil(t, ss.HysteriaSettings) - assert.Equal(t, int32(2), ss.HysteriaSettings.Version) - assert.Equal(t, "authpass", ss.HysteriaSettings.Auth) - - // QuicParams - require.NotNil(t, ss.FinalMask) - require.NotNil(t, ss.FinalMask.QuicParams) - qp := ss.FinalMask.QuicParams - assert.Equal(t, "brutal", qp.Congestion) - assert.Equal(t, conf.Bandwidth("100 mbps"), qp.BrutalUp) - assert.Equal(t, conf.Bandwidth("200 mbps"), qp.BrutalDown) - - // UdpHop - assert.Equal(t, "20000-40000", qp.UdpHop.PortList.String()) - assert.Equal(t, int32(30), qp.UdpHop.Interval.From) - assert.Equal(t, int32(30), qp.UdpHop.Interval.To) - - // Salamander - require.Len(t, ss.FinalMask.Udp, 1) - assert.Equal(t, "salamander", ss.FinalMask.Udp[0].Type) - var salamander conf.Salamander - require.NoError(t, json.Unmarshal(*ss.FinalMask.Udp[0].Settings, &salamander)) - assert.Equal(t, "secret", salamander.Password) - - // TLS default - assert.Equal(t, "tls", ss.Security) - require.NotNil(t, ss.TLSSettings) - assert.Equal(t, "example.com", ss.TLSSettings.ServerName) -} - -func TestClashHysteria2_BandwidthOnly(t *testing.T) { - yaml := clashHysteria2YAML(` up: 50 mbps - down: 100 mbps - sni: example.com`) - - outbound := parseClashHy2(t, yaml) - ss := outbound.StreamSetting - require.NotNil(t, ss) - - // HysteriaSettings - require.NotNil(t, ss.HysteriaSettings) - assert.Equal(t, "authpass", ss.HysteriaSettings.Auth) - - // QuicParams with bandwidth, no port-hopping - require.NotNil(t, ss.FinalMask) - require.NotNil(t, ss.FinalMask.QuicParams) - qp := ss.FinalMask.QuicParams - assert.Equal(t, "brutal", qp.Congestion) - assert.Equal(t, conf.Bandwidth("50 mbps"), qp.BrutalUp) - assert.Equal(t, conf.Bandwidth("100 mbps"), qp.BrutalDown) - - // No Salamander - assert.Empty(t, ss.FinalMask.Udp) - - // No UdpHop - assert.Empty(t, qp.UdpHop.PortList.Range) -} - -func TestClashHysteria2_SalamanderOnly(t *testing.T) { - yaml := clashHysteria2YAML(` obfs: salamander - obfs-password: mysecret - sni: example.com`) - - outbound := parseClashHy2(t, yaml) - ss := outbound.StreamSetting - require.NotNil(t, ss) - - require.NotNil(t, ss.FinalMask) - - // No QuicParams - assert.Nil(t, ss.FinalMask.QuicParams) - - // Salamander - require.Len(t, ss.FinalMask.Udp, 1) - assert.Equal(t, "salamander", ss.FinalMask.Udp[0].Type) - var salamander conf.Salamander - require.NoError(t, json.Unmarshal(*ss.FinalMask.Udp[0].Settings, &salamander)) - assert.Equal(t, "mysecret", salamander.Password) -} - -func TestClashHysteria2_Minimal(t *testing.T) { - yaml := clashHysteria2YAML(` sni: example.com`) - - outbound := parseClashHy2(t, yaml) - assert.Equal(t, "hysteria", outbound.Protocol) - - ss := outbound.StreamSetting - require.NotNil(t, ss) - - // HysteriaSettings - require.NotNil(t, ss.HysteriaSettings) - assert.Equal(t, int32(2), ss.HysteriaSettings.Version) - assert.Equal(t, "authpass", ss.HysteriaSettings.Auth) - - // No FinalMask - assert.Nil(t, ss.FinalMask) - - // TLS default for hysteria - assert.Equal(t, "tls", ss.Security) - require.NotNil(t, ss.TLSSettings) - assert.Equal(t, "example.com", ss.TLSSettings.ServerName) -} - -func TestClashShadowsocks_Plain(t *testing.T) { - yaml := `proxies: - - name: ss-plain - type: ss - server: ss.host - port: 8388 - cipher: aes-128-gcm - password: secret - udp: true` - cfg := parseClashYAML(t, yaml) - require.Len(t, cfg.OutboundConfigs, 1) - ob := cfg.OutboundConfigs[0] - assert.Equal(t, "shadowsocks", ob.Protocol) - var s conf.ShadowsocksClientConfig - require.NoError(t, json.Unmarshal(*ob.Settings, &s)) - assert.Equal(t, "aes-128-gcm", s.Cipher) - assert.Equal(t, "secret", s.Password) - assert.Nil(t, ob.StreamSetting) -} - -func TestClashShadowsocks_V2rayPluginWebsocketTLS(t *testing.T) { - yaml := `proxies: - - name: ss-ws - type: ss - server: ss.ws - port: 443 - cipher: aes-256-gcm - password: p - plugin: v2ray-plugin - plugin-opts: - mode: websocket - host: cloud.cdn - path: /ws - tls: true - fingerprint: chrome` - cfg := parseClashYAML(t, yaml) - require.Len(t, cfg.OutboundConfigs, 1) - ss := cfg.OutboundConfigs[0].StreamSetting - require.NotNil(t, ss) - require.NotNil(t, ss.WSSettings) - assert.Equal(t, "/ws", ss.WSSettings.Path) - assert.Equal(t, "cloud.cdn", ss.WSSettings.Host) - require.NotNil(t, ss.TLSSettings) - assert.Equal(t, "chrome", ss.TLSSettings.Fingerprint) -} - -func TestClashVmess_WsTLS(t *testing.T) { - yaml := fmt.Sprintf(`proxies: - - name: vm-ws - type: vmess - server: vm.host - port: 443 - uuid: %s - cipher: auto - network: ws - tls: true - servername: vm.host - alpn: - - h2 - fingerprint: firefox - ws-opts: - path: /vmws - headers: - Host: front.cdn`, testShareUUID) - cfg := parseClashYAML(t, yaml) - require.Len(t, cfg.OutboundConfigs, 1) - ss := cfg.OutboundConfigs[0].StreamSetting - require.NotNil(t, ss.WSSettings) - assert.Equal(t, "/vmws", ss.WSSettings.Path) - assert.Equal(t, "front.cdn", ss.WSSettings.Host) - assert.Equal(t, "tls", ss.Security) - require.NotNil(t, ss.TLSSettings) - assert.Equal(t, "vm.host", ss.TLSSettings.ServerName) - assert.Equal(t, "firefox", ss.TLSSettings.Fingerprint) -} - -func TestClashVless_Grpc(t *testing.T) { - yaml := fmt.Sprintf(`proxies: - - name: vl-grpc - type: vless - server: g.host - port: 443 - uuid: %s - encryption: none - network: grpc - tls: true - sni: g.host - grpc-opts: - grpc-service-name: MySvc`, testShareUUID) - cfg := parseClashYAML(t, yaml) - require.Len(t, cfg.OutboundConfigs, 1) - gs := cfg.OutboundConfigs[0].StreamSetting.GRPCSettings - require.NotNil(t, gs) - assert.Equal(t, "MySvc", gs.ServiceName) -} - -func TestClashVless_XhttpExtraDownloadAndRanges(t *testing.T) { - yaml := fmt.Sprintf(`proxies: - - name: vl-xh - type: vless - server: xh.host - port: 443 - uuid: %s - encryption: none - network: xhttp - tls: true - sni: xh.host - client-fingerprint: edge - ech-opts: - enable: true - config: echcfg123 - xhttp-opts: - path: /xh - host: xh.h - mode: stream-one - no-grpc-header: true - x-padding-bytes: 100-500 - sc-max-each-post-bytes: 1000-2000 - headers: - X-Custom: z - reuse-settings: - max-connections: 1-8 - max-concurrency: 2-4 - c-max-reuse-times: 0-0 - h-max-request-times: 1-1 - h-max-reusable-secs: 30-60 - download-settings: - server: dl.host - port: 8443 - tls: true - servername: dl.host - alpn: - - h3 - path: /dl - host: dl.h - fingerprint: ios - reality-opts: - public-key: AbCdEfGhIjKlMnOpQrStUvWxYz0123456789012 - short-id: "0123456789abcdef"`, testShareUUID) - cfg := parseClashYAML(t, yaml) - require.Len(t, cfg.OutboundConfigs, 1) - ob := cfg.OutboundConfigs[0] - ss := ob.StreamSetting - require.NotNil(t, ss.XHTTPSettings) - x := ss.XHTTPSettings - assert.Equal(t, "/xh", x.Path) - assert.Equal(t, "stream-one", x.Mode) - require.NotNil(t, x.Extra) - var extra conf.SplitHTTPConfig - require.NoError(t, json.Unmarshal(x.Extra, &extra)) - assert.True(t, extra.NoGRPCHeader) - assert.Equal(t, "z", extra.Headers["X-Custom"]) - require.NotNil(t, extra.DownloadSettings) - dl := extra.DownloadSettings - require.NotNil(t, dl.XHTTPSettings) - assert.Equal(t, "/dl", dl.XHTTPSettings.Path) - assert.Equal(t, "reality", dl.Security) - require.NotNil(t, dl.REALITYSettings) -} - -func TestClashSocks5_WithAuth(t *testing.T) { - yaml := `proxies: - - name: sk - type: socks5 - server: 127.0.0.1 - port: 1080 - username: su - password: sp` - cfg := parseClashYAML(t, yaml) - require.Len(t, cfg.OutboundConfigs, 1) - var s conf.SocksClientConfig - require.NoError(t, json.Unmarshal(*cfg.OutboundConfigs[0].Settings, &s)) - assert.Equal(t, "su", s.Username) - assert.Equal(t, "sp", s.Password) -} - -func TestClashTrojan_TlsFromType(t *testing.T) { - yaml := `proxies: - - name: tr - type: trojan - server: tr.host - port: 443 - password: trpass - sni: tr.host` - cfg := parseClashYAML(t, yaml) - require.Len(t, cfg.OutboundConfigs, 1) - ss := cfg.OutboundConfigs[0].StreamSetting - require.NotNil(t, ss) - assert.Equal(t, "tls", ss.Security) - require.NotNil(t, ss.TLSSettings) -} - -func TestClashVless_Reality(t *testing.T) { - yaml := fmt.Sprintf(`proxies: - - name: vl-reality - type: vless - server: r.host - port: 443 - uuid: %s - encryption: none - network: tcp - reality-opts: - public-key: XYAbCdEfGhIjKlMnOpQrStUvWxYz0123456789AB - short-id: abcdef01 - client-fingerprint: qq - servername: r.host`, testShareUUID) - cfg := parseClashYAML(t, yaml) - ss := cfg.OutboundConfigs[0].StreamSetting - assert.Equal(t, "reality", ss.Security) - require.NotNil(t, ss.REALITYSettings) - assert.Equal(t, "XYAbCdEfGhIjKlMnOpQrStUvWxYz0123456789AB", ss.REALITYSettings.PublicKey) -} diff --git a/share/convert_share.go b/share/convert_share.go index 2e9a797f..4043fd5c 100644 --- a/share/convert_share.go +++ b/share/convert_share.go @@ -7,7 +7,6 @@ import ( "strings" "github.com/xtls/xray-core/infra/conf" - "gopkg.in/yaml.v3" ) // ConvertShareLinksToXrayJson parses share links or an Age-encrypted subscription. @@ -81,24 +80,5 @@ func parseShareCandidates(links string, allowBase64 bool) (*conf.Config, error) return parseShareCandidates(decoded, false) } } - if hasTopLevelClashProxiesKey(text) { - var document struct { - Proxies []yaml.Node `yaml:"proxies"` - } - if err := yaml.Unmarshal([]byte(text), &document); err != nil || document.Proxies == nil { - return nil, errors.New("invalid share YAML proxies") - } - for _, node := range document.Proxies { - var proxy ClashProxy - if err := node.Decode(&proxy); err != nil { - continue - } - outbound, err := proxy.outbound() - if err == nil { - config.OutboundConfigs = append(config.OutboundConfigs, *outbound) - } - } - return config, nil - } return nil, errors.New("unsupported share format") } diff --git a/share/convert_share_test.go b/share/convert_share_test.go index 5848b2b8..d22b5105 100644 --- a/share/convert_share_test.go +++ b/share/convert_share_test.go @@ -11,21 +11,13 @@ const validShareOutbound = `{"protocol":"vless","tag":"Keep","settings":{"addres func TestConvertShareLinksSkipsInvalidCandidates(t *testing.T) { jsonText := `{"outbounds":[` + validShareOutbound + `,{"protocol":"freedom"},{"protocol":42},null,{"protocol":"vless","settings":{"id":"invalid"}}]}` - yamlText := `proxies: - - {name: Keep, type: vless, server: example.com, port: 443, uuid: 12345678-abcd-abcd-abcd-123456789abc, tls: true, servername: example.com} - - {name: Unsupported, type: unknown} - - {name: InvalidPort, type: vless, port: invalid} - - {name: InvalidId, type: vless, server: example.com, port: 443, uuid: invalid} - - null -` for _, test := range []struct { name, text string }{ {"links with headers", "Subscription export\n# comment\n\n" + ageTestShareLink + "\nvless://bad@example.com:443?encryption=none\nunknown://example.com"}, + {"links with removed protocols", "hy2://auth@hy.example:443\n" + ageTestShareLink + "\nhysteria2://auth@hy.example:443"}, {"JSON elements", jsonText}, - {"YAML elements", yamlText}, {"base64 JSON", base64.StdEncoding.EncodeToString([]byte(jsonText))}, - {"base64 YAML", base64.RawURLEncoding.EncodeToString([]byte(yamlText))}, {"base64 links", base64.StdEncoding.EncodeToString([]byte(ageTestShareLink + "\nvless://bad@example.com:443"))}, } { t.Run(test.name, func(t *testing.T) { @@ -38,11 +30,38 @@ func TestConvertShareLinksSkipsInvalidCandidates(t *testing.T) { } } +func TestConvertShareLinksRejectsRemovedFormats(t *testing.T) { + for _, input := range []struct { + name, text string + }{ + {"Clash YAML", "proxies:\n - {name: Node, type: vless, server: example.com, port: 443, uuid: " + testShareUUID + "}"}, + {"Mihomo JSON", `{"proxies":[{"name":"Node","type":"vless","server":"example.com","port":443,"uuid":"` + testShareUUID + `"}]}`}, + {"Hysteria2 URI", "hysteria2://auth@hy.example:443?sni=hy.example"}, + {"Hy2 URI", "hy2://auth@hy.example:443?sni=hy.example"}, + } { + t.Run(input.name, func(t *testing.T) { + for _, encoding := range []struct { + name, text string + }{ + {"plaintext", input.text}, + {"base64", base64.StdEncoding.EncodeToString([]byte(input.text))}, + {"base64 URL", base64.RawURLEncoding.EncodeToString([]byte(input.text))}, + } { + t.Run(encoding.name, func(t *testing.T) { + result, err := ConvertShareLinksToXrayJson(encoding.text, "") + if err == nil || result != nil { + t.Fatalf("result = %s, error = %v", result, err) + } + }) + } + }) + } +} + func TestConvertShareLinksAllInvalidReturnsNoData(t *testing.T) { for _, input := range []string{ `{"outbounds":[{"protocol":"freedom"}]}`, "vless://secret-not-a-uuid@example.com:443?encryption=none", - "proxies:\n - {type: unsupported, password: private-password}", } { result, err := ConvertShareLinksToXrayJson(input, "") if err == nil || err.Error() != "no valid outbound found" { @@ -94,6 +113,9 @@ func TestConvertShareLinksAgeSkipsInvalidCandidatesAndRedactsErrors(t *testing.T for _, input := range []string{ "vless://secret-not-a-uuid@example.com:443", `{"outbounds":"private-source"}`, + "proxies:\n - {type: vless, server: example.com, port: 443, uuid: " + testShareUUID + "}", + "hysteria2://private-password@hy.example:443", + "hy2://private-password@hy.example:443", } { result, err := ConvertShareLinksToXrayJson(encryptAgeForTest(t, pair, input), pair.SecretKey) if err != ErrAgePlaintextUnsupported { diff --git a/share/generate_share.go b/share/generate_share.go index 8f32f009..32073fa1 100644 --- a/share/generate_share.go +++ b/share/generate_share.go @@ -6,7 +6,6 @@ import ( "encoding/json" "fmt" "net/url" - "strconv" "strings" "github.com/xtls/xray-core/infra/conf" @@ -72,11 +71,6 @@ func shareLink(proxy conf.OutboundDetourConfig) (*url.URL, error) { if err != nil { return nil, err } - case "hysteria": - err := hysteriaLink(proxy, shareUrl) - if err != nil { - return nil, err - } default: return nil, fmt.Errorf("unsupported outbound protocol %q", proxy.Protocol) } @@ -217,24 +211,6 @@ func trojanLink(proxy conf.OutboundDetourConfig, link *url.URL) error { return nil } -func hysteriaLink(proxy conf.OutboundDetourConfig, link *url.URL) error { - settings, err := decodeOutboundSettings[conf.HysteriaClientConfig](proxy) - if err != nil { - return err - } - - link.Fragment = getOutboundName(proxy) - link.Scheme = "hysteria2" - - link.Host = fmt.Sprintf("%s:%d", settings.Address, settings.Port) - - if proxy.StreamSetting != nil && proxy.StreamSetting.HysteriaSettings != nil { - link.User = url.User(proxy.StreamSetting.HysteriaSettings.Auth) - } - - return nil -} - func streamSettingsQuery(proxy conf.OutboundDetourConfig, link *url.URL) { streamSettings := proxy.StreamSetting if streamSettings == nil { @@ -247,68 +223,6 @@ func streamSettingsQuery(proxy conf.OutboundDetourConfig, link *url.URL) { network = string(*streamSettings.Network) } - if network == "hysteria" { - // TLS params - if streamSettings.TLSSettings != nil { - sni := streamSettings.TLSSettings.ServerName - if len(sni) > 0 { - query = addQuery(query, "sni", sni) - } - fp := streamSettings.TLSSettings.Fingerprint - if len(fp) > 0 { - query = addQuery(query, "fp", fp) - } - alpn := streamSettings.TLSSettings.ALPN - if alpn != nil && len(*alpn) > 0 { - query = addQuery(query, "alpn", strings.Join(*alpn, ",")) - } - ech := streamSettings.TLSSettings.ECHConfigList - if len(ech) > 0 { - query = addQuery(query, "ech", ech) - } - pcs := streamSettings.TLSSettings.PinnedPeerCertSha256 - if len(pcs) > 0 { - query = addQuery(query, "pcs", pcs) - } - vcn := streamSettings.TLSSettings.VerifyPeerCertByName - if len(vcn) > 0 { - query = addQuery(query, "vcn", vcn) - } - } - - // QuicParams (bandwidth + port-hopping) - if streamSettings.FinalMask != nil && streamSettings.FinalMask.QuicParams != nil { - qp := streamSettings.FinalMask.QuicParams - if len(qp.BrutalUp) > 0 { - query = addQuery(query, "up", string(qp.BrutalUp)) - } - if len(qp.BrutalDown) > 0 { - query = addQuery(query, "down", string(qp.BrutalDown)) - } - if len(qp.UdpHop.PortList.Range) > 0 { - query = addQuery(query, "ports", qp.UdpHop.PortList.String()) - } - if qp.UdpHop.Interval.From != 0 || qp.UdpHop.Interval.To != 0 { - query = addQuery(query, "hop-interval", strconv.FormatInt(int64(qp.UdpHop.Interval.From), 10)) - } - } - - // Salamander - if streamSettings.FinalMask != nil && len(streamSettings.FinalMask.Udp) > 0 { - mask := streamSettings.FinalMask.Udp[0] - if mask.Settings != nil { - var obfs conf.Salamander - err := json.Unmarshal(*mask.Settings, &obfs) - if err == nil { - query = addQuery(query, "obfs", "salamander") - query = addQuery(query, "obfs-password", obfs.Password) - } - } - } - link.RawQuery = query - return - } - query = addQuery(query, "type", network) if len(streamSettings.Security) == 0 { diff --git a/share/generate_share_test.go b/share/generate_share_test.go index 01ba4a81..38da0a08 100644 --- a/share/generate_share_test.go +++ b/share/generate_share_test.go @@ -3,7 +3,6 @@ package share import ( "encoding/base64" "encoding/json" - "net/url" "testing" "github.com/stretchr/testify/assert" @@ -11,143 +10,6 @@ import ( "github.com/xtls/xray-core/infra/conf" ) -func buildHy2Outbound(t *testing.T, auth string, host string, port uint16, streamSetting *conf.StreamConfig) conf.OutboundDetourConfig { - t.Helper() - settings := &conf.HysteriaClientConfig{ - Address: parseAddress(host), - Port: port, - } - settingsJSON, err := json.Marshal(settings) - require.NoError(t, err) - return conf.OutboundDetourConfig{ - Protocol: "hysteria", - Settings: new(json.RawMessage(settingsJSON)), - StreamSetting: streamSetting, - } -} - -func buildHy2StreamSettings(auth string, tls *conf.TLSConfig, finalMask *conf.FinalMask) *conf.StreamConfig { - ss := &conf.StreamConfig{ - Network: new(conf.TransportProtocol("hysteria")), - Security: "tls", - } - ss.TLSSettings = tls - ss.HysteriaSettings = &conf.HysteriaConfig{ - Version: 2, - Auth: auth, - } - ss.FinalMask = finalMask - return ss -} - -func TestGenerate_Hy2_WithBandwidth(t *testing.T) { - tls := &conf.TLSConfig{ServerName: "example.com"} - fm := &conf.FinalMask{ - QuicParams: &conf.QuicParamsConfig{ - Congestion: "brutal", - BrutalUp: conf.Bandwidth("100 mbps"), - BrutalDown: conf.Bandwidth("200 mbps"), - }, - } - ss := buildHy2StreamSettings("auth", tls, fm) - outbound := buildHy2Outbound(t, "auth", "host", 443, ss) - - link, err := shareLink(outbound) - require.NoError(t, err) - - assert.Equal(t, "hysteria2", link.Scheme) - q := link.Query() - assert.Equal(t, "example.com", q.Get("sni")) - assert.Equal(t, "100 mbps", q.Get("up")) - assert.Equal(t, "200 mbps", q.Get("down")) -} - -func TestGenerate_Hy2_WithSalamanderBandwidthPortHopping(t *testing.T) { - tls := &conf.TLSConfig{ServerName: "example.com"} - - var portList conf.PortList - require.NoError(t, json.Unmarshal([]byte(`"20000-40000"`), &portList)) - - fm := &conf.FinalMask{ - QuicParams: &conf.QuicParamsConfig{ - Congestion: "brutal", - BrutalUp: conf.Bandwidth("50 mbps"), - BrutalDown: conf.Bandwidth("100 mbps"), - UdpHop: conf.UdpHop{ - PortList: portList, - Interval: conf.Int32Range{Left: 30, Right: 30, From: 30, To: 30}, - }, - }, - } - - salamander := &conf.Salamander{Password: "secret"} - salJSON, err := json.Marshal(salamander) - require.NoError(t, err) - fm.Udp = []conf.Mask{{Type: "salamander", Settings: new(json.RawMessage(salJSON))}} - - ss := buildHy2StreamSettings("auth", tls, fm) - outbound := buildHy2Outbound(t, "auth", "host", 443, ss) - - link, err := shareLink(outbound) - require.NoError(t, err) - - q := link.Query() - assert.Equal(t, "example.com", q.Get("sni")) - assert.Equal(t, "50 mbps", q.Get("up")) - assert.Equal(t, "100 mbps", q.Get("down")) - assert.Equal(t, "20000-40000", q.Get("ports")) - assert.Equal(t, "30", q.Get("hop-interval")) - assert.Equal(t, "salamander", q.Get("obfs")) - assert.Equal(t, "secret", q.Get("obfs-password")) -} - -func TestGenerate_Hy2_WithFullTLSParams(t *testing.T) { - alpn := conf.StringList{"h3"} - tls := &conf.TLSConfig{ - ServerName: "example.com", - Fingerprint: "chrome", - ALPN: &alpn, - } - ss := buildHy2StreamSettings("auth", tls, nil) - outbound := buildHy2Outbound(t, "auth", "host", 443, ss) - - link, err := shareLink(outbound) - require.NoError(t, err) - - q := link.Query() - assert.Equal(t, "example.com", q.Get("sni")) - assert.Equal(t, "chrome", q.Get("fp")) - assert.Equal(t, "h3", q.Get("alpn")) -} - -func TestGenerate_Hy2_RoundTrip(t *testing.T) { - original := "hy2://auth@host:443?up=50+mbps&down=100+mbps&obfs=salamander&obfs-password=secret&ports=20000-40000&hop-interval=30&sni=example.com&alpn=h3&fp=chrome" - - config, err := convertShareLinksForTest(original) - require.NoError(t, err) - require.Len(t, config.OutboundConfigs, 1) - - generated, err := shareLink(config.OutboundConfigs[0]) - require.NoError(t, err) - - // Parse both URLs to compare query params (order may differ) - origURL, err := url.Parse(original) - require.NoError(t, err) - origQ := origURL.Query() - genQ := generated.Query() - - assert.Equal(t, "hysteria2", generated.Scheme) - assert.Equal(t, origQ.Get("sni"), genQ.Get("sni")) - assert.Equal(t, origQ.Get("fp"), genQ.Get("fp")) - assert.Equal(t, origQ.Get("alpn"), genQ.Get("alpn")) - assert.Equal(t, origQ.Get("up"), genQ.Get("up")) - assert.Equal(t, origQ.Get("down"), genQ.Get("down")) - assert.Equal(t, origQ.Get("ports"), genQ.Get("ports")) - assert.Equal(t, origQ.Get("hop-interval"), genQ.Get("hop-interval")) - assert.Equal(t, origQ.Get("obfs"), genQ.Get("obfs")) - assert.Equal(t, origQ.Get("obfs-password"), genQ.Get("obfs-password")) -} - func TestConvertXrayJsonToShareLinks_RoundTripProtocols(t *testing.T) { cases := []string{ "vless://" + testShareUUID + "@r1.example:443?encryption=none&security=tls&sni=r1.example&type=ws&path=%2Fr&host=h.r1", @@ -155,17 +17,18 @@ func TestConvertXrayJsonToShareLinks_RoundTripProtocols(t *testing.T) { "ss://" + ssUserB64("aes-128-gcm", "pw") + "@r3.example:8389", "vmess://" + testShareUUID + "@r4.example:443?encryption=none&type=tcp", "socks://" + base64.StdEncoding.EncodeToString([]byte("u:p")) + "@127.0.0.1:1090", + "vmess://" + base64.StdEncoding.EncodeToString([]byte(`{"ps":"QR","add":"qr.example","port":443,"id":"`+testShareUUID+`","scy":"auto","net":"ws","path":"/ws","tls":"tls"}`)), } for _, link := range cases { t.Run(link[:12], func(t *testing.T) { - cfg, err := convertShareLinksForTest(link) + cfg, err := convertShareLinksWithKeyForTest(link, "") require.NoError(t, err) out, err := json.Marshal(cfg) require.NoError(t, err) text, err := ConvertXrayJsonToShareLinks(out) require.NoError(t, err) assert.NotEmpty(t, text) - again, err := convertShareLinksForTest(text) + again, err := convertShareLinksWithKeyForTest(text, "") require.NoError(t, err) require.Len(t, again.OutboundConfigs, 1) assert.Equal(t, cfg.OutboundConfigs[0].Protocol, again.OutboundConfigs[0].Protocol) @@ -246,6 +109,21 @@ func TestConvertXrayJsonToShareLinks_Errors(t *testing.T) { require.Error(t, err) } +func TestConvertXrayJsonToShareLinksRejectsHysteria(t *testing.T) { + links, err := ConvertXrayJsonToShareLinks([]byte(`{ + "outbounds": [{ + "protocol": "hysteria", + "settings": {"version": 2, "address": "example.com", "port": 443}, + "streamSettings": { + "network": "hysteria", "security": "tls", + "hysteriaSettings": {"version": 2, "auth": "password"} + } + }] + }`)) + require.EqualError(t, err, "no valid outbounds") + assert.Empty(t, links) +} + func TestConvertXrayJsonToShareLinksSkipsUnsupportedOutbounds(t *testing.T) { links, err := ConvertXrayJsonToShareLinks([]byte(`{ "outbounds": [ @@ -257,7 +135,8 @@ func TestConvertXrayJsonToShareLinksSkipsUnsupportedOutbounds(t *testing.T) { "password": "password" } }, - {"protocol": "freedom", "tag": "direct"} + {"protocol": "freedom", "tag": "direct"}, + {"protocol": "hysteria", "settings": {"version": 2, "address": "example.com", "port": 443}} ] }`)) require.NoError(t, err) diff --git a/share/hysteria_mask.go b/share/hysteria_mask.go deleted file mode 100644 index cbdc5e5b..00000000 --- a/share/hysteria_mask.go +++ /dev/null @@ -1,58 +0,0 @@ -package share - -import ( - "encoding/json" - - "github.com/xtls/xray-core/infra/conf" -) - -// buildHy2FinalMask builds Hysteria2 QUIC hop / bandwidth / salamander mask (shared by URI and Clash). -func buildHy2FinalMask(up, down, ports string, hopInterval *int32, obfsType, obfsPassword string) (*conf.FinalMask, error) { - var quicParams *conf.QuicParamsConfig - if up != "" || down != "" || ports != "" { - quicParams = &conf.QuicParamsConfig{} - if up != "" || down != "" { - quicParams.Congestion = "brutal" - } - if up != "" { - quicParams.BrutalUp = conf.Bandwidth(up) - } - if down != "" { - quicParams.BrutalDown = conf.Bandwidth(down) - } - if ports != "" { - var portList conf.PortList - udpHop := conf.UdpHop{} - portListJSON, err := json.Marshal(ports) - if err != nil { - return nil, err - } - if err := json.Unmarshal(portListJSON, &portList); err != nil { - return nil, err - } - udpHop.PortList = portList - if hopInterval != nil { - i := *hopInterval - udpHop.Interval = conf.Int32Range{Left: i, Right: i, From: i, To: i} - } - quicParams.UdpHop = udpHop - } - } - - var udpMasks []conf.Mask - if obfsType == "salamander" && obfsPassword != "" { - obfs := conf.Mask{Type: "salamander"} - salamander := &conf.Salamander{Password: obfsPassword} - salamanderRawMessage, err := convertJsonToRawMessage(salamander) - if err != nil { - return nil, err - } - obfs.Settings = &salamanderRawMessage - udpMasks = []conf.Mask{obfs} - } - - if quicParams == nil && len(udpMasks) == 0 { - return nil, nil - } - return &conf.FinalMask{QuicParams: quicParams, Udp: udpMasks}, nil -} diff --git a/share/marshal_share.go b/share/marshal_share.go index 9ffb5773..4a81373c 100644 --- a/share/marshal_share.go +++ b/share/marshal_share.go @@ -272,16 +272,6 @@ func projectShareFinalMask(source map[string]any) map[string]any { if params, ok := shareObject(source["quicParams"]); ok { projectedParams := map[string]any{} copyShareFields(projectedParams, params, "congestion", "brutalUp", "brutalDown") - if udpHop, ok := shareObject(params["udpHop"]); ok { - projectedUDPHop := map[string]any{} - copyShareFields(projectedUDPHop, udpHop, "ports") - if interval, exists := udpHop["interval"]; exists && !emptyShareRange(interval) { - projectedUDPHop["interval"] = interval - } - if len(projectedUDPHop) > 0 { - projectedParams["udpHop"] = projectedUDPHop - } - } if len(projectedParams) > 0 { projected["quicParams"] = projectedParams } @@ -348,11 +338,3 @@ func emptyShareValue(value any) bool { return false } } - -func emptyShareRange(value any) bool { - if text, ok := value.(string); ok { - number, err := strconv.ParseInt(text, 10, 32) - return err == nil && number == 0 - } - return emptyShareValue(value) -} diff --git a/share/marshal_share_test.go b/share/marshal_share_test.go index ffdecd62..6cb4a0fc 100644 --- a/share/marshal_share_test.go +++ b/share/marshal_share_test.go @@ -73,12 +73,22 @@ func TestMarshalShareConfigProjectsSupportedFields(t *testing.T) { requireProjectedOutboundsBuild(t, raw) } -func TestMarshalShareConfigPreservesHysteriaPortHopping(t *testing.T) { - config, err := convertShareLinksForTest( - "hy2://auth@host:443?up=50+mbps&down=100+mbps&ports=20000-40000&hop-interval=30&sni=example.com&fp=chrome", - ) - require.NoError(t, err) - raw, err := marshalShareConfigJSON(config) +func TestConvertShareLinksNativeJSONPreservesHysteriaPortHopping(t *testing.T) { + raw, err := ConvertShareLinksToXrayJson(`{ + "outbounds": [{ + "protocol": "hysteria", + "settings": {"version": 2, "address": "host", "port": 443}, + "streamSettings": { + "network": "hysteria", "security": "tls", + "tlsSettings": {"serverName": "example.com", "fingerprint": "chrome"}, + "hysteriaSettings": {"version": 2, "auth": "auth"}, + "finalmask": { + "quicParams": {"congestion": "brutal", "brutalUp": "50 mbps", "brutalDown": "100 mbps"}, + "udp": [{"type": "udphop", "settings": {"mode": "intervalRemote", "remotePorts": "20000-40000", "interval": "30"}}] + } + } + }] + }`, "") require.NoError(t, err) var document map[string]any @@ -87,8 +97,12 @@ func TestMarshalShareConfigPreservesHysteriaPortHopping(t *testing.T) { stream := outbound["streamSettings"].(map[string]any) finalMask := stream["finalmask"].(map[string]any) quicParams := finalMask["quicParams"].(map[string]any) - udpHop := quicParams["udpHop"].(map[string]any) - assert.Equal(t, "20000-40000", udpHop["ports"]) + assert.NotContains(t, quicParams, "udpHop") + mask := finalMask["udp"].([]any)[0].(map[string]any) + assert.Equal(t, "udphop", mask["type"]) + udpHop := mask["settings"].(map[string]any) + assert.Equal(t, "intervalRemote", udpHop["mode"]) + assert.Equal(t, "20000-40000", udpHop["remotePorts"]) assert.Equal(t, "30", udpHop["interval"]) requireProjectedOutboundsBuild(t, raw) } @@ -115,7 +129,6 @@ func TestMarshalShareConfigSupportedProtocolsBuild(t *testing.T) { "vless": "vless://" + testShareUUID + "@10.0.0.1:443?encryption=none&security=none", "socks": "socks://" + base64.StdEncoding.EncodeToString([]byte("user:password")) + "@127.0.0.1:1080", "trojan": "trojan://password@trojan.example:443?sni=trojan.example", - "hysteria": "hy2://auth@hysteria.example:443?sni=hysteria.example&fp=chrome", } for protocol, link := range tests { t.Run(protocol, func(t *testing.T) { diff --git a/share/parse_share.go b/share/parse_share.go index 0b2bac8d..07140384 100644 --- a/share/parse_share.go +++ b/share/parse_share.go @@ -44,7 +44,6 @@ func decodeBase64Text(text string) (string, error) { var shareSchemes = []string{ "vless://", "vmess://", "socks://", "ss://", "trojan://", - "hysteria2://", "hy2://", } func hasShareSchemeLine(text string) bool { @@ -59,20 +58,6 @@ func hasShareSchemeLine(text string) bool { return false } -func hasTopLevelClashProxiesKey(text string) bool { - for raw := range strings.SplitSeq(text, "\n") { - line := strings.TrimRight(raw, " \t") - trimmed := strings.TrimSpace(line) - if trimmed == "" || trimmed == "---" || strings.HasPrefix(trimmed, "#") { - continue - } - if strings.HasPrefix(line, "proxies:") { - return true - } - } - return false -} - type xrayShareLink struct { link *url.URL rawText string @@ -90,8 +75,6 @@ func (proxy xrayShareLink) outbound() (*conf.OutboundDetourConfig, error) { return proxy.socksOutbound() case "trojan": return proxy.trojanOutbound() - case "hysteria2", "hy2": - return proxy.hysteriaOutbound() default: return nil, fmt.Errorf("unsupported link: %s", proxy.rawText) } @@ -313,60 +296,3 @@ func (proxy xrayShareLink) trojanOutbound() (*conf.OutboundDetourConfig, error) outbound.StreamSetting = streamSettings return outbound, nil } - -func (proxy xrayShareLink) hysteriaOutbound() (*conf.OutboundDetourConfig, error) { - outbound := &conf.OutboundDetourConfig{} - outbound.Protocol = "hysteria" - setOutboundName(outbound, proxy.link.Fragment) - - settings := &conf.HysteriaClientConfig{} - settings.Version = 2 - settings.Address = parseAddress(proxy.link.Hostname()) - port, err := strconv.Atoi(proxy.link.Port()) - if err != nil { - return nil, err - } - settings.Port = uint16(port) - - settingsRawMessage, err := convertJsonToRawMessage(settings) - if err != nil { - return nil, err - } - outbound.Settings = &settingsRawMessage - - streamSettings := &conf.StreamConfig{} - streamSettings.Network = new(conf.TransportProtocol("hysteria")) - - hysteriaSettings := &conf.HysteriaConfig{} - hysteriaSettings.Version = 2 - auth, err := url.QueryUnescape(proxy.link.User.String()) - if err != nil { - return nil, err - } - hysteriaSettings.Auth = auth - streamSettings.HysteriaSettings = hysteriaSettings - - query := proxy.link.Query() - var hopPtr *int32 - if hopStr := query.Get("hop-interval"); hopStr != "" { - interval, pErr := strconv.ParseInt(hopStr, 10, 32) - if pErr != nil { - return nil, pErr - } - hopPtr = new(int32(interval)) - } - finalMask, mErr := buildHy2FinalMask( - query.Get("up"), query.Get("down"), query.Get("ports"), - hopPtr, query.Get("obfs"), query.Get("obfs-password"), - ) - if mErr != nil { - return nil, mErr - } - streamSettings.FinalMask = finalMask - - if err := proxy.parseSecurityFromURL(proxy.link, streamSettings); err != nil { - return nil, err - } - outbound.StreamSetting = streamSettings - return outbound, nil -} diff --git a/share/parse_share_test.go b/share/parse_share_test.go index 89f3dd5c..482276a9 100644 --- a/share/parse_share_test.go +++ b/share/parse_share_test.go @@ -17,141 +17,6 @@ func ssUserB64(cipher, password string) string { return base64.StdEncoding.EncodeToString([]byte(cipher + ":" + password)) } -func parseHy2Link(t *testing.T, link string) *conf.OutboundDetourConfig { - t.Helper() - config, err := convertShareLinksForTest(link) - require.NoError(t, err) - require.Len(t, config.OutboundConfigs, 1) - return &config.OutboundConfigs[0] -} - -func TestHysteria2_Minimal(t *testing.T) { - outbound := parseHy2Link(t, "hy2://auth@host:443?sni=example.com") - - assert.Equal(t, "hysteria", outbound.Protocol) - - var settings conf.HysteriaClientConfig - require.NoError(t, json.Unmarshal(*outbound.Settings, &settings)) - assert.Equal(t, int32(2), settings.Version) - assert.Equal(t, uint16(443), settings.Port) - - ss := outbound.StreamSetting - require.NotNil(t, ss) - assert.Equal(t, "tls", ss.Security) - require.NotNil(t, ss.TLSSettings) - assert.Equal(t, "example.com", ss.TLSSettings.ServerName) - - require.NotNil(t, ss.HysteriaSettings) - assert.Equal(t, int32(2), ss.HysteriaSettings.Version) - assert.Equal(t, "auth", ss.HysteriaSettings.Auth) - - // No FinalMask for minimal link - assert.Nil(t, ss.FinalMask) -} - -func TestHysteria2_WithBandwidth(t *testing.T) { - outbound := parseHy2Link(t, "hy2://auth@host:443?up=100+mbps&down=200+mbps&sni=example.com") - - ss := outbound.StreamSetting - require.NotNil(t, ss) - require.NotNil(t, ss.FinalMask) - require.NotNil(t, ss.FinalMask.QuicParams) - - qp := ss.FinalMask.QuicParams - assert.Equal(t, "brutal", qp.Congestion) - assert.Equal(t, conf.Bandwidth("100 mbps"), qp.BrutalUp) - assert.Equal(t, conf.Bandwidth("200 mbps"), qp.BrutalDown) - - // No Salamander - assert.Empty(t, ss.FinalMask.Udp) -} - -func TestHysteria2_WithSalamander(t *testing.T) { - outbound := parseHy2Link(t, "hy2://auth@host:443?obfs=salamander&obfs-password=secret&sni=example.com") - - ss := outbound.StreamSetting - require.NotNil(t, ss) - require.NotNil(t, ss.FinalMask) - - // No QuicParams - assert.Nil(t, ss.FinalMask.QuicParams) - - // Has Salamander - require.Len(t, ss.FinalMask.Udp, 1) - assert.Equal(t, "salamander", ss.FinalMask.Udp[0].Type) - - var salamander conf.Salamander - require.NoError(t, json.Unmarshal(*ss.FinalMask.Udp[0].Settings, &salamander)) - assert.Equal(t, "secret", salamander.Password) -} - -func TestHysteria2_WithEverything(t *testing.T) { - outbound := parseHy2Link(t, "hy2://auth@host:443?up=50+mbps&down=100+mbps&obfs=salamander&obfs-password=secret&ports=20000-40000&hop-interval=30&sni=example.com") - - ss := outbound.StreamSetting - require.NotNil(t, ss) - require.NotNil(t, ss.FinalMask) - - // QuicParams with bandwidth and port-hopping - require.NotNil(t, ss.FinalMask.QuicParams) - qp := ss.FinalMask.QuicParams - assert.Equal(t, "brutal", qp.Congestion) - assert.Equal(t, conf.Bandwidth("50 mbps"), qp.BrutalUp) - assert.Equal(t, conf.Bandwidth("100 mbps"), qp.BrutalDown) - - // UdpHop - assert.Equal(t, "20000-40000", qp.UdpHop.PortList.String()) - assert.Equal(t, int32(30), qp.UdpHop.Interval.From) - assert.Equal(t, int32(30), qp.UdpHop.Interval.To) - - // Salamander - require.Len(t, ss.FinalMask.Udp, 1) - assert.Equal(t, "salamander", ss.FinalMask.Udp[0].Type) -} - -func TestHysteria2_WithTLSParams(t *testing.T) { - outbound := parseHy2Link(t, "hy2://auth@host:443?sni=example.com&alpn=h3&fp=chrome") - - ss := outbound.StreamSetting - require.NotNil(t, ss) - assert.Equal(t, "tls", ss.Security) - require.NotNil(t, ss.TLSSettings) - assert.Equal(t, "example.com", ss.TLSSettings.ServerName) - assert.Equal(t, "chrome", ss.TLSSettings.Fingerprint) - require.NotNil(t, ss.TLSSettings.ALPN) - assert.Equal(t, conf.StringList{"h3"}, *ss.TLSSettings.ALPN) -} - -func TestHysteria2_PortsOnlyNoCongestion(t *testing.T) { - outbound := parseHy2Link(t, "hy2://auth@host:443?ports=20000-40000&hop-interval=10&sni=example.com") - - ss := outbound.StreamSetting - require.NotNil(t, ss) - require.NotNil(t, ss.FinalMask) - require.NotNil(t, ss.FinalMask.QuicParams) - - qp := ss.FinalMask.QuicParams - // No Congestion when only ports are set (no bandwidth) - assert.Empty(t, qp.Congestion) - assert.Empty(t, string(qp.BrutalUp)) - assert.Empty(t, string(qp.BrutalDown)) - - // UdpHop is set - assert.Equal(t, "20000-40000", qp.UdpHop.PortList.String()) - assert.Equal(t, int32(10), qp.UdpHop.Interval.From) - assert.Equal(t, int32(10), qp.UdpHop.Interval.To) -} - -func TestHysteria2_TLSDefaultWhenSecurityOmitted(t *testing.T) { - // When no security= param is present, hysteria2 should default to TLS - outbound := parseHy2Link(t, "hy2://auth@host:443?sni=example.com") - - ss := outbound.StreamSetting - require.NotNil(t, ss) - assert.Equal(t, "tls", ss.Security) - require.NotNil(t, ss.TLSSettings) -} - func TestFixWindowsReturn(t *testing.T) { in := "a\r\nb\r\nc" assert.Equal(t, "a\nb\nc", FixWindowsReturn(in)) @@ -436,11 +301,6 @@ func TestConvertShareLinksToXrayJson_FinalMaskQuery(t *testing.T) { assert.Equal(t, "noise", ss.FinalMask.Udp[0].Type) } -func TestConvertShareLinksToXrayJson_Hysteria2InvalidHop(t *testing.T) { - _, err := convertShareLinksForTest("hy2://auth@host:443?hop-interval=notint&sni=x.com") - require.Error(t, err) -} - func TestConvertShareLinksToXrayJson_MultiLineSkipsBad(t *testing.T) { bad := "vmess://" + testShareUUID + "@bad.example:notaport?encryption=none" good := "vless://" + testShareUUID + "@ok.example:443?encryption=none" @@ -487,51 +347,12 @@ func TestConvertShareLinksToXrayJson_Base64EncodedJSON(t *testing.T) { assert.Equal(t, "vless", cfg.OutboundConfigs[0].Protocol) } -func TestConvertShareLinksToXrayJson_Base64EncodedClashYAML(t *testing.T) { - yaml := `proxies: - - name: clash-ss - type: ss - server: c.example - port: 8390 - cipher: aes-256-gcm - password: yamlpw` - cfg, err := convertShareLinksForTest(base64.StdEncoding.EncodeToString([]byte(yaml))) - require.NoError(t, err) - require.Len(t, cfg.OutboundConfigs, 1) - assert.Equal(t, "shadowsocks", cfg.OutboundConfigs[0].Protocol) -} - func TestConvertShareLinksToXrayJson_UnsupportedFormat(t *testing.T) { _, err := convertShareLinksForTest("this is not a supported subscription format") require.Error(t, err) assert.Contains(t, err.Error(), "unsupported share format") } -func TestConvertShareLinksToXrayJson_RawClashYAML(t *testing.T) { - yaml := `proxies: - - name: clash-ss - type: ss - server: c.example - port: 8390 - cipher: aes-256-gcm - password: yamlpw` - cfg, err := convertShareLinksForTest(yaml) - require.NoError(t, err) - require.Len(t, cfg.OutboundConfigs, 1) - assert.Equal(t, "shadowsocks", cfg.OutboundConfigs[0].Protocol) -} - -func TestConvertShareLinksToXrayJson_ClashYAMLNoValidOutbound(t *testing.T) { - yaml := `proxies: - - name: unsupported - type: unsupported - server: c.example - port: 8390` - _, err := convertShareLinksForTest(yaml) - require.Error(t, err) - assert.Contains(t, err.Error(), "no valid outbound found") -} - func TestConvertShareLinksToXrayJson_VmessQRGrpcAndKcp(t *testing.T) { t.Run("grpc", func(t *testing.T) { qr := `{"ps":"g","add":"grpc.host","port":"443","id":"` + testShareUUID + `","net":"grpc","path":"svcname","type":"multi"}` diff --git a/share/stream.go b/share/stream.go index d6b822ee..0e272979 100644 --- a/share/stream.go +++ b/share/stream.go @@ -61,7 +61,7 @@ func (proxy xrayShareLink) parseSecurityFromURL(link *url.URL, streamSettings *c } switch proxy.link.Scheme { - case "trojan", "hysteria2", "hy2": + case "trojan": if streamSettings.Security == "none" { streamSettings.Security = "tls" } diff --git a/xray/ping_batch.go b/xray/ping_batch.go index 2014dc2d..0fcf010e 100644 --- a/xray/ping_batch.go +++ b/xray/ping_batch.go @@ -260,10 +260,6 @@ func preparePingOutbounds( namespacedTags[dependencyIndex] } } - if outbound.ProxySettings != nil && outbound.ProxySettings.Tag != "" { - dependencyIndex := tagIndexes[outbound.ProxySettings.Tag] - outbound.ProxySettings.Tag = namespacedTags[dependencyIndex] - } if _, err := outbound.Build(); err != nil { return nil, "", fmt.Errorf( @@ -336,7 +332,7 @@ func collectPingOutboundDependencies( } func pingOutboundDependencyTags(outbound conf.OutboundDetourConfig) []string { - dependencies := make([]string, 0, 2) + dependencies := make([]string, 0, 1) if outbound.StreamSetting != nil && outbound.StreamSetting.SocketSettings != nil && outbound.StreamSetting.SocketSettings.DialerProxy != "" { @@ -345,9 +341,6 @@ func pingOutboundDependencyTags(outbound conf.OutboundDetourConfig) []string { outbound.StreamSetting.SocketSettings.DialerProxy, ) } - if outbound.ProxySettings != nil && outbound.ProxySettings.Tag != "" { - dependencies = append(dependencies, outbound.ProxySettings.Tag) - } return dependencies } diff --git a/xray/ping_batch_test.go b/xray/ping_batch_test.go index ece744e8..40c16ece 100644 --- a/xray/ping_batch_test.go +++ b/xray/ping_batch_test.go @@ -127,25 +127,17 @@ func TestPreparePingOutboundsSelectsAndRewritesDependencies(t *testing.T) { } } -func TestPreparePingOutboundsRewritesProxySettings(t *testing.T) { - outbounds := []conf.OutboundDetourConfig{ - { - Protocol: "freedom", - Tag: "proxy", - ProxySettings: &conf.ProxyConfig{Tag: "transport"}, - }, - { - Protocol: "freedom", - Tag: "transport", - }, - } - - prepared, _, err := preparePingOutbounds(outbounds, "proxy", 1) +func TestPreparePingOutboundsRejectsRemovedProxySettings(t *testing.T) { + outbounds, err := readPingOutbounds(`{"outbounds":[ + {"protocol":"freedom","tag":"proxy","proxySettings":{"tag":"transport"}}, + {"protocol":"freedom","tag":"transport"} + ]}`) if err != nil { t.Fatal(err) } - if got, want := prepared[0].ProxySettings.Tag, prepared[1].Tag; got != want { - t.Fatalf("proxySettings.tag = %q, want %q", got, want) + _, _, err = preparePingOutbounds(outbounds, "proxy", 1) + if err == nil || !strings.Contains(err.Error(), "proxySettings") { + t.Fatalf("expected Xray-core to reject proxySettings, got %v", err) } } From 5bc823f47079b9bbaf421dbca5cf8f78e1d27e04 Mon Sep 17 00:00:00 2001 From: yiguodev <147401898+yiguodev@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:50:33 +0800 Subject: [PATCH 19/38] Validate Xray configurations through instance construction (#153) * Validate Xray configurations through instance construction * docs: configure engineering skills --- AGENTS.md | 27 ++++++++++++------ README.md | 33 ++++++++++++++-------- docs/agents/domain.md | 23 ++++++++++++++++ docs/agents/issue-tracker.md | 50 ++++++++++++++++++++++++++++++++++ docs/agents/triage-labels.md | 11 ++++++++ invoke_test.go | 27 +++++++++++------- readme/README.zh_CN.md | 20 +++++++++----- xray/validation.go | 16 +++++------ xray/validation_test.go | 53 ++++++++++++++++++++++++++++++++++++ 9 files changed, 215 insertions(+), 45 deletions(-) create mode 100644 docs/agents/domain.md create mode 100644 docs/agents/issue-tracker.md create mode 100644 docs/agents/triage-labels.md create mode 100644 xray/validation_test.go diff --git a/AGENTS.md b/AGENTS.md index 196c6286..49b6b5d5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,9 +14,10 @@ and the models/dispatch in `invoke_model.go` and `invoke.go`. - Applications use `Invoke`/`CGoInvoke` with typed requests. Config methods receive `xrayJson` text, not configuration file paths. Runtime `env` belongs inside that Xray JSON. File-oriented APIs and the desktop Core CLI retain file access. -- `TestXray` only loads/builds configuration with `core.LoadConfig`; it neither - constructs nor starts an instance. Success does not guarantee startup or - connectivity. Builders may still read local assets/certificates and apply `env`. +- `TestXray` constructs an instance through `newXrayInstance` and closes it + without calling `Start`. Callers own minimal/filtered validation configs. + Process-level side effects are accepted; startup resources and connectivity + remain outside this check. See [testXray](README.md#testxray) for limitations. - Manage one running instance. Validation and temporary instances must reject managed-instance overlap before loading configuration and hold the lifecycle lock through worker completion and instance cleanup. Close temporary instances @@ -54,12 +55,22 @@ python3 build/main.py apple go Other targets and local-core options are documented in [build usage](README.md#usage). -## Pull requests +## Agent skills -Use English for PR titles and descriptions. Keep titles, descriptions, and -comments self-contained: do not mention or link to another repository's PR, -including companion, dependency, or merge-order references. Describe required -interface or build behavior directly. +### Issue tracker + +GitHub Issues for `XTLS/libXray`. Before issue or PR work, read +[issue tracker](docs/agents/issue-tracker.md). + +### Triage labels + +Use the five canonical triage labels. Before triage, read +[label mapping](docs/agents/triage-labels.md). + +### Domain docs + +Single-context layout. Before codebase exploration or domain/ADR work, +read [domain guidance](docs/agents/domain.md). ## Verification diff --git a/README.md b/README.md index d3d7db39..f8404412 100644 --- a/README.md +++ b/README.md @@ -452,7 +452,7 @@ failure and do not perform either request. ### testXray -Loads and builds the complete configuration from the supplied JSON text. The +Loads the supplied JSON text and constructs a temporary Xray instance. The payload contains only `xrayJson`; success returns `data: {}`: ```json @@ -465,17 +465,26 @@ payload contains only `xrayJson`; success returns `data: {}`: } ``` -The Go entrypoint `TestXray` uses `core.LoadConfig` without constructing or -starting an Xray instance or runtime handlers. It validates configuration -structure, including TUN/WireGuard definitions, without creating devices, -listeners, log files, or background connections. The builder can still read -local GeoData/certificates and apply the root `env` to the current process. -Geodata asset declarations validate HTTPS URLs and existing local files; their -downloader/cron does not run during validation. - -A successful check establishes that the configuration builds. It does not -prove that runtime resources are available, that an instance can start, or -that the network is reachable. Callers must handle actual startup failures. +The Go entrypoint `TestXray` calls `newXrayInstance` (`core.LoadConfig` followed +by `core.New`) and closes the successfully constructed instance before returning. +It never calls `Start` or publishes a managed instance. Core construction errors, +including invalid routing matchers and missing balancers, and close errors are +returned through the normal error response. + +This is not a sandbox. Builders and constructors may read local GeoData and +certificates, apply `env`, replace process-global logging/DNS state, create log +files, or initialize protocol-specific resources and background tasks. Process +state is not restored. In particular, WireGuard can acquire a TUN during +construction and VLESS reverse can schedule background work. The current Core +does not return its partial instance when construction fails, so libXray cannot +close that partial instance. Core-managed geodata cron does not run without +`Start`, although its asset declarations still check local files. + +Callers may provide a minimal configuration or remove App-managed fields from a +disposable validation copy; libXray applies no App-specific filtering. Only the +supplied configuration is checked. Success establishes instance construction and +close, not listener/TUN startup, system permissions, or network connectivity. +Callers must handle actual startup failures. ### runXray diff --git a/docs/agents/domain.md b/docs/agents/domain.md new file mode 100644 index 00000000..a06be581 --- /dev/null +++ b/docs/agents/domain.md @@ -0,0 +1,23 @@ +# Domain Docs + +This repository uses a single-context layout: domain terminology lives in +`CONTEXT.md` at the repository root, and architecture decisions live in +`docs/adr/`. + +## Reading rules + +- Before exploring the codebase, read the root `CONTEXT.md` when it exists. +- Before changing an implementation, read ADRs under `docs/adr/` that affect + the area being changed. +- If these files do not exist, proceed silently without creating setup + placeholders. The `/domain-modeling` skill creates them when terminology + or decisions actually need recording. + +## Vocabulary and decisions + +- Use terms defined in `CONTEXT.md`; avoid synonyms the glossary explicitly + rejects. +- If a needed concept is missing, first check whether it is existing project + terminology. Leave genuine gaps for `/domain-modeling` to resolve. +- When a proposal contradicts an existing ADR, explicitly identify the + conflict and the reasoning instead of silently overriding the decision. diff --git a/docs/agents/issue-tracker.md b/docs/agents/issue-tracker.md new file mode 100644 index 00000000..ccce19ae --- /dev/null +++ b/docs/agents/issue-tracker.md @@ -0,0 +1,50 @@ +# Issue tracker: GitHub + +Issues and specs live in GitHub Issues for `XTLS/libXray`. + +Use the `gh` CLI. Because this clone's `origin` uses `yiguo.dev`, +include `--repo XTLS/libXray` in every `gh issue` and `gh pr` command. +For `gh api`, use explicit `repos/XTLS/libXray/...` endpoints. + +## Conventions + +- Use English for issue and PR titles, descriptions, and GitHub comments. +- Keep PR titles, descriptions, and comments self-contained. Do not mention + or link to another repository's PR, including companion, dependency, or + merge-order references. Describe required interface or build behavior directly. +- Use [triage-labels.md](triage-labels.md) for canonical triage roles. +- Use a heredoc for multiline bodies. + +## Issue operations + +- Publish a ticket: `gh issue create --repo XTLS/libXray --title "..." --body "..."` +- Fetch a ticket: `gh issue view --repo XTLS/libXray --comments` +- List tickets: `gh issue list --repo XTLS/libXray --state open --json number,title,body,labels,comments` +- Comment: `gh issue comment --repo XTLS/libXray --body "..."` +- Add or remove labels: `gh issue edit --repo XTLS/libXray --add-label "..."` or `--remove-label "..."` +- Close: `gh issue close --repo XTLS/libXray --comment "..."` + +GitHub shares one number space across issues and PRs. Resolve an ambiguous +number with `gh pr view --repo XTLS/libXray`, falling back to +`gh issue view --repo XTLS/libXray`. + +## Pull requests as a triage surface + +**PRs as a request surface: no.** + +## Wayfinding operations + +Used by `/wayfinder`. The map is one issue with child issues as tickets. + +- Map: label it `wayfinder:map`. +- Child ticket: link it as a GitHub sub-issue. If unavailable, add it to the + map's task list and put `Part of #` at the top of the child body. +- Child labels: `wayfinder:research`, `wayfinder:prototype`, + `wayfinder:grilling`, or `wayfinder:task`. +- Blocking: use GitHub issue dependencies. If unavailable, put + `Blocked by: #` at the top of the child body. +- Frontier: choose the first open, unassigned child in map order without + an open blocker. +- Claim: `gh issue edit --repo XTLS/libXray --add-assignee @me` +- Resolve: comment with the answer, close the child, then add a context + pointer to the map's Decisions-so-far. diff --git a/docs/agents/triage-labels.md b/docs/agents/triage-labels.md new file mode 100644 index 00000000..fe317920 --- /dev/null +++ b/docs/agents/triage-labels.md @@ -0,0 +1,11 @@ +# Triage Labels + +| Canonical role | GitHub label | Meaning | +| --- | --- | --- | +| `needs-triage` | `needs-triage` | Waiting for maintainer evaluation | +| `needs-info` | `needs-info` | Waiting for the reporter to provide more information | +| `ready-for-agent` | `ready-for-agent` | Requirements are clear; ready for an agent | +| `ready-for-human` | `ready-for-human` | Requires human implementation | +| `wontfix` | `wontfix` | Not planned | + +When a skill mentions a canonical role, use its corresponding GitHub label. diff --git a/invoke_test.go b/invoke_test.go index 9b4a32bf..fff68881 100644 --- a/invoke_test.go +++ b/invoke_test.go @@ -211,19 +211,15 @@ func TestInvokeTestXray(t *testing.T) { requireNoDataObject(t, response) } -func TestInvokeTestXrayDoesNotCreateRuntimeResources(t *testing.T) { - logPath := filepath.Join(t.TempDir(), "not-created", "error.log") +func TestInvokeTestXrayConstructsWithoutStarting(t *testing.T) { + logPath := filepath.Join(t.TempDir(), "error.log") config, err := json.Marshal(map[string]any{ "log": map[string]any{"error": logPath, "loglevel": "debug"}, "inbounds": []any{ map[string]any{"tag": "tunIn", "protocol": "tun", "settings": map[string]any{"name": "TestXrayMustNotCreate", "mtu": 1500}}, }, "outbounds": []any{ - map[string]any{"protocol": "wireguard", "settings": map[string]any{ - "secretKey": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", - "address": []string{"10.0.0.2/32"}, - "peers": []any{map[string]any{"publicKey": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", "endpoint": "127.0.0.1:9"}}, - }}, + map[string]any{"protocol": "freedom", "tag": "direct"}, }, }) if err != nil { @@ -231,11 +227,11 @@ func TestInvokeTestXrayDoesNotCreateRuntimeResources(t *testing.T) { } response := invokeForTest(t, LibXrayMethodTestXray, TestXrayRequest{XrayJson: string(config)}) if !response.Success { - t.Fatalf("testXray must accept structurally valid TUN/WireGuard without construction: %s", response.Err) + t.Fatalf("testXray must construct handlers without starting the TUN: %s", response.Err) } requireNoDataObject(t, response) - if _, err := os.Stat(filepath.Dir(logPath)); !os.IsNotExist(err) { - t.Fatalf("testXray created a runtime log directory: %v", err) + if _, err := os.Stat(logPath); err != nil { + t.Fatalf("testXray did not construct the configured file logger: %v", err) } response = invokeForTest(t, LibXrayMethodTestXray, TestXrayRequest{XrayJson: `{"outbounds":[{"protocol":"unknown"}]}`}) if response.Success || string(response.Data) != "null" { @@ -243,6 +239,17 @@ func TestInvokeTestXrayDoesNotCreateRuntimeResources(t *testing.T) { } } +func TestInvokeTestXrayReturnsConstructionError(t *testing.T) { + response := invokeForTest(t, LibXrayMethodTestXray, TestXrayRequest{XrayJson: `{ + "log":{"loglevel":"none"}, + "outbounds":[{"protocol":"freedom","tag":"direct"}], + "routing":{"rules":[{"domain":["example.com"],"balancerTag":"missing"}]} + }`}) + if response.Success || string(response.Data) != "null" || !strings.Contains(response.Err, "balancer missing not found") { + t.Fatalf("testXray must expose the core construction error: %+v", response) + } +} + func TestInvokeTestXrayDoesNotReadConfigPath(t *testing.T) { configPath := filepath.Join(t.TempDir(), "xray.json") configJSON, err := json.Marshal(testXrayConfig(t)) diff --git a/readme/README.zh_CN.md b/readme/README.zh_CN.md index da892a1c..0170ff3c 100644 --- a/readme/README.zh_CN.md +++ b/readme/README.zh_CN.md @@ -355,7 +355,7 @@ GET 成功后把响应正文原样放入 `locationJson` 字符串。JSON 解析 ### testXray -加载并构建传入的完整 Xray JSON 文本。payload 仅包含 `xrayJson`,成功时返回 +加载传入的 Xray JSON 文本并构造临时 instance。payload 仅包含 `xrayJson`,成功时返回 `data: {}`: ```json @@ -368,13 +368,19 @@ GET 成功后把响应正文原样放入 `locationJson` 字符串。JSON 解析 } ``` -Go 入口 `TestXray` 只调用 `core.LoadConfig`,不构造或启动 Xray instance 及运行时 -handler。它校验包括 TUN/WireGuard 定义在内的配置结构,不创建设备、监听、日志文件 -或后台连接。构建器仍可能读取本地 GeoData/证书,并将根 `env` 应用到当前进程。 -Geodata assets 声明只校验 HTTPS URL 和已存在的本地文件,下载器及 cron 不在校验时运行。 +Go 入口 `TestXray` 调用 `newXrayInstance`(`core.LoadConfig → core.New`),成功构造后 +关闭临时 instance,再返回结果。不调用 `Start`,也不登记为受管理的运行 instance。 +路由匹配器、缺失 balancer 等构造错误以及关闭错误通过正常错误响应返回。 -校验成功只说明配置可以构建,不保证运行资源可用、instance 可以启动或网络可以连接。 -调用方仍须处理实际启动失败。 +校验不是沙箱:构建器和构造器可能读取本地 GeoData/证书、应用 `env`、替换进程级 +日志/DNS 状态、创建日志文件,或初始化协议专属资源与后台任务。这些进程状态不恢复。 +WireGuard 可能在构造时创建 TUN,VLESS reverse 可能安排后台任务。当前 Core 构造失败时 +不返回部分 instance,因此 libXray 无法关闭该部分 instance。不调用 `Start` 时,内核 +Geodata cron 不运行,但 assets 声明仍会检查本地文件。 + +调用方允许构造最小配置,或在验证副本中排除 App 管理的字段;libXray 不做 App 专属裁剪。 +成功只说明传入配置可以完成实例构造和关闭,不覆盖监听/TUN 启动、系统权限与网络连通性。 +被排除的配置不在验证范围内,调用方仍须处理实际启动失败。 ### runXray diff --git a/xray/validation.go b/xray/validation.go index 23c6ff4c..cd84efd5 100644 --- a/xray/validation.go +++ b/xray/validation.go @@ -2,20 +2,20 @@ package xray import ( "errors" - "strings" - - "github.com/xtls/xray-core/core" ) -// TestXray only builds the configuration; it does not instantiate handlers. -// The core builder can read local assets/certificates and apply root env values. -// Success does not guarantee that the configuration can start. +// TestXray constructs and closes an instance without calling Start. +// Constructors may change process state and acquire resources. Callers own any +// configuration projection; startup resources and connectivity are not tested. func TestXray(xrayJSON string) error { coreServerMu.Lock() defer coreServerMu.Unlock() if coreServer != nil { return errors.New("testXray requires an isolated process without a managed Xray instance") } - _, err := core.LoadConfig("json", strings.NewReader(xrayJSON)) - return err + server, err := newXrayInstance(xrayJSON) + if err != nil { + return err + } + return server.Close() } diff --git a/xray/validation_test.go b/xray/validation_test.go new file mode 100644 index 00000000..749d9584 --- /dev/null +++ b/xray/validation_test.go @@ -0,0 +1,53 @@ +package xray + +import ( + "fmt" + "net" + "strings" + "testing" + + "github.com/xtls/xray-core/core" +) + +func TestTestXrayRejectsConstructionErrors(t *testing.T) { + for name, rule := range map[string]string{ + "missing balancer": `{"domain":["example.com"],"balancerTag":"missing"}`, + "invalid regexp": `{"domain":["regexp:["],"outboundTag":"direct"}`, + } { + t.Run(name, func(t *testing.T) { + config := `{"log":{"loglevel":"none"},"outbounds":[{"protocol":"freedom","tag":"direct"}],"routing":{"rules":[` + rule + `]}}` + if _, err := core.LoadConfig("json", strings.NewReader(config)); err != nil { + t.Fatalf("fixture must pass the old build-only check: %v", err) + } + if err := TestXray(config); err == nil { + t.Fatal("construction error was accepted") + } + if GetXrayState() { + t.Fatal("validation published a managed instance") + } + }) + } +} + +func TestTestXrayDoesNotBindPorts(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = listener.Close() }) + port := listener.Addr().(*net.TCPAddr).Port + config := fmt.Sprintf(`{ + "log":{"loglevel":"none"}, + "stats":{},"metrics":{"listen":"127.0.0.1:%d"}, + "inbounds":[{"listen":"127.0.0.1","port":%d,"protocol":"socks"}], + "outbounds":[{"protocol":"freedom","tag":"direct"}] + }`, port, port) + for range 2 { + if err := TestXray(config); err != nil { + t.Fatalf("validation must not start listeners: %v", err) + } + if GetXrayState() { + t.Fatal("validation left a managed instance") + } + } +} From 4ef9690d66d2eb1cecd54515bdca6099fdb1b18f Mon Sep 17 00:00:00 2001 From: yiguodev <147401898+yiguodev@users.noreply.github.com> Date: Sat, 12 Sep 2026 00:24:35 +0800 Subject: [PATCH 20/38] feat: add an optional desktop Core error output file (#154) --- README.md | 12 +++- desktop_bin/command.go | 79 ++++++++++++++++++++++++ desktop_bin/command_test.go | 116 ++++++++++++++++++++++++++++++++++++ desktop_bin/main.go | 55 +---------------- desktop_bin/main_test.go | 24 -------- readme/README.zh_CN.md | 10 +++- 6 files changed, 214 insertions(+), 82 deletions(-) create mode 100644 desktop_bin/command.go create mode 100644 desktop_bin/command_test.go delete mode 100644 desktop_bin/main_test.go diff --git a/README.md b/README.md index f8404412..e6a79ae3 100644 --- a/README.md +++ b/README.md @@ -74,12 +74,20 @@ 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 +xray run -dns -interface -config [-error-file ] ``` -All three options are required. `-dns` must be an IP endpoint, and `-config` +The `-dns`, `-interface`, and `-config` options are required. `-dns` must be an IP endpoint, and `-config` points directly to the Xray JSON configuration. +The optional `-error-file` also writes command failures to a UTF-8 file before +exiting, preserving the same error printed to stderr. The file is cleared before +running; a successful run leaves it empty. Its parent directory must exist. +Callers launching an elevated Core should create the file first under their own +account so they retain read access. This is an error-return channel, not Xray's +access/error log configuration, and it does not add a separate validation pass. +Applications using this option must bundle a desktop Core built with its support. + > [!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/desktop_bin/command.go b/desktop_bin/command.go new file mode 100644 index 00000000..6ec76cd5 --- /dev/null +++ b/desktop_bin/command.go @@ -0,0 +1,79 @@ +//go:build windows || (linux && !android) + +package main + +import ( + "errors" + "flag" + "fmt" + "io" + "os" +) + +type runOptions struct { + dns string + interfaceName string + configPath string + errorFile 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") + flags.StringVar(&options.errorFile, "error-file", "", "also write command errors to this file") + 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 execute(args []string, run func(runOptions) error, stdout, stderr io.Writer) int { + usage := func() { + fmt.Fprintln(stdout, "Usage: xray run -dns -interface -config [-error-file ]") + } + if len(args) == 1 && (args[0] == "-h" || args[0] == "--help") { + usage() + return 0 + } + + options, err := parseRunOptions(args) + if errors.Is(err, flag.ErrHelp) { + usage() + return 0 + } + if err == nil && options.errorFile != "" { + // Clear the previous failure before starting. Reuse a caller-created file + // so an elevated process preserves the caller's read permissions. + if err := os.WriteFile(options.errorFile, nil, 0600); err != nil { + fmt.Fprintln(stderr, err) + return 1 + } + } + if err == nil { + err = run(options) + } + if err != nil { + fmt.Fprintln(stderr, err) + if options.errorFile != "" { + if writeErr := os.WriteFile(options.errorFile, []byte(err.Error()), 0600); writeErr != nil { + fmt.Fprintln(stderr, writeErr) + } + } + return 1 + } + return 0 +} diff --git a/desktop_bin/command_test.go b/desktop_bin/command_test.go new file mode 100644 index 00000000..adf2e8cf --- /dev/null +++ b/desktop_bin/command_test.go @@ -0,0 +1,116 @@ +//go:build windows || (linux && !android) + +package main + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "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") + } +} + +func TestCommandReportsActualStartupError(t *testing.T) { + directory := commandTestDirectory(t) + path := filepath.Join(directory, "xray.json.error") + if err := os.WriteFile(path, nil, 0600); err != nil { + t.Fatal(err) + } + args := []string{"run", "-dns", "8.8.8.8:53", "-interface", "Ethernet", "-config", "xray.json", "-error-file", path} + want := "failed to load geosite: category TEST-MISSING not found" + var stdout, stderr bytes.Buffer + called := false + code := execute(args, func(runOptions) error { + called = true + return errors.New(want) + }, &stdout, &stderr) + if code != 1 || !called { + t.Fatalf("run was not attempted: code=%d, called=%v, stderr=%q", code, called, stderr.String()) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(data) != want || stderr.String() != want+"\n" { + t.Fatalf("diagnostics differ: file=%q, stderr=%q", data, stderr.String()) + } + if stdout.Len() != 0 { + t.Fatalf("unexpected stdout: %s", &stdout) + } +} + +func TestCommandClearsStaleErrorBeforeRun(t *testing.T) { + path := filepath.Join(commandTestDirectory(t), "xray.json.error") + if err := os.WriteFile(path, []byte("previous failure"), 0600); err != nil { + t.Fatal(err) + } + args := []string{"run", "-dns", "8.8.8.8:53", "-interface", "Ethernet", "-config", "xray.json", "-error-file", path} + var stdout, stderr bytes.Buffer + code := execute(args, func(runOptions) error { + data, err := os.ReadFile(path) + if err != nil || len(data) != 0 { + t.Fatalf("stale error remains before run: %q, err=%v", data, err) + } + return nil + }, &stdout, &stderr) + if code != 0 || stderr.Len() != 0 { + t.Fatalf("successful run failed: code=%d, stderr=%q", code, stderr.String()) + } +} + +func TestCommandWithoutErrorFilePreservesStderr(t *testing.T) { + args := []string{"run", "-dns", "8.8.8.8:53", "-interface", "Ethernet", "-config", "xray.json"} + var stdout, stderr bytes.Buffer + code := execute(args, func(runOptions) error { + return errors.New("actual startup error") + }, &stdout, &stderr) + if code != 1 || stderr.String() != "actual startup error\n" { + t.Fatalf("unexpected error: code=%d, stderr=%q", code, stderr.String()) + } +} + +func TestCommandRejectsUnwritableErrorFileBeforeRun(t *testing.T) { + path := filepath.Join(commandTestDirectory(t), "missing", "error") + args := []string{"run", "-dns", "8.8.8.8:53", "-interface", "Ethernet", "-config", "xray.json", "-error-file", path} + var stdout, stderr bytes.Buffer + code := execute(args, func(runOptions) error { + t.Fatal("run must not start when its error file cannot be prepared") + return nil + }, &stdout, &stderr) + if code != 1 || !bytes.Contains(stderr.Bytes(), []byte(path)) { + t.Fatalf("missing file error: code=%d, stderr=%q", code, stderr.String()) + } +} + +func commandTestDirectory(t *testing.T) string { + t.Helper() + root := filepath.Join("..", "..", "references", "libxray-desktop-tests") + if err := os.MkdirAll(root, 0700); err != nil { + t.Fatal(err) + } + directory, err := os.MkdirTemp(root, "command-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.RemoveAll(directory) }) + return directory +} diff --git a/desktop_bin/main.go b/desktop_bin/main.go index 33ba90af..a6433ddc 100644 --- a/desktop_bin/main.go +++ b/desktop_bin/main.go @@ -3,10 +3,6 @@ package main import ( - "errors" - "flag" - "fmt" - "io" "os" "os/signal" "syscall" @@ -15,35 +11,6 @@ import ( "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 { @@ -65,26 +32,6 @@ func run(options runOptions) error { 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) - } + os.Exit(execute(os.Args[1:], run, os.Stdout, os.Stderr)) } diff --git a/desktop_bin/main_test.go b/desktop_bin/main_test.go deleted file mode 100644 index 8d77f4d0..00000000 --- a/desktop_bin/main_test.go +++ /dev/null @@ -1,24 +0,0 @@ -//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/readme/README.zh_CN.md b/readme/README.zh_CN.md index 0170ff3c..701d652c 100644 --- a/readme/README.zh_CN.md +++ b/readme/README.zh_CN.md @@ -42,12 +42,18 @@ Linux 和 Windows 构建还会生成 `bin/xray` 或 `bin/xray.exe`。该会话 C 会保护 Go DNS 查询不被 VPN 路由重新捕获,并且只接受以下命令: ```shell -xray run -dns -interface <网卡名> -config +xray run -dns -interface <网卡名> -config [-error-file <路径>] ``` -三个参数都必须提供。`-dns` 必须是 IP endpoint,`-config` 直接指向 Xray +`-dns`、`-interface`、`-config` 均必须提供。`-dns` 必须是 IP endpoint,`-config` 直接指向 Xray JSON 配置。 +可选的 `-error-file` 将命令失败时打印到 stderr 的原始错误同时写入 UTF-8 文件, +随后退出。运行前清空文件,成功时保持为空;父目录必须已经存在。通过提权启动 Core +的调用方应先以自身账号创建文件,以保留读取权限。该文件仅用于返回错误,不是 +Xray 的 access/error 日志配置,也不增加额外的预校验。使用此参数的 App 必须同时 +打包支持该参数的新版桌面 Core。 + > [!WARNING] > **每个进程只能使用一个 Go runtime。** Go 不支持在同一进程中加载多个独立构建的 > Go runtime。libXray 的所有原生产物都会嵌入 Go runtime,无论它们通过 cgo 还是 From c8bd6ec3f59963c16688745ec641344ccde20e24 Mon Sep 17 00:00:00 2001 From: yiguodev <147401898+yiguodev@users.noreply.github.com> Date: Sun, 13 Sep 2026 18:27:31 +0800 Subject: [PATCH 21/38] Complete share-link fields and remove legacy VMess QR support (#155) * Add bug report and feature request issue forms * Simplify agent guidance and remove obsolete skill docs * Remove legacy VMessQrCode share-link support * Complete VMessAEAD and VLESS share field support --- .github/ISSUE_TEMPLATE/bug_report.yml | 89 ++++++++++ .github/ISSUE_TEMPLATE/config.yml | 8 + .github/ISSUE_TEMPLATE/feature_request.yml | 41 +++++ AGENTS.md | 42 ++--- README.md | 27 ++- docs/agents/domain.md | 23 --- docs/agents/issue-tracker.md | 50 ------ docs/agents/triage-labels.md | 11 -- invoke_test.go | 20 +++ readme/README.zh_CN.md | 26 ++- share/convert_share_test.go | 6 + share/generate_share.go | 67 ++++--- share/generate_share_test.go | 26 ++- share/marshal_share.go | 9 +- share/marshal_share_test.go | 3 +- share/parse_share.go | 9 +- share/parse_share_test.go | 22 ++- share/share_fields_test.go | 193 +++++++++++++++++++++ share/stream.go | 3 + share/transport_build.go | 75 ++++---- share/vmess.go | 99 ----------- 21 files changed, 545 insertions(+), 304 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml delete mode 100644 docs/agents/domain.md delete mode 100644 docs/agents/issue-tracker.md delete mode 100644 docs/agents/triage-labels.md create mode 100644 share/share_fields_test.go delete mode 100644 share/vmess.go diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000..2114512f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,89 @@ +name: Bug report +description: Report a libXray API, build, or native integration problem. +title: "[Bug]: " +labels: [bug] +body: + - type: markdown + attributes: + value: | + Please write your report in English and describe one problem per issue. + This tracker covers libXray. Report app-specific UI or behavior issues to the client application's repository unless you have a libXray reproduction. + Check the [README](https://github.com/XTLS/libXray#readme) and the supported Xray-core version before reporting. + Issues and attachments are public. Remove passwords, UUIDs, subscription URLs or tokens, age private keys, and other private data from examples and logs. + - type: checkboxes + id: existing-issues + attributes: + label: Existing issues + options: + - label: I searched open and closed issues and did not find an existing report of this problem. + required: true + - type: input + id: libxray-version + attributes: + label: libXray version + description: Enter the release tag, Go module version, or full commit SHA. For prebuilt artifacts, include the release or source they came from. + validations: + required: true + - type: input + id: xray-version + attributes: + label: Xray-core version + description: Enter the tag, module version, or commit if known. Mention any local replacement or custom patches. + - type: dropdown + id: integration + attributes: + label: Integration or build path + options: + - Go package + - C / FFI (CGoInvoke) + - Gomobile binding + - Desktop Core CLI + - Build script + - Other + validations: + required: true + - type: input + id: method + attributes: + label: Affected API or command + description: Name the method or build command involved, if applicable. + placeholder: "For example, testXray, runXray, pingBatch, or python3 build/main.py apple go" + - type: textarea + id: environment + attributes: + label: Environment + description: Include the target OS/version and CPU architecture. For build problems, also include the host OS, Go version, SDK/NDK or compiler versions, and exact build command. Mention simulators and other Go-based libraries loaded in the same process. + validations: + required: true + - type: textarea + id: reproduction + attributes: + label: Minimal reproduction + description: Provide a small code example, Invoke request, or command sequence. Include the relevant call order and cleanup so the problem can be reproduced outside the client app. For a build failure, provide the command and build options. + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + description: What should have happened? + validations: + required: true + - type: textarea + id: actual + attributes: + label: Actual behavior + description: What happened instead? Include the exact error or response, if any. + validations: + required: true + - type: textarea + id: logs + attributes: + label: Relevant logs or stack trace + description: Paste the relevant build output, response error, or stack trace with private data removed. Include the first error and enough surrounding context. + render: text + - type: textarea + id: context + attributes: + label: Configuration and additional context + description: Optionally attach a minimal configuration or reproducer with credentials removed. Mention whether it works with an earlier libXray version or directly with Xray-core, if already tested. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..d7c5ff48 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: true +contact_links: + - name: libXray documentation + url: https://github.com/XTLS/libXray#readme + about: Read the API, build instructions, and integration requirements. + - name: libXray releases + url: https://github.com/XTLS/libXray/releases + about: Check release versions and prebuilt artifacts before reporting a problem. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 00000000..d2484a22 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,41 @@ +name: Feature request +description: Propose a libXray API, build, or integration improvement. +title: "[Feature]: " +labels: [enhancement] +body: + - type: markdown + attributes: + value: | + Please write your request in English and focus on one improvement to libXray. + Client application features belong in the application's repository. Do not include private credentials. + - type: checkboxes + id: existing-issues + attributes: + label: Existing issues + options: + - label: I searched open and closed issues and did not find an existing request for this improvement. + required: true + - type: textarea + id: problem + attributes: + label: Problem or use case + description: What library capability is missing, and how does it affect your integration? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed improvement + description: Describe the intended behavior and relevant API, build target, or platform. Include an example request and response if useful. + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: Explain whether the existing libXray API, Xray-core configuration, or client-side code can address the use case. + - type: textarea + id: context + attributes: + label: Additional context + description: Add relevant upstream documentation, examples, or compatibility considerations. diff --git a/AGENTS.md b/AGENTS.md index 49b6b5d5..8a449d3f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,8 +5,9 @@ App behavior out of the generic library. ## API and runtime -Before changing a method, read [README API](README.md#api), its method section, -and the models/dispatch in `invoke_model.go` and `invoke.go`. +When changing a public API contract, read [README API](README.md#api), the +affected method section, and `invoke_model.go` / `invoke.go`. Internal-only +changes need the relevant implementation and tests, not the entire API guide. - Keep `LibXrayAPIVersion` fixed at `3`; do not increment it within this release. Synchronize contract changes with typed models, downstream consumers, tests, @@ -46,38 +47,27 @@ and the relevant platform/controller section in README. after a build and check the build command's success and resulting artifacts. - Modify an adjacent Xray-core checkout only when explicitly requested. -Common builds: +Targets and local-core options are documented in [build usage](README.md#usage). -```sh -python3 build/main.py android -python3 build/main.py apple go -``` +## GitHub and reviews -Other targets and local-core options are documented in [build usage](README.md#usage). - -## Agent skills - -### Issue tracker - -GitHub Issues for `XTLS/libXray`. Before issue or PR work, read -[issue tracker](docs/agents/issue-tracker.md). - -### Triage labels - -Use the five canonical triage labels. Before triage, read -[label mapping](docs/agents/triage-labels.md). - -### Domain docs - -Single-context layout. Before codebase exploration or domain/ADR work, -read [domain guidance](docs/agents/domain.md). +- Use explicit `--repo XTLS/libXray` or repository API endpoints; the Git remote + uses an SSH alias. Write issue/PR titles, descriptions and comments in English. + Keep PR content self-contained without references to other repos' PRs. +- Review the PR's actual remote base/head, not unpushed local changes; record + the commit IDs without switching the checkout. Report Standards and Spec + separately, with severity, location, concrete impact and evidence. +- A review does not authorize edits, comments, label changes, closure or pushes. + Check actual labels when an authorized action needs them; no triage setup is required. ## Verification Run `git diff --check` for all changes. Match further verification to the change; expand or repeat checks only for new changes, failures, or unresolved concerns. -- Go changes: format changed files with `gofmt`, then `go test ./... -count=1`. +- Go changes: `gofmt` changed files and run affected tests. Use + `go test ./... -count=1` for shared lifecycle, API, or dependency changes, + or when the impact cannot be contained to specific packages. - Invoke changes: cover dispatch/models, response shapes, and removed methods where relevant; verify downstream request models against the same contract. - Bridge/build changes: build the affected artifact where supported. Report diff --git a/README.md b/README.md index e6a79ae3..6a077b62 100644 --- a/README.md +++ b/README.md @@ -318,7 +318,7 @@ meaning as the local bind address. ### generate_share -Convert Xray JSON to VMess AEAD / VLESS share links following +Convert Xray JSON to VMessAEAD / VLESS share links following [Xray-core discussion #716](https://github.com/XTLS/Xray-core/discussions/716). SS, SOCKS and Trojan share links are also supported. VMess always generates an AEAD URI, not the legacy QR-code format. @@ -328,14 +328,31 @@ if no share links can be generated. ### parse_share -Parse VMess AEAD / VLESS, SS, SOCKS and Trojan share links, plus legacy -`vmessQrCode` links, into Xray JSON. +Parse VMessAEAD / VLESS, SS, SOCKS and Trojan share links into Xray JSON. +Legacy VMessQrCode links (`vmess://Base64(JSON)`) are not supported. Xray JSON node input and Base64 / Age subscription wrappers remain supported. Clash/Mihomo configurations and `hysteria2://` / `hy2://` URIs are not supported. This restriction concerns share-link conversion, not Hysteria2 in native Xray JSON configurations. +VMessAEAD / VLESS field mappings follow the +[Xray share-link proposal](https://github.com/XTLS/Xray-core/discussions/716) +where supported by the bundled Core: + +- mKCP `mtu` and `tti` are preserved in both directions. Omitted values use + Core defaults; Core validates their ranges. Legacy KCP `seed` and `headerType` + are not imported or exported. +- XHTTP `extra` keeps its complete JSON content, including nested settings. + `fm` carries FinalMask masks and all Core-supported `quicParams` fields. +- TLS `ech`, `pcs`, `vcn` and REALITY `pbk`, `sid`, `pqv`, `spx` are preserved + alongside `sni`, `fp` and TLS `alpn`. An omitted `sni` uses the remote host, + not the WebSocket HTTP host. +- Native transport aliases retain their settings when exported. RAW/TCP links + use `type=tcp`; query values use percent-encoded spaces. +- gRPC supports `gun` and `multi`. The bundled Core does not support `guna` + mode or the removed HTTP/QUIC transports; these are not silently substituted. + #### Parsing result `convertShareLinksToXrayJson` has one response shape. Its payload contains @@ -392,10 +409,6 @@ application must persist the pair and send only `publicKey` as persist keys, or add headers. Applications must never send the secret key over HTTP or write decrypted subscription text to disk. -### vmess - -convert VMessQRCode to Xray Json. - ### xray_json Some tools used to parse shared links. diff --git a/docs/agents/domain.md b/docs/agents/domain.md deleted file mode 100644 index a06be581..00000000 --- a/docs/agents/domain.md +++ /dev/null @@ -1,23 +0,0 @@ -# Domain Docs - -This repository uses a single-context layout: domain terminology lives in -`CONTEXT.md` at the repository root, and architecture decisions live in -`docs/adr/`. - -## Reading rules - -- Before exploring the codebase, read the root `CONTEXT.md` when it exists. -- Before changing an implementation, read ADRs under `docs/adr/` that affect - the area being changed. -- If these files do not exist, proceed silently without creating setup - placeholders. The `/domain-modeling` skill creates them when terminology - or decisions actually need recording. - -## Vocabulary and decisions - -- Use terms defined in `CONTEXT.md`; avoid synonyms the glossary explicitly - rejects. -- If a needed concept is missing, first check whether it is existing project - terminology. Leave genuine gaps for `/domain-modeling` to resolve. -- When a proposal contradicts an existing ADR, explicitly identify the - conflict and the reasoning instead of silently overriding the decision. diff --git a/docs/agents/issue-tracker.md b/docs/agents/issue-tracker.md deleted file mode 100644 index ccce19ae..00000000 --- a/docs/agents/issue-tracker.md +++ /dev/null @@ -1,50 +0,0 @@ -# Issue tracker: GitHub - -Issues and specs live in GitHub Issues for `XTLS/libXray`. - -Use the `gh` CLI. Because this clone's `origin` uses `yiguo.dev`, -include `--repo XTLS/libXray` in every `gh issue` and `gh pr` command. -For `gh api`, use explicit `repos/XTLS/libXray/...` endpoints. - -## Conventions - -- Use English for issue and PR titles, descriptions, and GitHub comments. -- Keep PR titles, descriptions, and comments self-contained. Do not mention - or link to another repository's PR, including companion, dependency, or - merge-order references. Describe required interface or build behavior directly. -- Use [triage-labels.md](triage-labels.md) for canonical triage roles. -- Use a heredoc for multiline bodies. - -## Issue operations - -- Publish a ticket: `gh issue create --repo XTLS/libXray --title "..." --body "..."` -- Fetch a ticket: `gh issue view --repo XTLS/libXray --comments` -- List tickets: `gh issue list --repo XTLS/libXray --state open --json number,title,body,labels,comments` -- Comment: `gh issue comment --repo XTLS/libXray --body "..."` -- Add or remove labels: `gh issue edit --repo XTLS/libXray --add-label "..."` or `--remove-label "..."` -- Close: `gh issue close --repo XTLS/libXray --comment "..."` - -GitHub shares one number space across issues and PRs. Resolve an ambiguous -number with `gh pr view --repo XTLS/libXray`, falling back to -`gh issue view --repo XTLS/libXray`. - -## Pull requests as a triage surface - -**PRs as a request surface: no.** - -## Wayfinding operations - -Used by `/wayfinder`. The map is one issue with child issues as tickets. - -- Map: label it `wayfinder:map`. -- Child ticket: link it as a GitHub sub-issue. If unavailable, add it to the - map's task list and put `Part of #` at the top of the child body. -- Child labels: `wayfinder:research`, `wayfinder:prototype`, - `wayfinder:grilling`, or `wayfinder:task`. -- Blocking: use GitHub issue dependencies. If unavailable, put - `Blocked by: #` at the top of the child body. -- Frontier: choose the first open, unassigned child in map order without - an open blocker. -- Claim: `gh issue edit --repo XTLS/libXray --add-assignee @me` -- Resolve: comment with the answer, close the child, then add a context - pointer to the map's Decisions-so-far. diff --git a/docs/agents/triage-labels.md b/docs/agents/triage-labels.md deleted file mode 100644 index fe317920..00000000 --- a/docs/agents/triage-labels.md +++ /dev/null @@ -1,11 +0,0 @@ -# Triage Labels - -| Canonical role | GitHub label | Meaning | -| --- | --- | --- | -| `needs-triage` | `needs-triage` | Waiting for maintainer evaluation | -| `needs-info` | `needs-info` | Waiting for the reporter to provide more information | -| `ready-for-agent` | `ready-for-agent` | Requirements are clear; ready for an agent | -| `ready-for-human` | `ready-for-human` | Requires human implementation | -| `wontfix` | `wontfix` | Not planned | - -When a skill mentions a canonical role, use its corresponding GitHub label. diff --git a/invoke_test.go b/invoke_test.go index fff68881..b8ed4380 100644 --- a/invoke_test.go +++ b/invoke_test.go @@ -450,6 +450,26 @@ func TestInvokeConvertShareLinksReturnsProjectedObject(t *testing.T) { } } +func TestInvokeConvertShareLinksPreservesKCPParameters(t *testing.T) { + response := invokeForTest(t, LibXrayMethodConvertShareLinksToXrayJson, + ConvertShareLinksToXrayJsonRequest{Text: "vless://12345678-abcd-abcd-abcd-123456789abc@127.0.0.1:443?type=kcp&mtu=1350&tti=30"}) + if !response.Success { + t.Fatalf("ConvertShareLinksToXrayJson failed: %s", response.Err) + } + config := decodeShareConfig(t, response) + if len(config.OutboundConfigs) != 1 { + t.Fatalf("outbounds = %d, want 1", len(config.OutboundConfigs)) + } + stream := config.OutboundConfigs[0].StreamSetting + if stream == nil || stream.KCPSettings == nil { + t.Fatal("KCP settings missing from Invoke response") + } + kcp := stream.KCPSettings + if kcp.Mtu == nil || *kcp.Mtu != 1350 || kcp.Tti == nil || *kcp.Tti != 30 { + t.Fatalf("unexpected KCP parameters: %s", response.Data) + } +} + func TestInvokeAgeKeyGenerationAndConversion(t *testing.T) { generated := invokeForTest( t, diff --git a/readme/README.zh_CN.md b/readme/README.zh_CN.md index 701d652c..688cf98e 100644 --- a/readme/README.zh_CN.md +++ b/readme/README.zh_CN.md @@ -233,20 +233,36 @@ libXray 使用 `tag` 存储节点名称。`sendThrough` 保留 Xray 原生语义 ### generate_share 按照 [Xray-core 讨论 #716](https://github.com/XTLS/Xray-core/discussions/716) -将 Xray JSON 转换为 VMess AEAD / VLESS 分享链接,同时支持 SS、SOCKS 和 Trojan。 +将 Xray JSON 转换为 VMessAEAD / VLESS 分享链接,同时支持 SS、SOCKS 和 Trojan。 VMess 始终生成 AEAD URI,不生成旧版二维码格式。 没有对应分享格式的 outbound 会被跳过;无法生成任何分享链接时返回失败。 ### parse_share -将 VMess AEAD / VLESS、SS、SOCKS、Trojan 分享链接以及旧版 `vmessQrCode` -链接解析为 Xray JSON。 +将 VMessAEAD / VLESS、SS、SOCKS、Trojan 分享链接解析为 Xray JSON。 +不支持旧版 VMessQrCode 链接(`vmess://Base64(JSON)`)。 保留 Xray JSON 节点输入以及 Base64 / Age 订阅包装。Clash/Mihomo 配置和 `hysteria2://` / `hy2://` URI 不受支持。 这一限制仅针对分享链接转换,不影响原生 Xray JSON 配置中的 Hysteria2。 +VMessAEAD / VLESS 的字段映射遵循 +[Xray 分享链接提案](https://github.com/XTLS/Xray-core/discussions/716), +以当前内置 Core 支持的能力为限: + +- 双向保留 mKCP 的 `mtu` 和 `tti`。省略时使用 Core 默认值,数值范围由 + Core 校验。不导入或导出旧版 KCP 的 `seed` 和 `headerType`。 +- XHTTP `extra` 保留完整 JSON 内容,包括嵌套配置。`fm` 保留 FinalMask + 掩饰配置和全部 Core 支持的 `quicParams` 字段。 +- 保留 TLS 的 `ech`、`pcs`、`vcn`,REALITY 的 `pbk`、`sid`、`pqv`、`spx`, + 以及 `sni`、`fp` 和 TLS `alpn`。省略 `sni` 时使用远端主机, + 不使用 WebSocket 的 HTTP host。 +- 从原生配置导出时保留传输别名对应的配置。RAW/TCP 链接使用 `type=tcp`, + 查询参数中的空格使用百分号编码。 +- gRPC 支持 `gun` 和 `multi`。当前内置 Core 不支持 `guna` 模式以及 + 已移除的 HTTP/QUIC 传输,不会将其静默替换成其他模式。 + #### 解析结果 `convertShareLinksToXrayJson` 只有一种响应结构。payload 包含 `text` 和可选的 @@ -300,10 +316,6 @@ outbound,不做节点 hash 比较、去重或失败节点计数。 密钥持久化或请求 Header;严禁通过 HTTP 发送私钥,也不能把解密后的订阅文本 写入磁盘。 -### vmess - -转换 VMessQRCode 为 Xray Json。 - ### xray_json 解析分享链接时用到的一些工具。 diff --git a/share/convert_share_test.go b/share/convert_share_test.go index d22b5105..5b267366 100644 --- a/share/convert_share_test.go +++ b/share/convert_share_test.go @@ -9,6 +9,8 @@ import ( const validShareOutbound = `{"protocol":"vless","tag":"Keep","settings":{"address":"example.com","port":443,"id":"12345678-abcd-abcd-abcd-123456789abc","encryption":"none"},"streamSettings":{"security":"tls","tlsSettings":{"serverName":"example.com"}}}` +const legacyVMessQRCodeJSON = `{"v":"2","ps":"Legacy","add":"vm.example","port":"443","id":"` + testShareUUID + `","aid":"0","scy":"auto","net":"ws","path":"/ws","tls":"tls"}` + func TestConvertShareLinksSkipsInvalidCandidates(t *testing.T) { jsonText := `{"outbounds":[` + validShareOutbound + `,{"protocol":"freedom"},{"protocol":42},null,{"protocol":"vless","settings":{"id":"invalid"}}]}` for _, test := range []struct { @@ -16,6 +18,7 @@ func TestConvertShareLinksSkipsInvalidCandidates(t *testing.T) { }{ {"links with headers", "Subscription export\n# comment\n\n" + ageTestShareLink + "\nvless://bad@example.com:443?encryption=none\nunknown://example.com"}, {"links with removed protocols", "hy2://auth@hy.example:443\n" + ageTestShareLink + "\nhysteria2://auth@hy.example:443"}, + {"links with VMessQrCode", "vmess://" + base64.StdEncoding.EncodeToString([]byte(legacyVMessQRCodeJSON)) + "\n" + ageTestShareLink}, {"JSON elements", jsonText}, {"base64 JSON", base64.StdEncoding.EncodeToString([]byte(jsonText))}, {"base64 links", base64.StdEncoding.EncodeToString([]byte(ageTestShareLink + "\nvless://bad@example.com:443"))}, @@ -38,6 +41,8 @@ func TestConvertShareLinksRejectsRemovedFormats(t *testing.T) { {"Mihomo JSON", `{"proxies":[{"name":"Node","type":"vless","server":"example.com","port":443,"uuid":"` + testShareUUID + `"}]}`}, {"Hysteria2 URI", "hysteria2://auth@hy.example:443?sni=hy.example"}, {"Hy2 URI", "hy2://auth@hy.example:443?sni=hy.example"}, + {"VMessQrCode", "vmess://" + base64.StdEncoding.EncodeToString([]byte(legacyVMessQRCodeJSON))}, + {"VMessQrCode URL-safe", "vmess://" + base64.RawURLEncoding.EncodeToString([]byte(legacyVMessQRCodeJSON))}, } { t.Run(input.name, func(t *testing.T) { for _, encoding := range []struct { @@ -116,6 +121,7 @@ func TestConvertShareLinksAgeSkipsInvalidCandidatesAndRedactsErrors(t *testing.T "proxies:\n - {type: vless, server: example.com, port: 443, uuid: " + testShareUUID + "}", "hysteria2://private-password@hy.example:443", "hy2://private-password@hy.example:443", + "vmess://" + base64.StdEncoding.EncodeToString([]byte(legacyVMessQRCodeJSON)), } { result, err := ConvertShareLinksToXrayJson(encryptAgeForTest(t, pair, input), pair.SecretKey) if err != ErrAgePlaintextUnsupported { diff --git a/share/generate_share.go b/share/generate_share.go index 32073fa1..2a65c4b0 100644 --- a/share/generate_share.go +++ b/share/generate_share.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "net/url" + "strconv" "strings" "github.com/xtls/xray-core/infra/conf" @@ -222,21 +223,36 @@ func streamSettingsQuery(proxy conf.OutboundDetourConfig, link *url.URL) { if streamSettings.Network != nil { network = string(*streamSettings.Network) } + if streamSettings.Method != nil { + network = string(*streamSettings.Method) + } + if canonical, ok := canonicalShareNetwork(network); ok { + network = canonical + } - query = addQuery(query, "type", network) + shareNetwork := network + if shareNetwork == "raw" { + shareNetwork = "tcp" + } + query = addQuery(query, "type", shareNetwork) - if len(streamSettings.Security) == 0 { - streamSettings.Security = "none" + security := streamSettings.Security + if security == "" { + security = "none" } - query = addQuery(query, "security", streamSettings.Security) + query = addQuery(query, "security", security) switch network { case "raw": - if streamSettings.RAWSettings == nil { + rawSettings := streamSettings.RAWSettings + if rawSettings == nil { + rawSettings = streamSettings.TCPSettings + } + if rawSettings == nil { break } - headerConfig := streamSettings.RAWSettings.HeaderConfig + headerConfig := rawSettings.HeaderConfig if headerConfig == nil { break } @@ -264,6 +280,15 @@ func streamSettingsQuery(proxy conf.OutboundDetourConfig, link *url.URL) { query = addQuery(query, "host", strings.Join(host, ",")) } } + case "kcp": + if settings := streamSettings.KCPSettings; settings != nil { + if settings.Mtu != nil { + query = addQuery(query, "mtu", strconv.FormatUint(uint64(*settings.Mtu), 10)) + } + if settings.Tti != nil { + query = addQuery(query, "tti", strconv.FormatUint(uint64(*settings.Tti), 10)) + } + } case "ws": if streamSettings.WSSettings == nil { break @@ -307,31 +332,28 @@ func streamSettingsQuery(proxy conf.OutboundDetourConfig, link *url.URL) { query = addQuery(query, "path", path) } case "xhttp": - if streamSettings.XHTTPSettings == nil { + xhttpSettings := streamSettings.XHTTPSettings + if xhttpSettings == nil { + xhttpSettings = streamSettings.SplitHTTPSettings + } + if xhttpSettings == nil { break } - host := streamSettings.XHTTPSettings.Host + host := xhttpSettings.Host if len(host) > 0 { query = addQuery(query, "host", host) } - path := streamSettings.XHTTPSettings.Path + path := xhttpSettings.Path if len(path) > 0 { query = addQuery(query, "path", path) } - mode := streamSettings.XHTTPSettings.Mode + mode := xhttpSettings.Mode if len(mode) > 0 { query = addQuery(query, "mode", mode) } - extra := streamSettings.XHTTPSettings.Extra + extra := xhttpSettings.Extra if extra != nil { - var extraConfig conf.SplitHTTPConfig - err := json.Unmarshal(extra, &extraConfig) - if err == nil { - extraBytes, err := json.Marshal(extraConfig) - if err == nil { - query = addQuery(query, "extra", string(extraBytes)) - } - } + query = addQuery(query, "extra", string(extra)) } } @@ -377,6 +399,9 @@ func streamSettingsQuery(proxy conf.OutboundDetourConfig, link *url.URL) { query = addQuery(query, "sni", sni) } pbk := streamSettings.REALITYSettings.Password + if pbk == "" { + pbk = streamSettings.REALITYSettings.PublicKey + } if len(pbk) > 0 { query = addQuery(query, "pbk", pbk) } @@ -408,12 +433,12 @@ func streamSettingsQuery(proxy conf.OutboundDetourConfig, link *url.URL) { func addQuery(rawQuery string, key, value string) string { v, err := url.ParseQuery(rawQuery) if err != nil { - newPart := key + "=" + url.QueryEscape(value) + newPart := key + "=" + strings.ReplaceAll(url.QueryEscape(value), "+", "%20") if rawQuery == "" { return newPart } return rawQuery + "&" + newPart } v.Add(key, value) - return v.Encode() + return strings.ReplaceAll(v.Encode(), "+", "%20") } diff --git a/share/generate_share_test.go b/share/generate_share_test.go index 38da0a08..3bb0012b 100644 --- a/share/generate_share_test.go +++ b/share/generate_share_test.go @@ -17,7 +17,7 @@ func TestConvertXrayJsonToShareLinks_RoundTripProtocols(t *testing.T) { "ss://" + ssUserB64("aes-128-gcm", "pw") + "@r3.example:8389", "vmess://" + testShareUUID + "@r4.example:443?encryption=none&type=tcp", "socks://" + base64.StdEncoding.EncodeToString([]byte("u:p")) + "@127.0.0.1:1090", - "vmess://" + base64.StdEncoding.EncodeToString([]byte(`{"ps":"QR","add":"qr.example","port":443,"id":"`+testShareUUID+`","scy":"auto","net":"ws","path":"/ws","tls":"tls"}`)), + "vmess://" + testShareUUID + "@vm.example:443?encryption=auto&type=ws&path=%2Fws&security=tls#VMessAEAD", } for _, link := range cases { t.Run(link[:12], func(t *testing.T) { @@ -28,6 +28,9 @@ func TestConvertXrayJsonToShareLinks_RoundTripProtocols(t *testing.T) { text, err := ConvertXrayJsonToShareLinks(out) require.NoError(t, err) assert.NotEmpty(t, text) + if cfg.OutboundConfigs[0].Protocol == "vmess" { + assert.Contains(t, text, "vmess://"+testShareUUID+"@") + } again, err := convertShareLinksWithKeyForTest(text, "") require.NoError(t, err) require.Len(t, again.OutboundConfigs, 1) @@ -45,17 +48,38 @@ func TestGenerate_KCPIgnoresSeedAndHeader(t *testing.T) { seed := "legacy-seed" header := json.RawMessage(`{"type":"srtp"}`) config.OutboundConfigs[0].StreamSetting.KCPSettings = &conf.KCPConfig{ + Mtu: new(uint32(1350)), + Tti: new(uint32(30)), Seed: &seed, HeaderConfig: header, } + config.OutboundConfigs[0].StreamSetting.Network = new(conf.TransportProtocol("mkcp")) link, err := shareLink(config.OutboundConfigs[0]) require.NoError(t, err) assert.Equal(t, "kcp", link.Query().Get("type")) + assert.Equal(t, "1350", link.Query().Get("mtu")) + assert.Equal(t, "30", link.Query().Get("tti")) assert.Empty(t, link.Query().Get("seed")) assert.Empty(t, link.Query().Get("headerType")) } +func TestGenerate_REALITYPublicKey(t *testing.T) { + const publicKey = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + text, err := ConvertXrayJsonToShareLinks([]byte(`{ + "outbounds":[{ + "protocol":"vless", + "settings":{"address":"example.com","port":443,"id":"` + testShareUUID + `","encryption":"none"}, + "streamSettings":{"security":"reality","realitySettings":{"fingerprint":"chrome","publicKey":"` + publicKey + `"}} + }] + }`)) + require.NoError(t, err) + config, err := convertShareLinksWithKeyForTest(text, "") + require.NoError(t, err) + require.Len(t, config.OutboundConfigs, 1) + assert.Equal(t, publicKey, config.OutboundConfigs[0].StreamSetting.REALITYSettings.Password) +} + func TestGenerate_ShadowsocksAEAD2022PlainUserInfo(t *testing.T) { const original = "ss://2022-blake3-aes-256-gcm:" + "YctPZ6U7xPPcU%2Bgp3u%2B0tx%2FtRizJN9K8y%2BuKlW2qjlI%3D" + diff --git a/share/marshal_share.go b/share/marshal_share.go index 4a81373c..31236bd2 100644 --- a/share/marshal_share.go +++ b/share/marshal_share.go @@ -128,7 +128,7 @@ func projectShareStream(source map[string]any) (map[string]any, bool) { } } case "kcp": - // KCP share links carry only the transport discriminator. + projectShareTransportSettings(projected, source, "kcpSettings", "mtu", "tti") case "ws": projectShareTransportSettings(projected, source, "wsSettings", "host", "path") case "grpc": @@ -271,7 +271,12 @@ func projectShareFinalMask(source map[string]any) map[string]any { if params, ok := shareObject(source["quicParams"]); ok { projectedParams := map[string]any{} - copyShareFields(projectedParams, params, "congestion", "brutalUp", "brutalDown") + // fm carries the full FinalMask config, including all Core QUIC parameters. + for field, value := range params { + if !emptyShareValue(value) { + projectedParams[field] = value + } + } if len(projectedParams) > 0 { projected["quicParams"] = projectedParams } diff --git a/share/marshal_share_test.go b/share/marshal_share_test.go index 6cb4a0fc..0eb2bab3 100644 --- a/share/marshal_share_test.go +++ b/share/marshal_share_test.go @@ -108,8 +108,7 @@ func TestConvertShareLinksNativeJSONPreservesHysteriaPortHopping(t *testing.T) { } func TestMarshalShareConfigKeepsKCPWithoutSettings(t *testing.T) { - qr := `{"ps":"k","add":"kcp.host","port":"8391","id":"` + testShareUUID + `","net":"kcp","path":"seedval","type":"wireguard"}` - link := "vmess://" + base64.StdEncoding.EncodeToString([]byte(qr)) + link := "vmess://" + testShareUUID + "@kcp.host:8391?type=kcp&seed=seedval&headerType=wireguard#k" config, err := convertShareLinksForTest(link) require.NoError(t, err) raw, err := marshalShareConfigJSON(config) diff --git a/share/parse_share.go b/share/parse_share.go index 07140384..1901fc80 100644 --- a/share/parse_share.go +++ b/share/parse_share.go @@ -10,7 +10,7 @@ import ( "github.com/xtls/xray-core/infra/conf" ) -// FixWindowsReturn normalizes CRLF to LF (v2rayN exports on Windows). +// FixWindowsReturn normalizes CRLF to LF. func FixWindowsReturn(text string) string { return strings.ReplaceAll(text, "\r\n", "\n") } @@ -28,7 +28,7 @@ func decodeBase64Text(text string) (string, error) { if b, err := base64.URLEncoding.DecodeString(text); err == nil { return string(b), nil } - // URL-safe raw + manual padding (legacy v2rayN) + // Normalize the URL-safe alphabet and restore missing padding. s := strings.ReplaceAll(strings.ReplaceAll(text, "-", "+"), "_", "/") if pad := len(s) % 4; pad != 0 { s += strings.Repeat("=", 4-pad) @@ -141,11 +141,6 @@ func parseShadowsocksUserInfo(user *url.Userinfo) (string, string, error) { } func (proxy xrayShareLink) vmessOutbound() (*conf.OutboundDetourConfig, error) { - text := strings.ReplaceAll(proxy.rawText, "vmess://", "") - if base64Text, err := decodeBase64Text(text); err == nil { - return parseVMessQrCode(base64Text) - } - outbound := &conf.OutboundDetourConfig{} outbound.Protocol = "vmess" setOutboundName(outbound, proxy.link.Fragment) diff --git a/share/parse_share_test.go b/share/parse_share_test.go index 482276a9..2b4afd11 100644 --- a/share/parse_share_test.go +++ b/share/parse_share_test.go @@ -91,14 +91,16 @@ func TestConvertShareLinksToXrayJson_AllBuildInvalidOutbounds(t *testing.T) { func TestConvertShareLinksToXrayJson_Base64EncodedLines(t *testing.T) { lines := "trojan://secret@trojan.example.com:443?sni=trojan.example.com\n" + - "ss://" + ssUserB64("aes-128-gcm", "pwd") + "@ss.example.com:8388#ssn" + "ss://" + ssUserB64("aes-128-gcm", "pwd") + "@ss.example.com:8388#ssn\n" + + "vmess://" + testShareUUID + "@vm.example:443?encryption=auto&type=ws#VMessAEAD" blob := base64.StdEncoding.EncodeToString([]byte(lines)) cfg, err := convertShareLinksForTest(blob) require.NoError(t, err) - require.Len(t, cfg.OutboundConfigs, 2) + require.Len(t, cfg.OutboundConfigs, 3) assert.Equal(t, "trojan", cfg.OutboundConfigs[0].Protocol) assert.Equal(t, "shadowsocks", cfg.OutboundConfigs[1].Protocol) + assert.Equal(t, "vmess", cfg.OutboundConfigs[2].Protocol) } func TestConvertShareLinksToXrayJson_Base64URLSafeBlob(t *testing.T) { @@ -227,13 +229,11 @@ func TestConvertShareLinksToXrayJson_VmessPlainURL(t *testing.T) { require.NotNil(t, ss.RAWSettings) } -func TestConvertShareLinksToXrayJson_VmessBase64QR(t *testing.T) { - qr := `{"ps":"qrname","add":"vm.add","port":"8443","id":"` + testShareUUID + `","scy":"auto","net":"ws","host":"ws.host","path":"/w","tls":"tls","sni":"tls.sni","alpn":"h2,h3","fp":"safari"}` - b64 := base64.StdEncoding.EncodeToString([]byte(qr)) - link := "vmess://" + b64 +func TestConvertShareLinksToXrayJson_VmessAEADWebSocketTLS(t *testing.T) { + link := "vmess://" + testShareUUID + "@vm.example:8443?encryption=auto&type=ws&host=ws.host&path=%2Fw&security=tls&sni=tls.sni&alpn=h2%2Ch3&fp=safari#VMessAEAD" cfg, err := convertShareLinksForTest(link) require.NoError(t, err) - assert.Equal(t, "qrname", cfg.OutboundConfigs[0].Tag) + assert.Equal(t, "VMessAEAD", cfg.OutboundConfigs[0].Tag) assert.Nil(t, cfg.OutboundConfigs[0].SendThrough) var s conf.VMessOutboundConfig require.NoError(t, json.Unmarshal(*cfg.OutboundConfigs[0].Settings, &s)) @@ -353,10 +353,9 @@ func TestConvertShareLinksToXrayJson_UnsupportedFormat(t *testing.T) { assert.Contains(t, err.Error(), "unsupported share format") } -func TestConvertShareLinksToXrayJson_VmessQRGrpcAndKcp(t *testing.T) { +func TestConvertShareLinksToXrayJson_VmessAEADGrpcAndKcp(t *testing.T) { t.Run("grpc", func(t *testing.T) { - qr := `{"ps":"g","add":"grpc.host","port":"443","id":"` + testShareUUID + `","net":"grpc","path":"svcname","type":"multi"}` - link := "vmess://" + base64.StdEncoding.EncodeToString([]byte(qr)) + link := "vmess://" + testShareUUID + "@grpc.host:443?type=grpc&serviceName=svcname&mode=multi#g" cfg, err := convertShareLinksForTest(link) require.NoError(t, err) gs := cfg.OutboundConfigs[0].StreamSetting.GRPCSettings @@ -366,8 +365,7 @@ func TestConvertShareLinksToXrayJson_VmessQRGrpcAndKcp(t *testing.T) { }) t.Run("kcp", func(t *testing.T) { - qr := `{"ps":"k","add":"kcp.host","port":"8391","id":"` + testShareUUID + `","net":"kcp","path":"seedval","type":"wireguard"}` - link := "vmess://" + base64.StdEncoding.EncodeToString([]byte(qr)) + link := "vmess://" + testShareUUID + "@kcp.host:8391?type=kcp#k" cfg, err := convertShareLinksForTest(link) require.NoError(t, err) assert.Nil(t, cfg.OutboundConfigs[0].StreamSetting.KCPSettings) diff --git a/share/share_fields_test.go b/share/share_fields_test.go new file mode 100644 index 00000000..417adb59 --- /dev/null +++ b/share/share_fields_test.go @@ -0,0 +1,193 @@ +package share + +import ( + "encoding/base64" + "encoding/json" + "net/url" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/xtls/xray-core/infra/conf" +) + +func TestShareKCPFieldsRoundTrip(t *testing.T) { + for _, protocol := range []string{"vmess", "vless"} { + for _, params := range []string{"", "&mtu=1350", "&tti=30", "&mtu=1350&tti=30"} { + t.Run(protocol+params, func(t *testing.T) { + original := protocol + "://" + testShareUUID + "@kcp.example:443?type=kcp" + params + config, link := roundTripShareFields(t, original) + assert.Equal(t, "kcp", link.Query().Get("type")) + settings := config.OutboundConfigs[0].StreamSetting.KCPSettings + if params == "" { + assert.Nil(t, settings) + assert.NotContains(t, link.Query(), "mtu") + assert.NotContains(t, link.Query(), "tti") + return + } + require.NotNil(t, settings) + if strings.Contains(params, "mtu=") { + require.NotNil(t, settings.Mtu) + assert.EqualValues(t, 1350, *settings.Mtu) + assert.Equal(t, "1350", link.Query().Get("mtu")) + } else { + assert.Nil(t, settings.Mtu) + assert.NotContains(t, link.Query(), "mtu") + } + if strings.Contains(params, "tti=") { + require.NotNil(t, settings.Tti) + assert.EqualValues(t, 30, *settings.Tti) + assert.Equal(t, "30", link.Query().Get("tti")) + } else { + assert.Nil(t, settings.Tti) + assert.NotContains(t, link.Query(), "tti") + } + }) + } + } +} + +func TestShareRejectsInvalidTransportFields(t *testing.T) { + good := "vless://" + testShareUUID + "@valid.example:443" + for _, query := range []string{ + "type=kcp&mtu=abc", "type=kcp&mtu=-1", "type=kcp&mtu=4294967296", + "type=kcp&mtu=20", "type=kcp&tti=9", "type=kcp&tti=1001", "type=kcp&tti=1.5", + "type=grpc&mode=guna", "type=xhttp&extra=%7B", "type=xhttp&extra=123", + } { + t.Run(query, func(t *testing.T) { + bad := "vless://" + testShareUUID + "@invalid.example:443?" + query + _, err := ConvertShareLinksToXrayJson(bad, "") + require.Error(t, err) + config, err := convertShareLinksWithKeyForTest(bad+"\n"+good, "") + require.NoError(t, err) + require.Len(t, config.OutboundConfigs, 1) + assert.Contains(t, string(*config.OutboundConfigs[0].Settings), "valid.example") + }) + } +} + +func TestShareXHTTPExtraRoundTrip(t *testing.T) { + const extra = `{ + "headers":{"X-Test":"a + b / c"},"noGRPCHeader":true, + "downloadSettings":{"network":"xhttp","xhttpSettings":{"path":"/download","headers":{"X-Download":"yes"}}}, + "futureOption":{"number":9007199254740993,"empty":null,"enabled":false} + }` + for _, protocol := range []string{"vmess", "vless"} { + t.Run(protocol, func(t *testing.T) { + query := url.Values{ + "type": {"xhttp"}, "host": {"cdn.example"}, "path": {"/path with + space"}, + "mode": {"stream-up"}, "extra": {extra}, + } + config, link := roundTripShareFields(t, protocol+"://"+testShareUUID+"@xhttp.example:443?"+query.Encode()) + assert.JSONEq(t, extra, string(config.OutboundConfigs[0].StreamSetting.XHTTPSettings.Extra)) + assert.JSONEq(t, extra, link.Query().Get("extra")) + assert.Contains(t, link.Query().Get("extra"), "9007199254740993") + assert.Equal(t, query.Get("path"), link.Query().Get("path")) + assert.NotContains(t, link.RawQuery, "+", "query values must use percent-encoded spaces") + }) + } +} + +func TestShareFinalMaskRoundTrip(t *testing.T) { + // Core validation enables these when quicParams.debug is true. + t.Setenv("HYSTERIA_BBR_DEBUG", "") + t.Setenv("HYSTERIA_BRUTAL_DEBUG", "") + const fm = `{ + "tcp":null, + "udp":[{"type":"noise","settings":{"futureOption":{"enabled":false,"empty":null}}}], + "quicParams":{ + "congestion":"bbr","debug":true,"bbrProfile":"conservative", + "brutalUp":"50 mbps","brutalDown":"100 mbps","brutalDisableLossCompensation":true, + "initStreamReceiveWindow":65536,"maxStreamReceiveWindow":131072, + "initConnectionReceiveWindow":262144,"maxConnectionReceiveWindow":524288, + "maxIdleTimeout":45,"keepAlivePeriod":10,"disablePathMTUDiscovery":true, + "disableChromeParrot":true,"disableGSO":true,"maxIncomingStreams":32,"disableStatelessReset":true + } + }` + config, link := roundTripShareFields(t, "vless://"+testShareUUID+"@mask.example:443?fm="+url.QueryEscape(fm)) + projected, err := json.Marshal(config.OutboundConfigs[0].StreamSetting.FinalMask) + require.NoError(t, err) + assert.JSONEq(t, fm, string(projected)) + assert.JSONEq(t, fm, link.Query().Get("fm")) +} + +func TestShareSecurityFieldsRoundTrip(t *testing.T) { + for name, query := range map[string]url.Values{ + "tls": { + "security": {"tls"}, "fp": {"chrome"}, "sni": {"tls.example"}, "alpn": {"h2,http/1.1"}, + "ech": {"config+with/padding="}, "pcs": {strings.Repeat("01", 32) + "," + strings.Repeat("02", 32)}, + "vcn": {"one.example,two.example"}, + }, + "reality": { + "security": {"reality"}, "fp": {"chrome"}, "sni": {"reality.example"}, + "pbk": {base64.RawURLEncoding.EncodeToString(make([]byte, 32))}, "sid": {"abcd"}, + "pqv": {base64.RawURLEncoding.EncodeToString(make([]byte, 1952))}, "spx": {"/path?key=a + b"}, + "flow": {"xtls-rprx-vision"}, + }, + } { + t.Run(name, func(t *testing.T) { + _, link := roundTripShareFields(t, "vless://"+testShareUUID+"@security.example:443?"+query.Encode()) + for key, values := range query { + assert.Equal(t, values, link.Query()[key], key) + } + }) + } +} + +func TestShareDefaultSNIUsesRemoteHost(t *testing.T) { + for _, protocol := range []string{"vmess", "vless"} { + t.Run(protocol, func(t *testing.T) { + config, link := roundTripShareFields(t, protocol+"://"+testShareUUID+"@remote.example:443?type=ws&host=cdn.example&security=tls") + assert.Equal(t, "remote.example", config.OutboundConfigs[0].StreamSetting.TLSSettings.ServerName) + assert.Equal(t, "remote.example", link.Query().Get("sni")) + }) + } +} + +func TestGenerateShareTransportAliases(t *testing.T) { + for name, stream := range map[string]string{ + "tcp": `{"network":"tcp","tcpSettings":{"header":{"type":"http","request":{"path":["/a"],"headers":{"Host":["cdn.example"]}}}}}`, + "raw": `{"network":"raw","rawSettings":{"header":{"type":"http","request":{"path":["/a"],"headers":{"Host":["cdn.example"]}}}}}`, + "websocket": `{"network":"websocket","wsSettings":{"path":"/a","host":"cdn.example"}}`, + "splithttp": `{"network":"splithttp","splithttpSettings":{"path":"/a","host":"cdn.example","mode":"stream-up","extra":{"futureOption":true}}}`, + "method": `{"network":"ws","method":"tcp","rawSettings":{"header":{"type":"http","request":{"path":["/a"],"headers":{"Host":["cdn.example"]}}}}}`, + } { + t.Run(name, func(t *testing.T) { + input := `{"outbounds":[{"protocol":"vless","settings":{"address":"remote.example","port":443,"id":"` + testShareUUID + `","encryption":"none"},"streamSettings":` + stream + `}]}` + text, err := ConvertXrayJsonToShareLinks([]byte(input)) + require.NoError(t, err) + link, err := url.Parse(text) + require.NoError(t, err) + assert.Equal(t, "/a", link.Query().Get("path")) + assert.Equal(t, "cdn.example", link.Query().Get("host")) + if name == "tcp" || name == "raw" || name == "method" { + assert.Equal(t, "tcp", link.Query().Get("type")) + assert.Equal(t, "http", link.Query().Get("headerType")) + } + if name == "splithttp" { + assert.Equal(t, "xhttp", link.Query().Get("type")) + assert.JSONEq(t, `{"futureOption":true}`, link.Query().Get("extra")) + } + _, err = ConvertShareLinksToXrayJson(text, "") + require.NoError(t, err) + }) + } +} + +func roundTripShareFields(t *testing.T, original string) (*conf.Config, *url.URL) { + t.Helper() + raw, err := ConvertShareLinksToXrayJson(original+"#Field%20test", "") + require.NoError(t, err) + text, err := ConvertXrayJsonToShareLinks(raw) + require.NoError(t, err) + link, err := url.Parse(text) + require.NoError(t, err) + again, err := ConvertShareLinksToXrayJson(text, "") + require.NoError(t, err) + assert.JSONEq(t, string(raw), string(again)) + var config conf.Config + require.NoError(t, json.Unmarshal(raw, &config)) + require.Len(t, config.OutboundConfigs, 1) + return &config, link +} diff --git a/share/stream.go b/share/stream.go index 0e272979..6926d9cb 100644 --- a/share/stream.go +++ b/share/stream.go @@ -36,6 +36,9 @@ func (proxy xrayShareLink) parseSecurityFromURL(link *url.URL, streamSettings *c realitySettings.Fingerprint = fp sni := query.Get("sni") + if sni == "" && (link.Scheme == "vmess" || link.Scheme == "vless") { + sni = link.Hostname() + } tlsSettings.ServerName = sni realitySettings.ServerName = sni diff --git a/share/transport_build.go b/share/transport_build.go index 060b29b4..00d06c4d 100644 --- a/share/transport_build.go +++ b/share/transport_build.go @@ -2,24 +2,28 @@ package share import ( "encoding/json" + "fmt" "net/url" + "strconv" "strings" "github.com/xtls/xray-core/infra/conf" ) -// shareTransportFields is the normalized transport slice of v2rayN share links and VMess QR JSON. +// shareTransportFields holds transport parameters from share-link queries. type shareTransportFields struct { Network string HeaderType string Path string Host string + KCPMTU string + KCPTTI string GrpcAuthority string GrpcServiceName string - GrpcMultiMode bool + GrpcMode string XHTTPMode string - ExtraJSON string // SplitHTTP extra (URL only) - FMJSON string // serialized FinalMask (URL only) + ExtraJSON string // SplitHTTP extra + FMJSON string // serialized FinalMask } func transportFieldsFromURLQuery(q url.Values) shareTransportFields { @@ -31,9 +35,11 @@ func transportFieldsFromURLQuery(q url.Values) shareTransportFields { Network: network, Path: q.Get("path"), Host: q.Get("host"), + KCPMTU: q.Get("mtu"), + KCPTTI: q.Get("tti"), GrpcAuthority: q.Get("authority"), GrpcServiceName: q.Get("serviceName"), - GrpcMultiMode: q.Get("mode") == "multi", + GrpcMode: q.Get("mode"), XHTTPMode: q.Get("mode"), ExtraJSON: q.Get("extra"), FMJSON: q.Get("fm"), @@ -44,29 +50,6 @@ func transportFieldsFromURLQuery(q url.Values) shareTransportFields { return fields } -func transportFieldsFromVmessQR(p vmessQrCode) shareTransportFields { - network := p.Net - if network == "" { - network = "raw" - } - t := shareTransportFields{ - Network: network, - Path: p.Path, - Host: p.Host, - } - switch network { - case "raw", "tcp": - t.HeaderType = p.Type - case "grpc", "gun": - t.GrpcServiceName = p.Path - t.GrpcMultiMode = p.Type == "multi" - } - if network == "xhttp" || network == "splithttp" { - t.XHTTPMode = p.Type - } - return t -} - // buildStreamFromTransportFields builds StreamConfig from normalized share fields (no TLS). func buildStreamFromTransportFields(t shareTransportFields) (*conf.StreamConfig, error) { streamSettings := &conf.StreamConfig{} @@ -95,28 +78,37 @@ func buildStreamFromTransportFields(t shareTransportFields) (*conf.StreamConfig, rawSettings.HeaderConfig = headerRawMessage streamSettings.RAWSettings = rawSettings } + case "kcp", "mkcp": + if t.KCPMTU != "" || t.KCPTTI != "" { + mtu, err := parseKCPParameter(t.KCPMTU) + if err != nil { + return nil, err + } + tti, err := parseKCPParameter(t.KCPTTI) + if err != nil { + return nil, err + } + streamSettings.KCPSettings = &conf.KCPConfig{Mtu: mtu, Tti: tti} + } case "ws", "websocket": streamSettings.WSSettings = &conf.WebSocketConfig{Path: t.Path, Host: t.Host} case "grpc", "gun": + if t.GrpcMode != "" && t.GrpcMode != "gun" && t.GrpcMode != "multi" { + return nil, fmt.Errorf("unsupported gRPC share mode %q", t.GrpcMode) + } streamSettings.GRPCSettings = &conf.GRPCConfig{ Authority: t.GrpcAuthority, ServiceName: t.GrpcServiceName, - MultiMode: t.GrpcMultiMode, + MultiMode: t.GrpcMode == "multi", } case "httpupgrade": streamSettings.HTTPUPGRADESettings = &conf.HttpUpgradeConfig{Host: t.Host, Path: t.Path} case "xhttp", "splithttp": xhttpSettings := &conf.SplitHTTPConfig{Host: t.Host, Path: t.Path, Mode: t.XHTTPMode} if t.ExtraJSON != "" { - var extraConfig *conf.SplitHTTPConfig - if err := json.Unmarshal([]byte(t.ExtraJSON), &extraConfig); err != nil { + if err := json.Unmarshal([]byte(t.ExtraJSON), &xhttpSettings.Extra); err != nil { return nil, err } - extraRawMessage, err := convertJsonToRawMessage(extraConfig) - if err != nil { - return nil, err - } - xhttpSettings.Extra = extraRawMessage } streamSettings.XHTTPSettings = xhttpSettings } @@ -131,3 +123,14 @@ func buildStreamFromTransportFields(t shareTransportFields) (*conf.StreamConfig, return streamSettings, nil } + +func parseKCPParameter(value string) (*uint32, error) { + if value == "" { + return nil, nil + } + parsed, err := strconv.ParseUint(value, 10, 32) + if err != nil { + return nil, err + } + return new(uint32(parsed)), nil +} diff --git a/share/vmess.go b/share/vmess.go deleted file mode 100644 index 18becd9c..00000000 --- a/share/vmess.go +++ /dev/null @@ -1,99 +0,0 @@ -package share - -import ( - "encoding/json" - "fmt" - "strconv" - "strings" - - "github.com/xtls/xray-core/infra/conf" -) - -// https://github.com/2dust/v2rayN/wiki/%E5%88%86%E4%BA%AB%E9%93%BE%E6%8E%A5%E6%A0%BC%E5%BC%8F%E8%AF%B4%E6%98%8E(ver-2) -type vmessQrCode struct { - Ps string `json:"ps,omitempty"` - Add string `json:"add,omitempty"` - Port interface{} `json:"port,omitempty"` - Id string `json:"id,omitempty"` - Scy string `json:"scy,omitempty"` - Net string `json:"net,omitempty"` - Type string `json:"type,omitempty"` - Host string `json:"host,omitempty"` - Path string `json:"path,omitempty"` - Tls string `json:"tls,omitempty"` - Sni string `json:"sni,omitempty"` - Alpn string `json:"alpn,omitempty"` - Fp string `json:"fp,omitempty"` -} - -func parseVMessQrCode(text string) (*conf.OutboundDetourConfig, error) { - var qrcode vmessQrCode - if err := json.Unmarshal([]byte(text), &qrcode); err != nil { - return nil, err - } - return qrcode.outbound() -} - -func (proxy vmessQrCode) outbound() (*conf.OutboundDetourConfig, error) { - outbound := &conf.OutboundDetourConfig{} - outbound.Protocol = "vmess" - setOutboundName(outbound, proxy.Ps) - - settings := conf.VMessOutboundConfig{} - settings.Address = parseAddress(proxy.Add) - portStr := fmt.Sprintf("%v", proxy.Port) - port, err := strconv.Atoi(portStr) - if err != nil { - return nil, err - } - settings.Port = uint16(port) - settings.ID = proxy.Id - settings.Security = proxy.Scy - - settingsRawMessage, err := convertJsonToRawMessage(settings) - if err != nil { - return nil, err - } - outbound.Settings = &settingsRawMessage - - streamSettings, err := buildStreamFromTransportFields(transportFieldsFromVmessQR(proxy)) - if err != nil { - return nil, err - } - if err := proxy.parseSecurity(streamSettings); err != nil { - return nil, err - } - outbound.StreamSetting = streamSettings - return outbound, nil -} - -func (proxy vmessQrCode) parseSecurity(streamSettings *conf.StreamConfig) error { - tlsSettings := &conf.TLSConfig{} - tlsSettings.Fingerprint = proxy.Fp - tlsSettings.ServerName = proxy.Sni - if proxy.Alpn != "" { - tlsSettings.ALPN = new(conf.StringList(strings.Split(proxy.Alpn, ","))) - } - - if proxy.Tls == "" { - streamSettings.Security = "none" - } else { - streamSettings.Security = proxy.Tls - } - - network, err := streamSettings.Network.Build() - if err != nil { - return err - } - // some link omits too many params, here is some fixing - if network == "websocket" && tlsSettings.ServerName == "" && - streamSettings.WSSettings != nil && streamSettings.WSSettings.Host != "" { - tlsSettings.ServerName = streamSettings.WSSettings.Host - } - - switch streamSettings.Security { - case "tls": - streamSettings.TLSSettings = tlsSettings - } - return nil -} From 5b7c5e07ef358b13acddc6f2b8e33949079f08e7 Mon Sep 17 00:00:00 2001 From: yiguodev <147401898+yiguodev@users.noreply.github.com> Date: Sat, 19 Sep 2026 10:32:23 +0800 Subject: [PATCH 22/38] Restore Matt Pocock skills configuration and documentation (#156) --- AGENTS.md | 26 ++++++++++------ docs/agents/domain.md | 23 ++++++++++++++ docs/agents/issue-tracker.md | 60 ++++++++++++++++++++++++++++++++++++ docs/agents/triage-labels.md | 11 +++++++ 4 files changed, 110 insertions(+), 10 deletions(-) create mode 100644 docs/agents/domain.md create mode 100644 docs/agents/issue-tracker.md create mode 100644 docs/agents/triage-labels.md diff --git a/AGENTS.md b/AGENTS.md index 8a449d3f..daa98e94 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,16 +49,22 @@ and the relevant platform/controller section in README. Targets and local-core options are documented in [build usage](README.md#usage). -## GitHub and reviews - -- Use explicit `--repo XTLS/libXray` or repository API endpoints; the Git remote - uses an SSH alias. Write issue/PR titles, descriptions and comments in English. - Keep PR content self-contained without references to other repos' PRs. -- Review the PR's actual remote base/head, not unpushed local changes; record - the commit IDs without switching the checkout. Report Standards and Spec - separately, with severity, location, concrete impact and evidence. -- A review does not authorize edits, comments, label changes, closure or pushes. - Check actual labels when an authorized action needs them; no triage setup is required. +## Agent skills + +### Issue tracker + +GitHub Issues for `XTLS/libXray`. Before issue, PR or review work, read +[issue tracker](docs/agents/issue-tracker.md). + +### Triage labels + +Use the five canonical triage labels. Before triage, read +[label mapping](docs/agents/triage-labels.md). + +### Domain docs + +Single-context layout. Before codebase exploration or domain/ADR work, +read [domain guidance](docs/agents/domain.md). ## Verification diff --git a/docs/agents/domain.md b/docs/agents/domain.md new file mode 100644 index 00000000..6c599a74 --- /dev/null +++ b/docs/agents/domain.md @@ -0,0 +1,23 @@ +# Domain Docs + +This repository uses a single-context layout: domain terminology lives in +`CONTEXT.md` at the repository root, and architecture decisions live in +`docs/adr/`. + +## Reading rules + +- Before exploring the codebase, read the root `CONTEXT.md` when it exists. +- Before changing an implementation, read ADRs under `docs/adr/` that affect + the area being changed, alongside the current contracts linked from `AGENTS.md`. +- If these files do not exist, proceed silently without creating setup + placeholders. The `/domain-modeling` skill creates them when terminology + or decisions actually need recording. + +## Vocabulary and decisions + +- Use terms defined in `CONTEXT.md`; avoid synonyms the glossary explicitly + rejects. +- If a needed concept is missing, first check whether it is existing project + terminology. Leave genuine gaps for `/domain-modeling` to resolve. +- When a proposal contradicts an existing ADR, explicitly identify the + conflict and the reasoning instead of silently overriding the decision. diff --git a/docs/agents/issue-tracker.md b/docs/agents/issue-tracker.md new file mode 100644 index 00000000..10c8e808 --- /dev/null +++ b/docs/agents/issue-tracker.md @@ -0,0 +1,60 @@ +# Issue tracker: GitHub + +Issues and specs live in GitHub Issues for `XTLS/libXray`. + +Use the `gh` CLI. Because this clone's `origin` uses `yiguo.dev`, +include `--repo XTLS/libXray` in every `gh issue` and `gh pr` command. +For `gh api`, use explicit `repos/XTLS/libXray/...` endpoints. + +## Conventions + +- Use English for issue and PR titles, descriptions, and GitHub comments. +- Keep PR titles, descriptions, and comments self-contained. Do not mention + or link to another repository's PR, including companion, dependency, or + merge-order references. Describe required interface or build behavior directly. +- Use [triage-labels.md](triage-labels.md) for canonical triage roles. +- Use a quoted heredoc for multiline bodies to preserve literal shell characters. + +## Reviews and authorization + +- For PR reviews, resolve the actual remote base/head and record the commit IDs + without switching the checkout. Unpushed local changes are not the PR diff. +- For local reviews, use the user's requested fixed point. Follow `code-review` + for separate Standards and Spec findings with severity, location, impact and evidence. +- Analysis and review are read-only unless the user also authorizes changes or + publication. They do not authorize code edits, GitHub comments, label changes, + closure or pushes. Check actual labels before an authorized label operation. + +## Issue operations + +- Publish a ticket: `gh issue create --repo XTLS/libXray --title "..." --body "..."` +- Fetch a ticket: `gh issue view --repo XTLS/libXray --comments`; include its labels when assessing state. +- List tickets: `gh issue list --repo XTLS/libXray --state open --json number,title,body,labels,comments` +- Comment: `gh issue comment --repo XTLS/libXray --body "..."` +- Add or remove labels: `gh issue edit --repo XTLS/libXray --add-label "..."` or `--remove-label "..."` +- Close: `gh issue close --repo XTLS/libXray --comment "..."` + +GitHub shares one number space across issues and PRs. Resolve an ambiguous +number with `gh pr view --repo XTLS/libXray`, falling back to +`gh issue view --repo XTLS/libXray`. + +## Pull requests as a triage surface + +**PRs as a request surface: no.** + +## Wayfinding operations + +Used by `/wayfinder`. The map is one issue with child issues as tickets. + +- Map: label it `wayfinder:map`. +- Child ticket: link it as a GitHub sub-issue. If unavailable, add it to the + map's task list and put `Part of #` at the top of the child body. +- Child labels: `wayfinder:research`, `wayfinder:prototype`, + `wayfinder:grilling`, or `wayfinder:task`. +- Blocking: use GitHub issue dependencies. If unavailable, put + `Blocked by: #` at the top of the child body. +- Frontier: choose the first open, unassigned child in map order without + an open blocker. +- Claim: `gh issue edit --repo XTLS/libXray --add-assignee @me` +- Resolve: comment with the answer, close the child, then add a context + pointer to the map's Decisions-so-far. diff --git a/docs/agents/triage-labels.md b/docs/agents/triage-labels.md new file mode 100644 index 00000000..fe317920 --- /dev/null +++ b/docs/agents/triage-labels.md @@ -0,0 +1,11 @@ +# Triage Labels + +| Canonical role | GitHub label | Meaning | +| --- | --- | --- | +| `needs-triage` | `needs-triage` | Waiting for maintainer evaluation | +| `needs-info` | `needs-info` | Waiting for the reporter to provide more information | +| `ready-for-agent` | `ready-for-agent` | Requirements are clear; ready for an agent | +| `ready-for-human` | `ready-for-human` | Requires human implementation | +| `wontfix` | `wontfix` | Not planned | + +When a skill mentions a canonical role, use its corresponding GitHub label. From 55cb29e214c5b77a1ba485da639a9e6b9b98adca Mon Sep 17 00:00:00 2001 From: yiguodev <147401898+yiguodev@users.noreply.github.com> Date: Wed, 23 Sep 2026 11:29:24 +0800 Subject: [PATCH 23/38] feat: allow excluding reserved ports from allocation (#157) --- README.md | 22 +++++++- invoke.go | 2 +- invoke_model.go | 3 +- invoke_test.go | 26 ++++++++++ nodep/port.go | 47 +++++++++++++---- nodep/port_test.go | 111 +++++++++++++++++++++++++++++++++++++++++ readme/README.zh_CN.md | 20 +++++++- 7 files changed, 216 insertions(+), 15 deletions(-) create mode 100644 nodep/port_test.go diff --git a/README.md b/README.md index 6a077b62..5e19275d 100644 --- a/README.md +++ b/README.md @@ -309,7 +309,27 @@ Speed ​​test the Xray configuration. ### port -Get free ports. +`getFreePorts` returns distinct free TCP ports on localhost. Its payload accepts +`count` and an optional `excludePorts` list of caller-reserved port numbers: + +```json +{ + "apiVersion": 3, + "method": "getFreePorts", + "payload": {"count": 2, "excludePorts": [18587, 9000]} +} +``` + +The response keeps the existing `data.ports` integer array. Omit `excludePorts` +or pass an empty list for no exclusions; duplicates have no additional effect. +Excluded ports must be between 1 and 65535. Negative or impossible counts fail; +zero returns no ports. The Go entrypoint is +`nodep.GetFreePorts(count int, excludePorts []int)`; pass `nil` for no exclusions. + +Listeners remain open during selection to avoid duplicates or repeatedly picking +an excluded port, and close before the function returns, including on failure. +The returned ports are therefore candidates, not reservations: another process +can claim them before the caller binds. This checks TCP only, not UDP. ## share diff --git a/invoke.go b/invoke.go index ac3573ad..1f832412 100644 --- a/invoke.go +++ b/invoke.go @@ -123,7 +123,7 @@ func invokeGetFreePorts(payload json.RawMessage) string { if err != nil { return encodeInvokeResponse(nil, err) } - ports, err := nodep.GetFreePorts(request.Count) + ports, err := nodep.GetFreePorts(request.Count, request.ExcludePorts) if err != nil { return encodeInvokeResponse(nil, err) } diff --git a/invoke_model.go b/invoke_model.go index b723ac71..89143175 100644 --- a/invoke_model.go +++ b/invoke_model.go @@ -30,7 +30,8 @@ type LibXrayInvokeRequest struct { } type GetFreePortsRequest struct { - Count int `json:"count,omitempty"` + Count int `json:"count,omitempty"` + ExcludePorts []int `json:"excludePorts,omitempty"` } type GetFreePortsResponse struct { diff --git a/invoke_test.go b/invoke_test.go index b8ed4380..b2178952 100644 --- a/invoke_test.go +++ b/invoke_test.go @@ -331,6 +331,32 @@ func TestInvokeXrayVersion(t *testing.T) { } } +func TestInvokeGetFreePortsExclusions(t *testing.T) { + response := invokeRawForTest(t, `{"apiVersion":3,"method":"getFreePorts","payload":{"count":2,"excludePorts":[18587,9000,18587]}}`) + if !response.Success { + t.Fatalf("GetFreePorts failed: %s", response.Err) + } + ports := decodeDataObject[GetFreePortsResponse](t, response).Ports + if len(ports) != 2 || ports[0] == ports[1] { + t.Fatalf("ports = %v, want two distinct ports", ports) + } + for _, port := range ports { + if port == 18587 || port == 9000 { + t.Fatalf("excluded port returned: %d", port) + } + } + response = invokeForTest(t, LibXrayMethodGetFreePorts, GetFreePortsRequest{ + Count: 1, ExcludePorts: []int{0}, + }) + if response.Success || !strings.Contains(response.Err, "excluded port") { + t.Fatalf("invalid exclusion response = %+v", response) + } + response = invokeRawForTest(t, `{"apiVersion":3,"method":"getFreePorts","payload":{"count":1,"excludePorts":["18587"]}}`) + if response.Success { + t.Fatal("string exclusion should fail JSON decoding") + } +} + func TestInvokeMapResponseShape(t *testing.T) { response := invokeForTest(t, LibXrayMethodGetFreePorts, GetFreePortsRequest{Count: 1}) if !response.Success { diff --git a/nodep/port.go b/nodep/port.go index c06b3bb2..af74a55f 100644 --- a/nodep/port.go +++ b/nodep/port.go @@ -1,26 +1,51 @@ package nodep import ( + "fmt" "net" ) -// https://github.com/phayes/freeport/blob/master/freeport.go -// GetFreePort asks the kernel for free open ports that are ready to use. +// Port allocation approach: https://github.com/phayes/freeport/blob/master/freeport.go +// GetFreePorts returns distinct free TCP ports, excluding caller-reserved ports. +// Listeners are released before returning, so the ports are not reserved for use. +func GetFreePorts(count int, excludePorts []int) ([]int, error) { + return getFreePorts(count, excludePorts, func() (net.Listener, error) { + return net.Listen("tcp", "localhost:0") + }) +} -func GetFreePorts(count int) ([]int, error) { - var ports []int - for range count { - addr, err := net.ResolveTCPAddr("tcp", "localhost:0") - if err != nil { - return ports, err +func getFreePorts(count int, excludePorts []int, listen func() (net.Listener, error)) ([]int, error) { + excluded := make(map[int]struct{}, len(excludePorts)) + for _, port := range excludePorts { + if port < 1 || port > 65535 { + return nil, fmt.Errorf("excluded port must be between 1 and 65535: %d", port) } + excluded[port] = struct{}{} + } + if count < 0 || count > 65535-len(excluded) { + return nil, fmt.Errorf("requested port count exceeds the available range: %d", count) + } - l, err := net.ListenTCP("tcp", addr) + var ports []int + var listeners []net.Listener + defer func() { + for _, listener := range listeners { + listener.Close() + } + }() + for len(ports) < count { + listener, err := listen() if err != nil { return ports, err } - ports = append(ports, l.Addr().(*net.TCPAddr).Port) - l.Close() + // Hold rejected ports too: closing them here could let the kernel pick + // the same excluded port indefinitely. All listeners close on return. + listeners = append(listeners, listener) + port := listener.Addr().(*net.TCPAddr).Port + if _, skip := excluded[port]; !skip { + excluded[port] = struct{}{} + ports = append(ports, port) + } } return ports, nil } diff --git a/nodep/port_test.go b/nodep/port_test.go new file mode 100644 index 00000000..66904227 --- /dev/null +++ b/nodep/port_test.go @@ -0,0 +1,111 @@ +package nodep + +import ( + "errors" + "net" + "reflect" + "testing" +) + +func TestGetFreePorts(t *testing.T) { + excluded := []int{18587, 18587, 9000} + ports, err := GetFreePorts(8, excluded) + if err != nil { + t.Fatal(err) + } + if len(ports) != 8 { + t.Fatalf("ports = %v, want 8", ports) + } + seen := map[int]bool{18587: true, 9000: true} + for _, port := range ports { + if port < 1 || port > 65535 || seen[port] { + t.Fatalf("invalid, excluded or duplicate port: %d", port) + } + seen[port] = true + } +} + +func TestGetFreePortsHoldsExcludedAndSelectedListeners(t *testing.T) { + listeners := []*portListener{{port: 18587}, {port: 50001}, {port: 50002}} + calls := 0 + ports, err := getFreePorts(2, []int{18587}, func() (net.Listener, error) { + for _, previous := range listeners[:calls] { + if previous.closed { + t.Fatal("listener released before selection completed") + } + } + listener := listeners[calls] + calls++ + return listener, nil + }) + if err != nil || !reflect.DeepEqual(ports, []int{50001, 50002}) { + t.Fatalf("ports = %v, error = %v", ports, err) + } + for _, listener := range listeners { + if !listener.closed { + t.Fatal("listener not released after selection") + } + } +} + +func TestGetFreePortsClosesListenersOnError(t *testing.T) { + for _, first := range []int{18587, 50001} { + listener := &portListener{port: first} + failure := errors.New("no more ports") + calls := 0 + _, err := getFreePorts(2, []int{18587}, func() (net.Listener, error) { + calls++ + if calls == 1 { + return listener, nil + } + return nil, failure + }) + if !errors.Is(err, failure) || !listener.closed { + t.Fatalf("error = %v, closed = %v", err, listener.closed) + } + } +} + +func TestGetFreePortsRejectsImpossibleRequestsBeforeListening(t *testing.T) { + for _, request := range []struct { + count int + exclude []int + }{ + {count: -1}, + {count: 65536}, + {count: 65535, exclude: []int{18587}}, + {count: 1, exclude: []int{0}}, + {count: 1, exclude: []int{-1}}, + {count: 1, exclude: []int{65536}}, + } { + _, err := getFreePorts(request.count, request.exclude, func() (net.Listener, error) { + t.Fatal("invalid request opened a listener") + return nil, nil + }) + if err == nil { + t.Fatalf("request %+v succeeded", request) + } + } +} + +func TestGetFreePortsZeroDoesNotListen(t *testing.T) { + ports, err := getFreePorts(0, nil, func() (net.Listener, error) { + t.Fatal("zero-port request opened a listener") + return nil, nil + }) + if err != nil || len(ports) != 0 { + t.Fatalf("ports = %v, error = %v", ports, err) + } +} + +type portListener struct { + port int + closed bool +} + +func (l *portListener) Accept() (net.Conn, error) { return nil, errors.New("unused") } +func (l *portListener) Addr() net.Addr { return &net.TCPAddr{Port: l.port} } +func (l *portListener) Close() error { + l.closed = true + return nil +} diff --git a/readme/README.zh_CN.md b/readme/README.zh_CN.md index 688cf98e..f8344aef 100644 --- a/readme/README.zh_CN.md +++ b/readme/README.zh_CN.md @@ -224,7 +224,25 @@ LibXray.resetDNS(); ### port -获取空闲端口。 +`getFreePorts` 返回 localhost 上互不重复的空闲 TCP 端口。payload 支持 +`count` 和可选的 `excludePorts`,用于避开调用方预留的固定端口: + +```json +{ + "apiVersion": 3, + "method": "getFreePorts", + "payload": {"count": 2, "excludePorts": [18587, 9000]} +} +``` + +响应保持现有的 `data.ports` 整数数组。省略 `excludePorts` 或传空列表表示不排除端口, +重复值不影响结果。排除端口必须在 1–65535 之间;数量为负数或超过可用范围时报错, +数量为 0 时不返回端口。Go 入口为 `nodep.GetFreePorts(count int, excludePorts []int)`, +不排除端口时传入 `nil`。 + +选择期间保持监听,避免返回重复端口或反复选中同一个排除端口;返回前关闭全部监听, +失败时也会关闭。因此结果仅为候选端口,不构成预留,调用方绑定前仍可能被其他进程占用。 +仅检查 TCP,不检查 UDP。 ## share From 83f16029d38791274c40fa061958daf9ccd85bb7 Mon Sep 17 00:00:00 2001 From: yiguodev <147401898+yiguodev@users.noreply.github.com> Date: Sat, 26 Sep 2026 10:32:48 +0800 Subject: [PATCH 24/38] feat(share): restore Hysteria2 URI import and export (#158) --- README.md | 25 +- docs/hysteria2-share-restoration-research.md | 348 +++++++++++++++++++ invoke_test.go | 31 +- readme/README.zh_CN.md | 21 +- share/convert_share.go | 7 +- share/convert_share_test.go | 10 +- share/generate_share.go | 3 + share/generate_share_test.go | 7 +- share/hysteria2.go | 264 ++++++++++++++ share/hysteria2_test.go | 225 ++++++++++++ share/parse_share.go | 12 + share/stream.go | 2 +- 12 files changed, 928 insertions(+), 27 deletions(-) create mode 100644 docs/hysteria2-share-restoration-research.md create mode 100644 share/hysteria2.go create mode 100644 share/hysteria2_test.go diff --git a/README.md b/README.md index 5e19275d..a9d1244c 100644 --- a/README.md +++ b/README.md @@ -340,7 +340,7 @@ meaning as the local bind address. Convert Xray JSON to VMessAEAD / VLESS share links following [Xray-core discussion #716](https://github.com/XTLS/Xray-core/discussions/716). -SS, SOCKS and Trojan share links are also supported. VMess always generates +Hysteria2, SS, SOCKS and Trojan share links are also supported. VMess always generates an AEAD URI, not the legacy QR-code format. Outbounds without a supported share-link format are skipped. Conversion fails @@ -348,13 +348,28 @@ if no share links can be generated. ### parse_share -Parse VMessAEAD / VLESS, SS, SOCKS and Trojan share links into Xray JSON. +Parse VMessAEAD / VLESS, Hysteria2, SS, SOCKS and Trojan share links into Xray JSON. Legacy VMessQrCode links (`vmess://Base64(JSON)`) are not supported. Xray JSON node input and Base64 / Age subscription wrappers remain supported. -Clash/Mihomo configurations and `hysteria2://` / `hy2://` URIs are not supported. -This restriction concerns share-link conversion, not Hysteria2 in native -Xray JSON configurations. +Clash/Mihomo configurations remain unsupported. + +Hysteria2 accepts `hysteria2://` and `hy2://` using the +[official URI scheme](https://v2.hysteria.network/docs/developers/URI-Scheme/): +optional authentication, default port 443, IPv6, SNI, Salamander obfuscation, +multi-port authorities and fragment names. Export uses `hysteria2://`. +Port hopping uses Core's `finalmask.udp` with both local and remote interval +hopping (30 seconds by default; `hop-interval` must be at least 5 seconds). +Legacy `ports` / `mport` queries are accepted and exported as authority ports. +Legacy `up` / `down` values are imported as client-local QUIC tuning, not exported. + +TLS is mandatory. `insecure=true` / `allowInsecure=true` and `pinSHA256` are +rejected: the bundled Core cannot preserve those Hysteria TLS semantics. +Explicit Xray `fp`, `alpn`, `ech`, `pcs` and `vcn` extensions retain their Xray +meaning. Export rejects TLS/mask settings that cannot be represented, rather +than silently dropping security or obfuscation. Client-local QUIC tuning is +not part of the URI. Realm and Gecko are not supported. Hopping requires a +direct UDP socket; this does not add hopping support over chained proxies. VMessAEAD / VLESS field mappings follow the [Xray share-link proposal](https://github.com/XTLS/Xray-core/discussions/716) diff --git a/docs/hysteria2-share-restoration-research.md b/docs/hysteria2-share-restoration-research.md new file mode 100644 index 00000000..ad750a14 --- /dev/null +++ b/docs/hysteria2-share-restoration-research.md @@ -0,0 +1,348 @@ +# Hysteria2 share restoration research + +Research date: 2026-09-26. This note distinguishes restoring the former share +format from implementing every feature in the latest Hysteria specification. + +## Implementation status — 2026-09-26 + +Implemented on `feat/restore-hysteria2-share`; the App changes remain on its +existing `dev-26.9.5-2` branch. The sections below retain the pre-implementation +research. The current supported fields and security boundaries are specified +in [parse_share](../README.md#parse_share), not by the historical implementation. + +- Both URI aliases import; export uses `hysteria2://`. IPv6, userinfo escaping, + Salamander, mixed/Base64/Age inputs and both local/remote hopping are covered. + Official `pinSHA256` and verification bypass are rejected, not reinterpreted. +- `go test ./... -count=1` and the Hysteria2 race tests passed, including a real + UDP reply after the five-second socket hop. Invoke API remains version 3. +- Standard Android and Apple Go builds succeeded and their artifacts were + copied to the App. Module files were unchanged after the builds. No adjacent + Xray-core, VCore, database schema or Pigeon API was changed. +- The App propagates Windows/Linux interface policy to the hopping mask's + independent socket options. Its 48 affected Flutter tests and analyze passed. +- Android emulator validation used a separate application ID and the official + Hysteria v2.12.3 server. Import, automatic queued probes, database preservation, + URI export/re-import, native VPN start/switch/stop and actual HTTP downloads + passed for plain, Salamander and hopping nodes. Six approximately 1 MiB + transfers succeeded across 30 seconds of hopping; both destination ports and + changed local ports were observed. The share UI displayed the generated URI. +- The controlled certificate used the explicit Xray `pcs` extension. Public-PKI + interoperability, the reporter's provider, and physical iOS devices were not + tested. The server deliberately blocked non-test destinations, so location + lookup and background Geodata download failures in this isolated run were + expected; they did not invalidate measured delay or interrupt the VPN. +- App copy and three website languages were updated. Hugo, documentation/SEO + checks and all 13 SEO regression tests passed. This is not deployment evidence. + +Local harnesses, server checksums, screenshots and the detailed results are +under workspace `references/hysteria2-share-restoration/`. Test VPN, isolated +package, ADB forwarding and local servers were stopped/removed after validation; +the developer's existing App database was not modified. No commit or push was +requested or performed during implementation. + +## Recommendation and estimated cost + +Restore Hysteria2 URI import and export as a focused share-conversion change, +not a protocol implementation or an App architecture rewrite. Keep Clash/Mihomo +and legacy VMessQrCode unsupported. Keep the existing core dependency and Invoke +API version/response shapes unchanged. + +The ordinary valid-certificate path is low-cost. Correct URI edge cases, +security-policy boundaries, and live port-hopping checks make the complete +restoration low-to-medium cost. A planning estimate is **one to two focused +development days** for the scoped converter, regression coverage, App artifacts, +copy updates, and an available Android test endpoint. This is an engineering +estimate, not measured implementation time; it excludes a core TLS rewrite, +new protocol variants, release review, unavailable device checks, and platform +issues discovered by live testing. + +| Area | Expected work | Cost / boundary | +| --- | --- | --- | +| libXray | Restore URI dispatch, protocol-specific parsing/export, and mask conversion; fix URL edge cases and security rejection behavior | Main implementation work; a few hundred lines rather than a new protocol stack | +| Regression coverage | Replace Hysteria2 rejection fixtures; cover both directions, Base64/Age, mixed input, and retained unsupported formats | Existing tests/helpers are reusable | +| App | Refresh native libraries; update five subscription-description translations and their tests; add Hysteria outbound import/share coverage | No database, Pigeon, or routing-model redesign indicated by the ordinary path | +| Public documentation | Update libXray EN/ZH support statements, App sharing contract, and website support descriptions | Small, but must reflect actual TLS/URI limitations | +| Device acceptance | Android import, queued probe, VPN traffic, export/re-import, and hopping; Apple build and eventual iOS device check | Parsing/build success alone is not acceptance | + +## Issue and deletion history + +[OneXray issue #201][issue-201] reports an App Store installation of OneXray +26.9.4 with Xray-core 26.9.9 on iOS, using Custom Routing. It contains no example +link, configuration, or log. The removed URI support matches the reported +regression, but the report does not establish whether the user's exact failure +is import, sharing, or runtime connectivity. Do not claim it has been reproduced +or fixed on iOS from this research. + +The relevant libXray history is: + +1. [`36351d7`][core-update] (2026-09-09) updated the core dependency to the exact + revision still used today and migrated Hysteria2 port hopping to UDP masks. +2. [`613752d`][removal] (2026-09-09), `refactor(share): remove Clash and Hysteria2 + URI support`, removed `hy2`/`hysteria2` recognition, the outbound parser, + share generation, TLS-default dispatch, and `share/hysteria_mask.go`. +3. The change entered main in [`50b9597`][main-release], carrying libXray tags + `v26.9.9` and `v1.260909.0`. These are library tags, not an App release number. + +Use the Hysteria2 portions of `613752d^` as a reference, not a wholesale revert. +The deletion also removed Clash/Mihomo, and later sharing work removed legacy +VMessQrCode and improved other protocols. Preserve those changes. The former +Hysteria parser/export/helper amount to roughly 200-250 implementation lines, +excluding tests; an updated restoration need not reproduce the old file layout. + +Current `share/marshal_share.go` still projects native Hysteria outbounds, +`hysteriaSettings`, TLS fields, and FinalMask. The native JSON regression in +`share/marshal_share_test.go` still verifies Hysteria2 port-hopping JSON can +survive projection and build. Only the URI path was intentionally removed. +See the current [sharing implementation][current-share] and [API contract][current-readme]. + +## App integration scope + +OneXray was inspected at `6d2e777f4fa8c23fe9cb04dc827251148b32bbed`, branch +`dev-26.9.5-2`, in the adjacent App repository. + +- `lib/service/shared/share/xray_share_reader.dart` delegates standard parsing + to `AppHostApi.convertShareLinksToXrayJson` without a Dart protocol whitelist. +- `lib/service/servers/outbound/map.dart` and `state_db.dart` preserve outbound + maps and store them through the existing CoreConfig representation. +- `lib/service/connect/compiler.dart` clones selected outbound maps and applies + managed tags/socket policy; it does not reconstruct Hysteria settings. +- `lib/pages/shared/share/controller.dart` already delegates standard link + generation to `convertXrayJsonToShareLinks`. +- `prototypeSubscriptionDescription` in all five `lib/l10n/*.arb` languages, + plus `test/pages/servers/subscription/form_test.dart`, currently says only + VMessAEAD/VLESS links are supported. Correct that capability statement when + support is delivered, without adding a new import UI. + +Thus ordinary links should flow through clipboard/file/QR/subscription import, +storage, existing queued probes, selection, and sharing after the native library +is updated. This is a source-derived integration assessment, not device proof. +Port hopping additionally needs the socket-policy review described below. + +## Source boundaries + +- libXray inspected at `55cb29e214c5b77a1ba485da639a9e6b9b98adca`. +- The dependency in `go.mod` is + `github.com/xtls/xray-core v1.260327.1-0.20260908222543-52a412d9e2f5`. + Its cached module metadata resolves to + `52a412d9e2f5c2a5142b1b4e2ab3771dacb8b120`. Source inspection used + `/Users/yiguo/go/pkg/mod/github.com/xtls/xray-core@v1.260327.1-0.20260908222543-52a412d9e2f5/`, + not a sibling checkout. +- Official Hysteria `master` resolved to + `e1366b173ccf5706e1e4630fe8aa654a4b574085` (2026-09-13). Its former + `apernet/hysteria` URLs redirect to `HyNetworks/hysteria`. + Online documentation was checked on the research date and may evolve. + +## URI contract and core mapping + +The [official URI specification][uri] defines `hysteria2` and `hy2`, optional +userinfo, default port 443, port lists/ranges, TLS parameters, and an optional +display-name fragment. Bandwidth and client listening modes are deliberately +excluded. `mport` and hopping-interval query parameters are not defined there; +supporting them would be a separately documented compatibility extension. + +| Input | Mapping to the pinned core | Constraints | +| --- | --- | --- | +| `hysteria2://` / `hy2://` | Outbound `protocol: "hysteria"`; `settings.version: 2`; stream `network: "hysteria"`, `security: "tls"`, `hysteriaSettings.version: 2` | The core also accepts `method` as the stream selector and gives it precedence over `network`. Both version fields are required. [Core config][core-hysteria], [stream config][core-stream], [transport config][core-method]. | +| `auth@` / `username:password@` | `streamSettings.hysteriaSettings.auth` | Preserve decoded credentials, including a colon when password is present. Auth does not belong in outbound `settings`. [Official parser/generator][hy-client], [core transport][core-method]. | +| Host and single port | `settings.address`, `settings.port` | Apply 443 when omitted. URI IPv6 literals require brackets; the address value should contain the literal without authority brackets. [URI][uri], [RFC 3986 section 3.2.2][rfc-host]. | +| `sni` | `streamSettings.tlsSettings.serverName` | If absent, the Hysteria dialer uses the destination address through `tls.WithDestination`. [Dialer][core-dialer], [TLS runtime][core-tls]. | +| `obfs=salamander&obfs-password=...` | `streamSettings.finalmask.udp` entry with `type: "salamander"`, `settings.password` | No `packetSize` for ordinary Salamander. Password must be at least four bytes at connection wrapping time; a JSON build alone does not exercise that check. [Mask schema][core-mask], [Salamander][core-salamander]. | +| Multi-port authority, e.g. `host:443,5000-5002` | One base `settings.port` plus an outermost `udphop` UDP mask containing the entire port set | Details below. [Port hopping][hopping], [mask schema][core-mask]. | +| `#name` | Display metadata only | Decode/encode as a URI fragment, without changing connection fields. [URI][uri]. | + +The official implementation obtains decoded values through `User.Username()` +and `User.Password()`. Decode userinfo exactly once: a literal `+` in userinfo +is not a space, and `%25` must not be decoded again. Generate userinfo with URI +userinfo escaping; query escaping has different `+` behavior. Query values such +as an obfuscation password containing `+` need `%2B`. [Official URL parser][hy-url]. + +Go 1.27.1's standard `net/url` rejects commas and ranges in the authority port. +Hysteria's URL fork explicitly admits those separators. A restoration therefore +needs a deliberate multi-port authority path while retaining normal escaping +and IPv6 handling. Validate malformed escapes, empty hosts, invalid port bounds, +reversed/empty ranges, and IPv6 with an omitted port. [Official URL fork][hy-url], +local `/opt/homebrew/Cellar/go/1.27.1/libexec/src/net/url/url.go:762`. + +## TLS compatibility requires an explicit policy + +Official Hysteria sets `InsecureSkipVerify` independently and compares +`pinSHA256` against the leaf certificate's DER SHA-256. Consequently, with +`insecure=0` or omission, normal trust/hostname validation runs before the pin +callback. Its parser accepts `strconv.ParseBool` spellings in addition to the +documented `1`/`0`. [Official TLS and URI code][hy-client], +[official TLS connection construction][hy-core-client]. + +| URI TLS state | Pinned-core behavior / restoration implication | +| --- | --- | +| No pin, `insecure` absent or `0` | Normal TLS verification; direct mapping. | +| No pin, `insecure=1` | Unsupported: `TLSConfig.Build` rejects `allowInsecure: true`. Do not silently drop the flag or claim the original connection semantics are supported. | +| Pin with `insecure=1` | `tlsSettings.pinnedPeerCertSha256` provides the intended check when the supplied fingerprint identifies the leaf; never emit `allowInsecure: true`. Xray can also accept a matching CA, which official Hysteria's leaf-only pin would reject. | +| Pin with `insecure` absent or `0` | Not equivalent: Xray's leaf pin bypasses ordinary trust/hostname validation. Adding `verifyPeerCertByName` does not restore it because a matching leaf returns success first. Reject or explicitly resolve this contract before claiming faithful import. | + +The pinned-core field is a **hex string**, accepting colon-separated OpenSSL +hex and comma-separated pins, with each decoded hash exactly 32 bytes. The +official URI expresses a single certificate fingerprint. Do not base64-encode +it or substitute a digest of concatenated chain bytes. Export cannot faithfully turn arbitrary multiple pins, +CA pins, or other TLS policy into one official `pinSHA256` parameter. +[Core TLS builder, lines 361-378][core-security], +[core TLS verification, lines 284-405 and 561 onward][core-tls]. + +The current [Project X TLS documentation][xray-tls-doc] still describes +`allowInsecure` as deprecated; the pinned source rejects it. The dependency +source is authoritative for this restoration. + +## Current UDP mask and hopping shape + +For ordinary Salamander plus hopping, the source-supported shape is: + +```json +{ + "finalmask": { + "udp": [ + { "type": "salamander", "settings": { "password": "example-password" } }, + { + "type": "udphop", + "settings": { + "mode": "intervalLocal,intervalRemote", + "interval": 30, + "remotePorts": "443,5000-5002" + } + } + ] + } +} +``` + +This object belongs under `streamSettings`. Omit the Salamander entry when +obfuscation is absent. A concrete base port is still required by the outbound; +using the first supplied port is a reasonable converter convention, while the +hopping mask chooses an initial remote port randomly. + +`remotePorts` uses `conf.PortList`; `interval` uses `conf.Int32Range`, in seconds. +Set 30 explicitly to match Hysteria's default: Xray's runtime rejects either +interval bound below 5 and supplies no default. The mask manager reverses the +configured list before wrapping; `udphop` must be the last configured entry +because its wrapper requires level zero. These are the current `finalmask.udp` +fields, not transport-level `obfs`/`hopPorts` fields or a `udpmasks` JSON field. +[Schema][core-mask], [range types][core-common], [mask ordering][core-mask-runtime], +[hopping wrapper][core-hop-config], [hopping runtime][core-hop-runtime]. + +The official client changes the local UDP socket as well as the remote port. +Both interval modes above match that source behavior. In the pinned Xray code, +the receive goroutine is only started in the local-hop branch; using only +`intervalRemote` appears unable to receive replies. This is a source-derived +concern, not a reproduced connection failure. The wrapper also rejects +`FakePacketConn`, so upstream proxy chaining is not established by this mapping. +[Official hopping runtime][hy-hop], [Xray hopping runtime][core-hop-runtime], +[wrapper restriction][core-hop-config]. + +Local hopping opens replacement sockets with the mask's own `settings.sockopt`. +The App currently places its Windows/Linux interface selection in the parent +`streamSettings.sockopt`; the core builds the mask and parent socket settings +separately. Before claiming hopping works on every platform, check that the +managed interface/protection policy reaches replacement sockets as well. +This may require a small runtime-configuration adjustment, not a database or +native API redesign. Windows/Linux live verification remains outside the +current macOS-host validation boundary. [Stream builder][core-stream], +[hopping runtime][core-hop-runtime], App `compiler.dart:570`. + +## Restoration scope and validation boundary + +The latest official docs also include Gecko and separate realm schemes. +The pinned core has related mechanisms (`salamander.settings.packetSize` and a +`realm` mask), but their presence does not make them part of restoring the +former ordinary Hysteria2 links. Treat them as separate work. ECH is different: +the former and current TLS helper already maps `ech` to `echConfigList`, so +reuse and test that mapping rather than introducing a new mechanism. Unsupported +connection/security parameters must not be silently discarded. +[Latest URI specification][uri], [mask schema][core-mask], +[TLS builder][core-security], [pre-removal TLS helper][old-stream]. + +For export, preserve only configurations representable by the selected URI +contract; do not flatten extra UDP masks, remote-IP hopping, or distinct TLS +policies into a link that appears equivalent. Parser round trips and successful +core config construction are necessary checks but do not prove connectivity. + +The former helper supported nonstandard `up`, `down`, `ports`, and +`hop-interval` query fields. Treat those as a compatibility decision, not as +official URI requirements. Recommended export follows the official URI contract +and does not emit client-specific bandwidth values. Keep complete unrepresentable +configurations in native Xray JSON instead of claiming a lossless standard link. + +## Suggested implementation and acceptance order + +1. Implement a protocol-local parser/exporter, reusing existing sharing helpers. + Add both schemes to dispatch and export canonical `hysteria2://` links. + Cover auth escaping, default 443, IPv4/IPv6, SNI, name-to-`tag`, Salamander, + and standard multi-port authority handling. Avoid vendoring a whole URL fork + only to parse port lists. Keep the current per-item filtering contract. +2. Resolve the TLS table above explicitly. Start with normal certificate + verification; reject any security mode that cannot preserve the requested + meaning. Do not silently turn insecure or pinned-only input into another + TLS policy. The old helper itself never read `insecure` or `pinSHA256`, so + restoring its code alone would not provide full official-format support. +3. Update conversion, generation, projection, Invoke, and Age tests. Include + mixed subscriptions, empty/invalid parameters, export/re-import, and an + assertion that Clash/Mihomo and VMessQrCode remain unsupported. Exercise + the actual constructed mask with packet replies and a hop; the old tests + covered construction/wrapping, not successful hopping traffic. +4. Build with the documented `build/main.py android` and `build/main.py apple go` + paths, without `local` core replacement; copy verified artifacts to the App. + Update App import/share regressions and capability copy. Preserve API version + 3 and `data.outbounds`. +5. Use an authorized known-good Hysteria2 endpoint or a controlled test server + for Android end-to-end acceptance. Verify plain TLS, Salamander, TCP and UDP, + and sustained traffic across at least one hop. Keep evidence under workspace + `references/`. Android success does not close the iOS validation gap in #201; + follow with an iOS device check. Do not start a macOS VPN or claim unperformed + Windows/Linux validation. + +The current Android emulator is available for the later implementation phase. +No implementation, dependency, branch, device, or GitHub state was changed in +this research. Only this research note was added; the proposed conversion has +not been exercised against an official Hysteria server. + +## Baseline checks performed + +On 2026-09-26, from the current libXray checkout: + +```text +go test ./share -count=1 +PASS + +go test . -run '^TestInvokeConvertShareLinksRejectsRemovedFormats$' -count=1 -v +PASS (Clash, Hysteria2, Hy2) +``` + +These checks confirm the current sharing baseline, native Hysteria JSON build +coverage, and intentional `unsupported share format` results for Hysteria2 URI +input. They are not tests of a restored implementation or real VPN traffic. + +[issue-201]: https://github.com/OneXray/OneXray/issues/201 +[core-update]: https://github.com/XTLS/libXray/commit/36351d7530b2233f3c8d404e6f58cffda0cbc8e5 +[removal]: https://github.com/XTLS/libXray/commit/613752d75f41629e8a43d043cf2e4c14592690fd +[main-release]: https://github.com/XTLS/libXray/commit/50b95979f5db551bd273165cf469e5daaf791341 +[current-share]: https://github.com/XTLS/libXray/tree/55cb29e214c5b77a1ba485da639a9e6b9b98adca/share +[current-readme]: https://github.com/XTLS/libXray/blob/55cb29e214c5b77a1ba485da639a9e6b9b98adca/README.md#share +[old-stream]: https://github.com/XTLS/libXray/blob/36351d7530b2233f3c8d404e6f58cffda0cbc8e5/share/stream.go + +[uri]: https://v2.hysteria.network/docs/developers/URI-Scheme/ +[hopping]: https://v2.hysteria.network/docs/advanced/Port-Hopping/ +[rfc-host]: https://www.rfc-editor.org/rfc/rfc3986.html#section-3.2.2 +[xray-tls-doc]: https://xtls.github.io/en/config/transports/tls.html +[hy-client]: https://github.com/HyNetworks/hysteria/blob/e1366b173ccf5706e1e4630fe8aa654a4b574085/app/cmd/client.go +[hy-core-client]: https://github.com/HyNetworks/hysteria/blob/e1366b173ccf5706e1e4630fe8aa654a4b574085/core/client/client.go#L74 +[hy-url]: https://github.com/HyNetworks/hysteria/blob/e1366b173ccf5706e1e4630fe8aa654a4b574085/app/internal/url/url.go +[hy-hop]: https://github.com/HyNetworks/hysteria/blob/e1366b173ccf5706e1e4630fe8aa654a4b574085/extras/transport/udphop/conn.go +[core-hysteria]: https://github.com/XTLS/Xray-core/blob/52a412d9e2f5c2a5142b1b4e2ab3771dacb8b120/infra/conf/hysteria.go#L13 +[core-stream]: https://github.com/XTLS/Xray-core/blob/52a412d9e2f5c2a5142b1b4e2ab3771dacb8b120/infra/conf/transport_internet.go#L44 +[core-method]: https://github.com/XTLS/Xray-core/blob/52a412d9e2f5c2a5142b1b4e2ab3771dacb8b120/infra/conf/transport_method.go#L752 +[core-dialer]: https://github.com/XTLS/Xray-core/blob/52a412d9e2f5c2a5142b1b4e2ab3771dacb8b120/transport/internet/hysteria/dialer.go#L293 +[core-security]: https://github.com/XTLS/Xray-core/blob/52a412d9e2f5c2a5142b1b4e2ab3771dacb8b120/infra/conf/transport_security.go#L299 +[core-tls]: https://github.com/XTLS/Xray-core/blob/52a412d9e2f5c2a5142b1b4e2ab3771dacb8b120/transport/internet/tls/config.go +[core-mask]: https://github.com/XTLS/Xray-core/blob/52a412d9e2f5c2a5142b1b4e2ab3771dacb8b120/infra/conf/transport_finalmask.go +[core-common]: https://github.com/XTLS/Xray-core/blob/52a412d9e2f5c2a5142b1b4e2ab3771dacb8b120/infra/conf/common.go#L215 +[core-salamander]: https://github.com/XTLS/Xray-core/blob/52a412d9e2f5c2a5142b1b4e2ab3771dacb8b120/transport/internet/finalmask/salamander/salamander.go#L30 +[core-mask-runtime]: https://github.com/XTLS/Xray-core/blob/52a412d9e2f5c2a5142b1b4e2ab3771dacb8b120/transport/internet/finalmask/finalmask.go#L21 +[core-hop-config]: https://github.com/XTLS/Xray-core/blob/52a412d9e2f5c2a5142b1b4e2ab3771dacb8b120/transport/internet/finalmask/udphop/config.go#L10 +[core-hop-runtime]: https://github.com/XTLS/Xray-core/blob/52a412d9e2f5c2a5142b1b4e2ab3771dacb8b120/transport/internet/finalmask/udphop/conn.go diff --git a/invoke_test.go b/invoke_test.go index b2178952..7aee0dce 100644 --- a/invoke_test.go +++ b/invoke_test.go @@ -416,13 +416,40 @@ func TestInvokeConvertShareLinksFiltersBuildInvalidOutbounds(t *testing.T) { } } +func TestInvokeHysteria2ShareRoundTrip(t *testing.T) { + for _, scheme := range []string{"hysteria2", "hy2"} { + t.Run(scheme, func(t *testing.T) { + response := invokeForTest(t, LibXrayMethodConvertShareLinksToXrayJson, + ConvertShareLinksToXrayJsonRequest{Text: scheme + "://password@example.com:443?obfs=salamander&obfs-password=secret#Test"}) + if !response.Success { + t.Fatal(response.Err) + } + config := decodeShareConfig(t, response) + if len(config.OutboundConfigs) != 1 || config.OutboundConfigs[0].Protocol != "hysteria" || config.OutboundConfigs[0].Tag != "Test" { + t.Fatalf("unexpected Hysteria2 projection: %s", response.Data) + } + if result := invokeForTest(t, LibXrayMethodTestXray, TestXrayRequest{XrayJson: string(response.Data)}); !result.Success { + t.Fatal(result.Err) + } + result := invokeForTest(t, LibXrayMethodConvertXrayJsonToShareLinks, + ConvertXrayJsonToShareLinksRequest{XrayJson: string(response.Data)}) + if !result.Success { + t.Fatal(result.Err) + } + links := decodeDataObject[ConvertXrayJsonToShareLinksResponse](t, result) + if !strings.HasPrefix(links.Links, "hysteria2://") || !strings.HasSuffix(links.Links, "#Test") { + t.Fatalf("unexpected Hysteria2 link: %s", links.Links) + } + }) + } +} + func TestInvokeConvertShareLinksRejectsRemovedFormats(t *testing.T) { for _, test := range []struct { name, text string }{ {"Clash", "proxies:\n - {type: vless, server: example.com, port: 443, uuid: 12345678-abcd-abcd-abcd-123456789abc}"}, - {"Hysteria2", "hysteria2://password@example.com:443"}, - {"Hy2", "hy2://password@example.com:443"}, + {"HysteriaRealm", "hysteria2+realm://token@example.com/realm?auth=password"}, } { t.Run(test.name, func(t *testing.T) { response := invokeForTest(t, LibXrayMethodConvertShareLinksToXrayJson, diff --git a/readme/README.zh_CN.md b/readme/README.zh_CN.md index f8344aef..a4aa0830 100644 --- a/readme/README.zh_CN.md +++ b/readme/README.zh_CN.md @@ -251,19 +251,30 @@ libXray 使用 `tag` 存储节点名称。`sendThrough` 保留 Xray 原生语义 ### generate_share 按照 [Xray-core 讨论 #716](https://github.com/XTLS/Xray-core/discussions/716) -将 Xray JSON 转换为 VMessAEAD / VLESS 分享链接,同时支持 SS、SOCKS 和 Trojan。 +将 Xray JSON 转换为 VMessAEAD / VLESS 分享链接,同时支持 Hysteria2、SS、SOCKS 和 Trojan。 VMess 始终生成 AEAD URI,不生成旧版二维码格式。 没有对应分享格式的 outbound 会被跳过;无法生成任何分享链接时返回失败。 ### parse_share -将 VMessAEAD / VLESS、SS、SOCKS、Trojan 分享链接解析为 Xray JSON。 +将 VMessAEAD / VLESS、Hysteria2、SS、SOCKS、Trojan 分享链接解析为 Xray JSON。 不支持旧版 VMessQrCode 链接(`vmess://Base64(JSON)`)。 -保留 Xray JSON 节点输入以及 Base64 / Age 订阅包装。Clash/Mihomo 配置和 -`hysteria2://` / `hy2://` URI 不受支持。 -这一限制仅针对分享链接转换,不影响原生 Xray JSON 配置中的 Hysteria2。 +保留 Xray JSON 节点输入以及 Base64 / Age 订阅包装,不支持 Clash/Mihomo 配置。 + +Hysteria2 接受 `hysteria2://` 和 `hy2://`,遵循 +[官方 URI 格式](https://v2.hysteria.network/docs/developers/URI-Scheme/):支持可选认证、 +默认端口 443、IPv6、SNI、Salamander 混淆、多端口 authority 与名称 fragment。 +导出统一使用 `hysteria2://`。端口跳跃映射到 Core 的 `finalmask.udp`,同时启用本地与 +远端定时跳跃,默认 30 秒,`hop-interval` 至少为 5 秒。兼容 `ports` / `mport` 查询参数, +导出时转为 authority 中的多端口。旧 `up` / `down` 仅导入为客户端 QUIC 调优,不导出。 + +TLS 必须开启。拒绝 `insecure=true` / `allowInsecure=true` 和 `pinSHA256`, +因为当前 Core 无法等价表达这些 Hysteria 证书校验语义。显式 Xray 扩展 `fp`、`alpn`、 +`ech`、`pcs`、`vcn` 保留 Xray 原有语义。无法表达的 TLS 或 mask 设置拒绝导出, +不静默丢弃安全或混淆配置。客户端 QUIC 调优不属于 URI。不支持 Realm 或 Gecko。 +端口跳跃需要直接 UDP socket;本次恢复不增加链式代理上的跳跃支持。 VMessAEAD / VLESS 的字段映射遵循 [Xray 分享链接提案](https://github.com/XTLS/Xray-core/discussions/716), diff --git a/share/convert_share.go b/share/convert_share.go index 4043fd5c..b14546d0 100644 --- a/share/convert_share.go +++ b/share/convert_share.go @@ -3,7 +3,6 @@ package share import ( "encoding/json" "errors" - "net/url" "strings" "github.com/xtls/xray-core/infra/conf" @@ -64,11 +63,7 @@ func parseShareCandidates(links string, allowBase64 bool) (*conf.Config, error) if !found || strings.ContainsAny(scheme, " \t#") { continue } - parsed, err := url.Parse(line) - if err != nil { - continue - } - outbound, err := (xrayShareLink{link: parsed, rawText: line}).outbound() + outbound, err := parseOutboundShareLink(line) if err == nil { config.OutboundConfigs = append(config.OutboundConfigs, *outbound) } diff --git a/share/convert_share_test.go b/share/convert_share_test.go index 5b267366..7ebdc699 100644 --- a/share/convert_share_test.go +++ b/share/convert_share_test.go @@ -17,7 +17,7 @@ func TestConvertShareLinksSkipsInvalidCandidates(t *testing.T) { name, text string }{ {"links with headers", "Subscription export\n# comment\n\n" + ageTestShareLink + "\nvless://bad@example.com:443?encryption=none\nunknown://example.com"}, - {"links with removed protocols", "hy2://auth@hy.example:443\n" + ageTestShareLink + "\nhysteria2://auth@hy.example:443"}, + {"links with unsupported Hysteria TLS", "hy2://auth@hy.example:443?insecure=1\n" + ageTestShareLink + "\nhysteria2://auth@hy.example:443?pinSHA256=bad"}, {"links with VMessQrCode", "vmess://" + base64.StdEncoding.EncodeToString([]byte(legacyVMessQRCodeJSON)) + "\n" + ageTestShareLink}, {"JSON elements", jsonText}, {"base64 JSON", base64.StdEncoding.EncodeToString([]byte(jsonText))}, @@ -39,8 +39,7 @@ func TestConvertShareLinksRejectsRemovedFormats(t *testing.T) { }{ {"Clash YAML", "proxies:\n - {name: Node, type: vless, server: example.com, port: 443, uuid: " + testShareUUID + "}"}, {"Mihomo JSON", `{"proxies":[{"name":"Node","type":"vless","server":"example.com","port":443,"uuid":"` + testShareUUID + `"}]}`}, - {"Hysteria2 URI", "hysteria2://auth@hy.example:443?sni=hy.example"}, - {"Hy2 URI", "hy2://auth@hy.example:443?sni=hy.example"}, + {"Hysteria realm URI", "hysteria2+realm://token@realm.example/name?auth=password"}, {"VMessQrCode", "vmess://" + base64.StdEncoding.EncodeToString([]byte(legacyVMessQRCodeJSON))}, {"VMessQrCode URL-safe", "vmess://" + base64.RawURLEncoding.EncodeToString([]byte(legacyVMessQRCodeJSON))}, } { @@ -108,6 +107,7 @@ func TestConvertShareLinksAgeSkipsInvalidCandidatesAndRedactsErrors(t *testing.T for _, input := range []string{ ageTestShareLink + "\nvless://secret-not-a-uuid@example.com:443", `{"outbounds":[` + validShareOutbound + `,{"protocol":false}]}`, + "hy2://age-password@hy.example:443#Age%20Hysteria", } { result, err := ConvertShareLinksToXrayJson(encryptAgeForTest(t, pair, input), pair.SecretKey) if err != nil { @@ -119,8 +119,8 @@ func TestConvertShareLinksAgeSkipsInvalidCandidatesAndRedactsErrors(t *testing.T "vless://secret-not-a-uuid@example.com:443", `{"outbounds":"private-source"}`, "proxies:\n - {type: vless, server: example.com, port: 443, uuid: " + testShareUUID + "}", - "hysteria2://private-password@hy.example:443", - "hy2://private-password@hy.example:443", + "hysteria2://private-password@hy.example:443?insecure=1", + "hy2://private-password@hy.example:443?obfs=unsupported", "vmess://" + base64.StdEncoding.EncodeToString([]byte(legacyVMessQRCodeJSON)), } { result, err := ConvertShareLinksToXrayJson(encryptAgeForTest(t, pair, input), pair.SecretKey) diff --git a/share/generate_share.go b/share/generate_share.go index 2a65c4b0..c4ba0537 100644 --- a/share/generate_share.go +++ b/share/generate_share.go @@ -44,6 +44,9 @@ func ConvertXrayJsonToShareLinks(xrayBytes []byte) (string, error) { } func shareLink(proxy conf.OutboundDetourConfig) (*url.URL, error) { + if proxy.Protocol == "hysteria" { + return hysteria2ShareLink(proxy) + } shareUrl := &url.URL{} switch proxy.Protocol { diff --git a/share/generate_share_test.go b/share/generate_share_test.go index 3bb0012b..d0f78adf 100644 --- a/share/generate_share_test.go +++ b/share/generate_share_test.go @@ -18,6 +18,7 @@ func TestConvertXrayJsonToShareLinks_RoundTripProtocols(t *testing.T) { "vmess://" + testShareUUID + "@r4.example:443?encryption=none&type=tcp", "socks://" + base64.StdEncoding.EncodeToString([]byte("u:p")) + "@127.0.0.1:1090", "vmess://" + testShareUUID + "@vm.example:443?encryption=auto&type=ws&path=%2Fws&security=tls#VMessAEAD", + "hy2://user:password@hy.example/?sni=hy.example#Hysteria2", } for _, link := range cases { t.Run(link[:12], func(t *testing.T) { @@ -133,7 +134,7 @@ func TestConvertXrayJsonToShareLinks_Errors(t *testing.T) { require.Error(t, err) } -func TestConvertXrayJsonToShareLinksRejectsHysteria(t *testing.T) { +func TestConvertXrayJsonToShareLinksSupportsHysteria(t *testing.T) { links, err := ConvertXrayJsonToShareLinks([]byte(`{ "outbounds": [{ "protocol": "hysteria", @@ -144,8 +145,8 @@ func TestConvertXrayJsonToShareLinksRejectsHysteria(t *testing.T) { } }] }`)) - require.EqualError(t, err, "no valid outbounds") - assert.Empty(t, links) + require.NoError(t, err) + assert.Equal(t, "hysteria2://password@example.com:443#hysteria", links) } func TestConvertXrayJsonToShareLinksSkipsUnsupportedOutbounds(t *testing.T) { diff --git a/share/hysteria2.go b/share/hysteria2.go new file mode 100644 index 00000000..71b34c00 --- /dev/null +++ b/share/hysteria2.go @@ -0,0 +1,264 @@ +package share + +import ( + "encoding/json" + "errors" + "net" + "net/url" + "reflect" + "strconv" + "strings" + + "github.com/xtls/xray-core/infra/conf" +) + +var errHysteria2Link = errors.New("unsupported or invalid Hysteria2 share link") + +// Hysteria permits port lists in the authority, which net/url rejects. Replace +// only that port component before parsing; let net/url handle escaping/userinfo. +func hysteria2URL(text string) (*url.URL, conf.PortList, error) { + scheme, rest, _ := strings.Cut(text, "://") + end := strings.IndexAny(rest, "/?#") + if end < 0 { + end = len(rest) + } + authority, suffix := rest[:end], rest[end:] + user, hostPort := "", authority + if at := strings.LastIndexByte(authority, '@'); at >= 0 { + user, hostPort = authority[:at+1], authority[at+1:] + } + host, portText := hostPort, "443" + if strings.HasPrefix(hostPort, "[") { + close := strings.IndexByte(hostPort, ']') + if close < 0 { + return nil, conf.PortList{}, errHysteria2Link + } + host = hostPort[:close+1] + if net.ParseIP(host[1:len(host)-1]) == nil { + return nil, conf.PortList{}, errHysteria2Link + } + if tail := hostPort[close+1:]; tail != "" { + if !strings.HasPrefix(tail, ":") { + return nil, conf.PortList{}, errHysteria2Link + } + portText = tail[1:] + } + } else if strings.Contains(hostPort, ":") { + host, portText, _ = strings.Cut(hostPort, ":") + } + ports, err := hysteria2Ports(portText) + if err != nil { + return nil, conf.PortList{}, err + } + link, err := url.Parse(scheme + "://" + user + host + ":" + strconv.Itoa(int(ports.Range[0].From)) + suffix) + if err != nil || link.Hostname() == "" || (link.Path != "" && link.Path != "/") { + return nil, conf.PortList{}, errHysteria2Link + } + return link, ports, nil +} + +func hysteria2Ports(text string) (conf.PortList, error) { + ports := conf.PortList{} + for _, item := range strings.Split(text, ",") { + from, to, ranged := strings.Cut(item, "-") + if !ranged { + to = from + } + left, leftErr := strconv.ParseUint(from, 10, 16) + right, rightErr := strconv.ParseUint(to, 10, 16) + if leftErr != nil || rightErr != nil || left == 0 || left > right { + return conf.PortList{}, errHysteria2Link + } + ports.Range = append(ports.Range, conf.PortRange{From: uint32(left), To: uint32(right)}) + } + return ports, nil +} + +func parseHysteria2Link(text string) (*conf.OutboundDetourConfig, error) { + link, ports, err := hysteria2URL(text) + if err != nil { + return nil, err + } + query, err := url.ParseQuery(link.RawQuery) + if err != nil { + return nil, errHysteria2Link + } + for _, values := range query { + if len(values) != 1 { + return nil, errHysteria2Link + } + } + // Core no longer supports skipping verification. Its certificate pin is not + // equivalent to Hysteria's leaf pin + ordinary PKI. Retain explicit Xray pcs + // and vcn extensions, but never reinterpret the official TLS flags as those. + for _, key := range []string{"insecure", "allowInsecure"} { + if query.Has(key) { + insecure, err := strconv.ParseBool(query.Get(key)) + if err != nil || insecure { + return nil, errHysteria2Link + } + } + } + if query.Get("pinSHA256") != "" || (query.Get("security") != "" && query.Get("security") != "tls") { + return nil, errHysteria2Link + } + // Preserve old port-query links, while exporting standard multi-port authorities. + for _, key := range []string{"ports", "mport"} { + if query.Has(key) { + if len(ports.Range) != 1 || ports.Range[0].From != ports.Range[0].To || (key == "mport" && query.Has("ports")) { + return nil, errHysteria2Link + } + ports, err = hysteria2Ports(query.Get(key)) + if err != nil { + return nil, err + } + } + } + settings, err := convertJsonToRawMessage(&conf.HysteriaClientConfig{ + Version: 2, Address: parseAddress(link.Hostname()), Port: uint16(ports.Range[0].From), + }) + if err != nil { + return nil, err + } + auth := "" + if link.User != nil { + auth = link.User.Username() + if password, present := link.User.Password(); present { + auth += ":" + password + } + } + stream := &conf.StreamConfig{ + Network: new(conf.TransportProtocol("hysteria")), + HysteriaSettings: &conf.HysteriaConfig{Version: 2, Auth: auth}, + } + if err := (xrayShareLink{link: link}).parseSecurityFromURL(link, stream); err != nil { + return nil, err + } + mask := &conf.FinalMask{} + if obfs := query.Get("obfs"); obfs != "" && obfs != "none" { + password := query.Get("obfs-password") + if obfs != "salamander" || len(password) < 4 { + return nil, errHysteria2Link + } + raw, err := convertJsonToRawMessage(&conf.Salamander{Password: password}) + if err != nil { + return nil, err + } + mask.Udp = append(mask.Udp, conf.Mask{Type: "salamander", Settings: &raw}) + } else if query.Get("obfs-password") != "" { + return nil, errHysteria2Link + } + if len(ports.Range) > 1 || ports.Range[0].From != ports.Range[0].To { + interval := int64(30) + if query.Has("hop-interval") { + interval, err = strconv.ParseInt(query.Get("hop-interval"), 10, 32) + if err != nil || interval < 5 { + return nil, errHysteria2Link + } + } + raw, err := convertJsonToRawMessage(&conf.UDPHop{ + Mode: "intervalLocal,intervalRemote", RemotePorts: ports, + Interval: conf.Int32Range{Left: int32(interval), Right: int32(interval), From: int32(interval), To: int32(interval)}, + }) + if err != nil { + return nil, err + } + // The core wraps UDP masks in reverse order; hopping must wrap the raw socket. + mask.Udp = append(mask.Udp, conf.Mask{Type: "udphop", Settings: &raw}) + } else if query.Has("hop-interval") { + return nil, errHysteria2Link + } + if query.Get("up") != "" || query.Get("down") != "" { + mask.QuicParams = &conf.QuicParamsConfig{ + Congestion: "brutal", BrutalUp: conf.Bandwidth(query.Get("up")), BrutalDown: conf.Bandwidth(query.Get("down")), + } + } + if len(mask.Udp) > 0 || mask.QuicParams != nil { + stream.FinalMask = mask + } + return &conf.OutboundDetourConfig{ + Protocol: "hysteria", Tag: link.Fragment, Settings: &settings, StreamSetting: stream, + }, nil +} + +func hysteria2ShareLink(outbound conf.OutboundDetourConfig) (*url.URL, error) { + settings, err := decodeOutboundSettings[conf.HysteriaClientConfig](outbound) + if err != nil { + return nil, err + } + stream := outbound.StreamSetting + if settings.Version != 2 || settings.Address == nil || settings.Port == 0 || stream == nil || + stream.Security != "tls" || stream.HysteriaSettings == nil || stream.HysteriaSettings.Version != 2 { + return nil, errHysteria2Link + } + network := stream.Network + if stream.Method != nil { + network = stream.Method + } + if network == nil || *network != "hysteria" { + return nil, errHysteria2Link + } + host := strings.Trim(settings.Address.String(), "[]") + link := &url.URL{ + Scheme: "hysteria2", User: url.User(stream.HysteriaSettings.Auth), + Host: net.JoinHostPort(host, strconv.Itoa(int(settings.Port))), Fragment: getOutboundName(outbound), + } + // Reuse the existing TLS field projection; Hysteria does not use type/security. + streamSettingsQuery(outbound, link) + query := link.Query() + query.Del("type") + query.Del("security") + query.Del("fm") + if tls := stream.TLSSettings; tls != nil { + remaining := *tls + remaining.ServerName, remaining.Fingerprint, remaining.ECHConfigList = "", "", "" + remaining.PinnedPeerCertSha256, remaining.VerifyPeerCertByName = "", "" + remaining.ALPN = nil + if !reflect.DeepEqual(remaining, conf.TLSConfig{}) { + return nil, errHysteria2Link + } + if _, err := tls.Build(); err != nil { + return nil, errHysteria2Link + } + } + if mask := stream.FinalMask; mask != nil { + if len(mask.Tcp) > 0 { + return nil, errHysteria2Link + } + for index, entry := range mask.Udp { + if entry.Settings == nil { + return nil, errHysteria2Link + } + switch entry.Type { + case "salamander": + var obfs conf.Salamander + if err := json.Unmarshal(*entry.Settings, &obfs); err != nil || index != 0 || + len(obfs.Password) < 4 || obfs.PacketSize != (conf.Int32Range{}) { + return nil, errHysteria2Link + } + query.Set("obfs", "salamander") + query.Set("obfs-password", obfs.Password) + case "udphop": + var hop conf.UDPHop + if err := json.Unmarshal(*entry.Settings, &hop); err != nil || index != len(mask.Udp)-1 || + hop.Mode != "intervalLocal,intervalRemote" || len(hop.RemoteIPs) > 0 || hop.Sockopt != nil || + hop.Interval.From < 5 || hop.Interval.From != hop.Interval.To { + return nil, errHysteria2Link + } + ports, err := hysteria2Ports(hop.RemotePorts.String()) + if err != nil || (len(ports.Range) == 1 && ports.Range[0].From == ports.Range[0].To) { + return nil, errHysteria2Link + } + link.Host = net.JoinHostPort(host, ports.String()) + if hop.Interval.From != 30 { + query.Set("hop-interval", strconv.Itoa(int(hop.Interval.From))) + } + default: + return nil, errHysteria2Link + } + } + // QUIC bandwidth/tuning is client-local, not part of a standard share URI. + } + link.RawQuery = strings.ReplaceAll(query.Encode(), "+", "%20") + return link, nil +} diff --git a/share/hysteria2_test.go b/share/hysteria2_test.go new file mode 100644 index 00000000..ad2d200a --- /dev/null +++ b/share/hysteria2_test.go @@ -0,0 +1,225 @@ +package share + +import ( + "encoding/base64" + "encoding/json" + "net" + "net/url" + "strconv" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/xtls/xray-core/infra/conf" + "github.com/xtls/xray-core/transport/internet/finalmask" +) + +func TestHysteria2ImportAndRoundTrip(t *testing.T) { + for _, test := range []struct { + name, text, host, auth string + port uint16 + }{ + {"default TLS and port", "hy2://password@example.com", "example.com", "password", 443}, + {"empty auth", "hysteria2://example.com/", "example.com", "", 443}, + {"userpass", "hysteria2://user:p%3Aa%25ss%40word@example.com:8443/", "example.com", "user:p:a%ss@word", 8443}, + {"literal plus", "hy2://a+b%2Bc%252B@example.com#Test%20%E6%B5%8B%E8%AF%95", "example.com", "a+b+c%2B", 443}, + {"IPv6 default", "hy2://auth@[2001:db8::1]", "2001:db8::1", "auth", 443}, + {"IPv6 port", "hysteria2://auth@[2001:db8::2]:8443?sni=server.example", "2001:db8::2", "auth", 8443}, + } { + t.Run(test.name, func(t *testing.T) { + data, err := ConvertShareLinksToXrayJson(test.text, "") + require.NoError(t, err) + var config conf.Config + require.NoError(t, json.Unmarshal(data, &config)) + require.Len(t, config.OutboundConfigs, 1) + outbound := config.OutboundConfigs[0] + settings, err := decodeOutboundSettings[conf.HysteriaClientConfig](outbound) + require.NoError(t, err) + assert.Equal(t, parseAddress(test.host), settings.Address) + assert.Equal(t, test.port, settings.Port) + assert.Equal(t, int32(2), settings.Version) + assert.Equal(t, "hysteria", outbound.Protocol) + assert.Equal(t, "tls", outbound.StreamSetting.Security) + assert.Equal(t, test.auth, outbound.StreamSetting.HysteriaSettings.Auth) + assert.Equal(t, int32(2), outbound.StreamSetting.HysteriaSettings.Version) + requireProjectedOutboundsBuild(t, data) + generated, err := ConvertXrayJsonToShareLinks(data) + require.NoError(t, err) + assert.True(t, strings.HasPrefix(generated, "hysteria2://")) + again, err := ConvertShareLinksToXrayJson(generated, "") + require.NoError(t, err) + // Nameless nodes acquire the existing protocol-name fallback on export. + if outbound.Tag == "" { + outbound.Tag = "hysteria" + config.OutboundConfigs[0] = outbound + data, err = marshalShareConfigJSON(&config) + require.NoError(t, err) + } + assert.JSONEq(t, string(data), string(again)) + }) + } +} + +func TestHysteria2TLSAndMaskRoundTrip(t *testing.T) { + for _, authority := range []string{"example.com:443,5000-5002", "[2001:db8::1]:5000-5002"} { + input := "hy2://auth@" + authority + "/?sni=server.example&obfs=salamander&obfs-password=pass%2Bword&hop-interval=5&fp=chrome&alpn=h3&ech=aGVsbG8%3D&pcs=" + strings.Repeat("ab", 32) + "&vcn=server.example#Node" + data, err := ConvertShareLinksToXrayJson(input, "") + require.NoError(t, err) + generated, err := ConvertXrayJsonToShareLinks(data) + require.NoError(t, err) + assert.Contains(t, generated, "@"+authority) + assert.NotContains(t, generated, "fm=") + assert.NotContains(t, generated, "security=") + assert.NotContains(t, generated, "type=") + again, err := ConvertShareLinksToXrayJson(generated, "") + require.NoError(t, err) + assert.JSONEq(t, string(data), string(again)) + } +} + +func TestHysteria2LegacyPortQueriesAndBandwidth(t *testing.T) { + for _, key := range []string{"ports", "mport"} { + config, err := convertShareLinksWithKeyForTest("hy2://auth@host:443?"+key+"=5000-5002&up=50%20mbps&down=100%20mbps", "") + require.NoError(t, err) + mask := config.OutboundConfigs[0].StreamSetting.FinalMask + require.NotNil(t, mask) + assert.Equal(t, conf.Bandwidth("50 mbps"), mask.QuicParams.BrutalUp) + var hop conf.UDPHop + require.NoError(t, json.Unmarshal(*mask.Udp[0].Settings, &hop)) + assert.Equal(t, "intervalLocal,intervalRemote", hop.Mode) + assert.Equal(t, int32(30), hop.Interval.From) + link, err := shareLink(config.OutboundConfigs[0]) + require.NoError(t, err) + assert.Contains(t, link.String(), "host:5000-5002") + assert.Empty(t, link.Query().Get("up")) + assert.Empty(t, link.Query().Get("down")) + } +} + +func TestHysteria2RejectsInvalidOrUnsupportedLinks(t *testing.T) { + for _, suffix := range []string{ + "", ":443", "example.com:", "example.com:0", "example.com:65536", "example.com:-1", + "example.com:5002-5000", "example.com:443,", "example.com:443,,444", "example.com:env:PORT", + "2001:db8::1", "[example.com]:443", "[2001:db8::1", "example.com/path", + "example.com?insecure=1", "example.com?allowInsecure=true", "example.com?insecure=invalid", + "example.com?insecure=0&insecure=1", "example.com?pinSHA256=" + strings.Repeat("ab", 32), + "example.com?insecure=1&pinSHA256=" + strings.Repeat("ab", 32), + "example.com?security=none", "example.com?security=reality", + "example.com?obfs=gecko&obfs-password=secret", "example.com?obfs=salamander", + "example.com?obfs=salamander&obfs-password=abc", "example.com?obfs-password=secret", + "example.com?pcs=not-a-pin", "example.com?hop-interval=5", + "example.com:443,444?hop-interval=0", "example.com:443,444?hop-interval=4", + "example.com:443,444?hop-interval=no", "example.com:443,444?ports=445,446", + "example.com?ports=443,444&mport=445,446", "example.com?ports=", + "example.com?obfs=salamander&obfs-password=%zz", "example.com?up=not-bandwidth", + } { + t.Run(suffix, func(t *testing.T) { + data, err := ConvertShareLinksToXrayJson("hy2://private-password@"+suffix, "") + require.Error(t, err) + assert.Nil(t, data) + assert.NotContains(t, err.Error(), "private-password") + }) + } + for _, value := range []string{"0", "false"} { + _, err := ConvertShareLinksToXrayJson("hy2://auth@host?insecure="+value, "") + require.NoError(t, err) + } +} + +func TestHysteria2SubscriptionsKeepOrder(t *testing.T) { + text := "hy2://one@first.example#First\n" + ageTestShareLink + "\nhy2://bad@bad.example?insecure=1\nhysteria2://last@last.example#Last" + for _, input := range []string{text, base64.StdEncoding.EncodeToString([]byte(text)), base64.RawURLEncoding.EncodeToString([]byte(text))} { + config, err := convertShareLinksWithKeyForTest(input, "") + require.NoError(t, err) + require.Len(t, config.OutboundConfigs, 3) + assert.Equal(t, "First", config.OutboundConfigs[0].Tag) + assert.Equal(t, "vless", config.OutboundConfigs[1].Protocol) + assert.Equal(t, "Last", config.OutboundConfigs[2].Tag) + } +} + +func TestHysteria2ExportRejectsUnrepresentableSecurityAndMasks(t *testing.T) { + for _, mutate := range []func(*conf.StreamConfig){ + func(s *conf.StreamConfig) { s.TLSSettings.AllowInsecure = true }, + func(s *conf.StreamConfig) { s.TLSSettings.DisableSystemRoot = true }, + func(s *conf.StreamConfig) { s.TLSSettings.MinVersion = "1.3" }, + func(s *conf.StreamConfig) { s.Method = new(conf.TransportProtocol("ws")) }, + func(s *conf.StreamConfig) { s.FinalMask = &conf.FinalMask{Udp: []conf.Mask{{Type: "noise"}}} }, + func(s *conf.StreamConfig) { + raw := json.RawMessage(`{"password":"secret","packetSize":"100-200"}`) + s.FinalMask = &conf.FinalMask{Udp: []conf.Mask{{Type: "salamander", Settings: &raw}}} + }, + func(s *conf.StreamConfig) { + raw := json.RawMessage(`{"mode":"intervalRemote","interval":30,"remotePorts":"443,444"}`) + s.FinalMask = &conf.FinalMask{Udp: []conf.Mask{{Type: "udphop", Settings: &raw}}} + }, + } { + config, err := convertShareLinksForTest("hy2://auth@host?sni=host") + require.NoError(t, err) + mutate(config.OutboundConfigs[0].StreamSetting) + _, err = shareLink(config.OutboundConfigs[0]) + require.Error(t, err) + } +} + +// Exercise packet replies, not just mask construction: the remote-only hopping +// mode used by the former converter never starts the pinned core's read loop. +func TestHysteria2PortHoppingReceivesAfterHop(t *testing.T) { + if testing.Short() { + t.Skip("waits for a real local UDP hop") + } + server, err := net.ListenPacket("udp", "127.0.0.1:0") + require.NoError(t, err) + t.Cleanup(func() { _ = server.Close() }) + go func() { + buf := make([]byte, 64) + for { + n, addr, err := server.ReadFrom(buf) + if err != nil { + return + } + _, _ = server.WriteTo([]byte(addr.String()+":"+string(buf[:n])), addr) + } + }() + port := server.LocalAddr().(*net.UDPAddr).Port + config, err := convertShareLinksForTest("hy2://auth@127.0.0.1:" + strconv.Itoa(port) + "," + strconv.Itoa(port) + "?hop-interval=5") + require.NoError(t, err) + mask := config.OutboundConfigs[0].StreamSetting.FinalMask.Udp[0] + built, err := mask.Build(false) + require.NoError(t, err) + raw, err := net.ListenPacket("udp", "127.0.0.1:0") + require.NoError(t, err) + t.Cleanup(func() { _ = raw.Close() }) + conn, err := finalmask.NewUdpmaskManager([]finalmask.Udpmask{built.(finalmask.Udpmask)}).WrapPacketConnClient(raw) + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + exchange := func() string { + t.Helper() + require.NoError(t, conn.SetDeadline(time.Now().Add(2*time.Second))) + _, err := conn.WriteTo([]byte("echo"), server.LocalAddr()) + require.NoError(t, err) + buf := make([]byte, 128) + n, _, err := conn.ReadFrom(buf) + require.NoError(t, err) + assert.True(t, strings.HasSuffix(string(buf[:n]), ":echo")) + return string(buf[:n]) + } + first := exchange() + // Clear the first exchange's deadline before the replacement socket inherits it. + require.NoError(t, conn.SetDeadline(time.Time{})) + time.Sleep(5500 * time.Millisecond) + second := exchange() + assert.NotEqual(t, first, second, "the local UDP socket should change after hopping") +} + +func TestHysteria2ShareDoesNotUseQueryEscapingForUserinfo(t *testing.T) { + data, err := ConvertShareLinksToXrayJson("hy2://user%3Apassword%2B%25@example.com", "") + require.NoError(t, err) + text, err := ConvertXrayJsonToShareLinks(data) + require.NoError(t, err) + u, err := url.Parse(text) + require.NoError(t, err) + assert.Equal(t, "user:password+%", u.User.Username()) +} diff --git a/share/parse_share.go b/share/parse_share.go index 1901fc80..0ef5bf6f 100644 --- a/share/parse_share.go +++ b/share/parse_share.go @@ -44,6 +44,7 @@ func decodeBase64Text(text string) (string, error) { var shareSchemes = []string{ "vless://", "vmess://", "socks://", "ss://", "trojan://", + "hysteria2://", "hy2://", } func hasShareSchemeLine(text string) bool { @@ -63,6 +64,17 @@ type xrayShareLink struct { rawText string } +func parseOutboundShareLink(text string) (*conf.OutboundDetourConfig, error) { + if strings.HasPrefix(text, "hysteria2://") || strings.HasPrefix(text, "hy2://") { + return parseHysteria2Link(text) + } + link, err := url.Parse(text) + if err != nil { + return nil, err + } + return (xrayShareLink{link: link, rawText: text}).outbound() +} + func (proxy xrayShareLink) outbound() (*conf.OutboundDetourConfig, error) { switch proxy.link.Scheme { case "ss": diff --git a/share/stream.go b/share/stream.go index 6926d9cb..89852603 100644 --- a/share/stream.go +++ b/share/stream.go @@ -64,7 +64,7 @@ func (proxy xrayShareLink) parseSecurityFromURL(link *url.URL, streamSettings *c } switch proxy.link.Scheme { - case "trojan": + case "trojan", "hysteria2", "hy2": if streamSettings.Security == "none" { streamSettings.Security = "tls" } From 54738ead8c889995cb22f2c0499cc0203bc7d184 Mon Sep 17 00:00:00 2001 From: yiguodev <147401898+yiguodev@users.noreply.github.com> Date: Wed, 30 Sep 2026 19:35:51 +0800 Subject: [PATCH 25/38] Update Xray-core to v26.9.30 and refresh dependencies (#160) * chore: update Xray-core to v26.9.30 and refresh dependencies * build: allow rebuilding Go modules without snapshots --- AGENTS.md | 3 +- README.md | 16 ++++- build/app/apple_go.py | 9 ++- build/app/build.py | 16 ++++- build/app/linux.py | 9 ++- build/app/windows.py | 9 ++- build/main.py | 41 +++++++----- build/test_build.py | 138 ++++++++++++++++++++++++++++++++++++++++ go.mod | 10 ++- go.sum | 38 +++-------- readme/README.zh_CN.md | 14 +++- share/hysteria2.go | 5 +- share/hysteria2_test.go | 21 ++++-- 13 files changed, 257 insertions(+), 72 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index daa98e94..907ff9a4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,7 +44,8 @@ and the relevant platform/controller section in README. process resolver and `ResetDNS` follows managed-instance shutdown. - Use `build/main.py` to generate native artifacts; do not edit generated headers, archives, or binaries. Verify temporary module edits are restored - after a build and check the build command's success and resulting artifacts. + after a build by default, or retained when `--no-snapshot` is explicitly used. + Check the build command's success and resulting artifacts. - Modify an adjacent Xray-core checkout only when explicitly requested. Targets and local-core options are documented in [build usage](README.md#usage). diff --git a/README.md b/README.md index a9d1244c..0ad9161a 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ Compile script. It is recommended to always use this script to compile libXray. depends on git and go. -By default, the build script does not clone [Xray-core](https://github.com/XTLS/Xray-core). It uses Go modules and pins Xray-core to release tag `v26.9.9` through its pseudo-version. +By default, the build script does not clone [Xray-core](https://github.com/XTLS/Xray-core). It uses Go modules and pins Xray-core to release tag `v26.9.30` through its pseudo-version. Pass the optional `local` argument to use an existing local checkout at `../Xray-core` through a Go module `replace`. ### Usage @@ -66,7 +66,19 @@ python3 build/main.py windows local ``` -Builds restore `go.mod` and `go.sum` on success or failure. Gomobile builds +Builds snapshot and restore `go.mod` and `go.sum` on success or failure by default. +Pass `--no-snapshot` to skip both the snapshot and restoration. Before initializing +Go dependencies, the script deletes existing `go.mod` and `go.sum` and creates a +fresh module. The resulting files are retained even if the build fails. +This option is supported by every target +and can be combined with `local` in either order: + +```shell +python3 build/main.py android --no-snapshot +python3 build/main.py apple go local --no-snapshot +``` + +Gomobile builds resolve `latest` by default; set `LIBXRAY_GOMOBILE_VERSION` to select a Go module version. Both `gomobile` and `gobind` use that resolved version. diff --git a/build/app/apple_go.py b/build/app/apple_go.py index bf4def26..a36d8835 100644 --- a/build/app/apple_go.py +++ b/build/app/apple_go.py @@ -21,8 +21,13 @@ def __init__( class AppleGoBuilder(Builder): - def __init__(self, build_dir: str, use_local_xray_core: bool = False): - super().__init__(build_dir, use_local_xray_core) + def __init__( + self, + build_dir: str, + use_local_xray_core: bool = False, + keep_go_env_snapshot: bool = True, + ): + super().__init__(build_dir, use_local_xray_core, keep_go_env_snapshot) self.framework_dir = os.path.join(self.lib_dir, "apple_xcframework") delete_dir_if_exists(self.framework_dir) create_dir_if_not_exists(self.framework_dir) diff --git a/build/app/build.py b/build/app/build.py index 725bd0eb..74bc5712 100644 --- a/build/app/build.py +++ b/build/app/build.py @@ -9,16 +9,22 @@ LIBXRAY_MOD_NAME = "github.com/xtls/libxray" XRAY_CORE_MOD_NAME = "github.com/xtls/xray-core" -# Go modules resolve the Xray-core v26.9.9 release tag through this version. -DEFAULT_XRAY_CORE_VERSION = "v1.260327.1-0.20260908222543-52a412d9e2f5" +# Go modules resolve the Xray-core v26.9.30 release tag through this version. +DEFAULT_XRAY_CORE_VERSION = "v1.260327.1-0.20260930074004-b26a91de4f32" LOCAL_XRAY_CORE_DIR_NAME = "Xray-core" class Builder(object): - def __init__(self, build_dir: str, use_local_xray_core: bool = False): + def __init__( + self, + build_dir: str, + use_local_xray_core: bool = False, + keep_go_env_snapshot: bool = True, + ): self.build_dir = build_dir self.lib_dir = os.path.abspath(os.path.join(self.build_dir, "..")) self.use_local_xray_core = use_local_xray_core + self.keep_go_env_snapshot = keep_go_env_snapshot self.xray_core_replace_path = f"../{LOCAL_XRAY_CORE_DIR_NAME}" self.xray_core_dir = os.path.abspath( os.path.join(self.lib_dir, self.xray_core_replace_path) @@ -26,6 +32,8 @@ def __init__(self, build_dir: str, use_local_xray_core: bool = False): self._go_env_snapshot = None def snapshot_go_env(self): + if not self.keep_go_env_snapshot: + return paths = [ os.path.join(self.lib_dir, "go.mod"), os.path.join(self.lib_dir, "go.sum"), @@ -68,6 +76,8 @@ def prepare_xray_core(self): def init_go_env(self): os.chdir(self.lib_dir) + if not self.keep_go_env_snapshot: + self.clean_lib_files(["go.mod", "go.sum"]) if not os.path.exists(os.path.join(self.lib_dir, "go.mod")): ret = subprocess.run(["go", "mod", "init", LIBXRAY_MOD_NAME]) if ret.returncode != 0: diff --git a/build/app/linux.py b/build/app/linux.py index 79051b6b..704f01c5 100644 --- a/build/app/linux.py +++ b/build/app/linux.py @@ -6,8 +6,13 @@ class LinuxBuilder(Builder): - def __init__(self, build_dir: str, use_local_xray_core: bool = False): - super().__init__(build_dir, use_local_xray_core) + def __init__( + self, + build_dir: str, + use_local_xray_core: bool = False, + keep_go_env_snapshot: bool = True, + ): + super().__init__(build_dir, use_local_xray_core, keep_go_env_snapshot) self.framework_dir = os.path.join(self.lib_dir, "linux_so") delete_dir_if_exists(self.framework_dir) create_dir_if_not_exists(self.framework_dir) diff --git a/build/app/windows.py b/build/app/windows.py index 88870111..3ed8df18 100644 --- a/build/app/windows.py +++ b/build/app/windows.py @@ -6,8 +6,13 @@ class WindowsBuilder(Builder): - def __init__(self, build_dir: str, use_local_xray_core: bool = False): - super().__init__(build_dir, use_local_xray_core) + def __init__( + self, + build_dir: str, + use_local_xray_core: bool = False, + keep_go_env_snapshot: bool = True, + ): + super().__init__(build_dir, use_local_xray_core, keep_go_env_snapshot) self.framework_dir = os.path.join(self.lib_dir, "windows_dll") delete_dir_if_exists(self.framework_dir) create_dir_if_not_exists(self.framework_dir) diff --git a/build/main.py b/build/main.py index 19b77fab..d3d056b8 100644 --- a/build/main.py +++ b/build/main.py @@ -9,6 +9,7 @@ from app.windows import WindowsBuilder LOCAL_ARG = "local" +NO_SNAPSHOT_ARG = "--no-snapshot" def build_dir_path(): @@ -17,12 +18,12 @@ def build_dir_path(): return dir_path -def parse_local_arg(args: list[str]) -> bool: - if not args: - return False - if args == [LOCAL_ARG]: - return True - raise Exception(f"unsupported args: {args}") +def parse_build_args(args: list[str]) -> tuple[bool, bool]: + if len(args) != len(set(args)) or any( + arg not in (LOCAL_ARG, NO_SNAPSHOT_ARG) for arg in args + ): + raise Exception(f"unsupported args: {args}") + return LOCAL_ARG in args, NO_SNAPSHOT_ARG not in args if __name__ == "__main__": @@ -31,29 +32,39 @@ def parse_local_arg(args: list[str]) -> bool: if platform == "apple": tool = sys.argv[2] - use_local_xray_core = parse_local_arg(sys.argv[3:]) + use_local_xray_core, keep_go_env_snapshot = parse_build_args(sys.argv[3:]) if tool == "go": - builder = AppleGoBuilder(build_dir_path(), use_local_xray_core) + builder = AppleGoBuilder( + build_dir_path(), use_local_xray_core, keep_go_env_snapshot + ) builder.build() elif tool == "gomobile": - builder = AppleGoMobileBuilder(build_dir_path(), use_local_xray_core) + builder = AppleGoMobileBuilder( + build_dir_path(), use_local_xray_core, keep_go_env_snapshot + ) builder.build() else: raise Exception(f"platform {platform} tool {tool} not supported") elif platform == "android": - use_local_xray_core = parse_local_arg(sys.argv[2:]) - builder = AndroidBuilder(build_dir_path(), use_local_xray_core) + use_local_xray_core, keep_go_env_snapshot = parse_build_args(sys.argv[2:]) + builder = AndroidBuilder( + build_dir_path(), use_local_xray_core, keep_go_env_snapshot + ) builder.build() elif platform == "linux": - use_local_xray_core = parse_local_arg(sys.argv[2:]) - builder = LinuxBuilder(build_dir_path(), use_local_xray_core) + use_local_xray_core, keep_go_env_snapshot = parse_build_args(sys.argv[2:]) + builder = LinuxBuilder( + build_dir_path(), use_local_xray_core, keep_go_env_snapshot + ) builder.build() elif platform == "windows": - use_local_xray_core = parse_local_arg(sys.argv[2:]) - builder = WindowsBuilder(build_dir_path(), use_local_xray_core) + use_local_xray_core, keep_go_env_snapshot = parse_build_args(sys.argv[2:]) + builder = WindowsBuilder( + build_dir_path(), use_local_xray_core, keep_go_env_snapshot + ) builder.build() else: diff --git a/build/test_build.py b/build/test_build.py index 47d61033..63956632 100644 --- a/build/test_build.py +++ b/build/test_build.py @@ -1,5 +1,6 @@ """Run: python3 build/test_build.py. No Go or platform build is run.""" from pathlib import Path +import runpy import shutil import subprocess import unittest @@ -7,6 +8,12 @@ from uuid import uuid4 from app.android import AndroidBuilder +from app.apple_go import AppleGoBuilder +from app.apple_gomobile import AppleGoMobileBuilder +from app.build import LIBXRAY_MOD_NAME, XRAY_CORE_MOD_NAME, DEFAULT_XRAY_CORE_VERSION +from app.linux import LinuxBuilder +from app.windows import WindowsBuilder +from main import parse_build_args class BuildTest(unittest.TestCase): @@ -50,6 +57,137 @@ def prepare(): self.assertEqual(list((self.root / "build").iterdir()), []) self.assertIsNone(self.builder._go_env_snapshot) + def test_no_snapshot_retains_modules_on_success_and_failure(self): + for exists in (False, True): + for fails in (False, True): + with self.subTest(exists=exists, fails=fails): + self.builder = AndroidBuilder( + str(self.root / "build"), keep_go_env_snapshot=False + ) + for name in ("go.mod", "go.sum"): + path = self.root / name + if exists: + path.write_text("original\n") + else: + path.unlink(missing_ok=True) + + def prepare(): + self.assertIsNone(self.builder._go_env_snapshot) + (self.root / "go.mod").write_text("effective module\n") + (self.root / "go.sum").write_text("effective sums\n") + if fails: + raise RuntimeError("original build failed") + + with ( + patch.object(self.builder, "before_build", side_effect=prepare), + patch("app.android.os.chdir"), + patch("app.android.subprocess.run", return_value=subprocess.CompletedProcess([], 0)), + ): + if fails: + with self.assertRaisesRegex(RuntimeError, "original build failed"): + self.builder.build() + else: + self.builder.build() + + self.assertEqual((self.root / "go.mod").read_text(), "effective module\n") + self.assertEqual((self.root / "go.sum").read_text(), "effective sums\n") + self.assertIsNone(self.builder._go_env_snapshot) + + def test_build_args(self): + for args, expected in ( + ([], (False, True)), + (["local"], (True, True)), + (["--no-snapshot"], (False, False)), + (["local", "--no-snapshot"], (True, False)), + (["--no-snapshot", "local"], (True, False)), + ): + with self.subTest(args=args): + self.assertEqual(parse_build_args(args), expected) + for args in ( + ["unknown"], ["local", "local"], ["--no-snapshot", "--no-snapshot"], + ): + with self.subTest(args=args): + with self.assertRaisesRegex(Exception, "unsupported args"): + parse_build_args(args) + + def test_no_snapshot_reinitializes_modules_before_dependency_commands(self): + for use_local in (False, True): + with self.subTest(use_local=use_local): + (self.root / "go.mod").write_text("old module\n") + (self.root / "go.sum").write_text("old sums\n") + builder = AndroidBuilder(str(self.root / "build"), use_local, False) + + def run(args): + if args == ["go", "mod", "init", LIBXRAY_MOD_NAME]: + self.assertFalse((self.root / "go.mod").exists()) + self.assertFalse((self.root / "go.sum").exists()) + (self.root / "go.mod").write_text("new module\n") + else: + self.assertEqual((self.root / "go.mod").read_text(), "new module\n") + return subprocess.CompletedProcess(args, 0) + + with patch("app.build.os.chdir"), patch( + "app.build.subprocess.run", side_effect=run + ) as commands: + builder.init_go_env() + expected = [call(["go", "mod", "init", LIBXRAY_MOD_NAME])] + if use_local: + expected.append(call([ + "go", "mod", "edit", f"-replace={XRAY_CORE_MOD_NAME}=../Xray-core", + ])) + else: + expected.extend([ + call(["go", "mod", "edit", f"-dropreplace={XRAY_CORE_MOD_NAME}"]), + call(["go", "get", f"{XRAY_CORE_MOD_NAME}@{DEFAULT_XRAY_CORE_VERSION}"]), + ]) + expected.append(call(["go", "mod", "tidy"])) + self.assertEqual(commands.call_args_list, expected) + + def test_default_initialization_keeps_existing_modules(self): + (self.root / "go.mod").write_text("original module\n") + (self.root / "go.sum").write_text("original sums\n") + with patch("app.build.os.chdir"), patch( + "app.build.subprocess.run", return_value=subprocess.CompletedProcess([], 0) + ) as commands: + self.builder.init_go_env() + self.assertEqual((self.root / "go.mod").read_text(), "original module\n") + self.assertEqual((self.root / "go.sum").read_text(), "original sums\n") + self.assertNotIn(call(["go", "mod", "init", LIBXRAY_MOD_NAME]), commands.call_args_list) + + def test_all_builders_accept_snapshot_option(self): + for builder_type in ( + AndroidBuilder, AppleGoBuilder, AppleGoMobileBuilder, + LinuxBuilder, WindowsBuilder, + ): + for keep_snapshot in (False, True): + with self.subTest(builder=builder_type.__name__, keep_snapshot=keep_snapshot): + builder = builder_type( + str(self.root / "build"), True, keep_snapshot + ) + self.assertTrue(builder.use_local_xray_core) + self.assertEqual(builder.keep_go_env_snapshot, keep_snapshot) + + def test_cli_passes_options_to_every_builder(self): + script = Path(__file__).with_name("main.py") + for target, builder_path in ( + (["android"], "app.android.AndroidBuilder"), + (["apple", "go"], "app.apple_go.AppleGoBuilder"), + (["apple", "gomobile"], "app.apple_gomobile.AppleGoMobileBuilder"), + (["linux"], "app.linux.LinuxBuilder"), + (["windows"], "app.windows.WindowsBuilder"), + ): + for options in ([], ["local"], ["--no-snapshot"], ["local", "--no-snapshot"], ["--no-snapshot", "local"]): + with self.subTest(target=target, options=options), patch( + builder_path + ) as builder, patch( + "sys.argv", [str(script), *target, *options] + ), patch("builtins.print"): + runpy.run_path(str(script), run_name="__main__") + builder.assert_called_once_with( + str(script.parent), *parse_build_args(options) + ) + builder.return_value.build.assert_called_once_with() + def test_gomobile_and_gobind_use_the_same_resolved_version(self): version = "v0.0.0-20260821190718-4776eadac327" for requested in ("", version): diff --git a/go.mod b/go.mod index 11cdb6e1..4b9f2687 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.27.1 require ( github.com/metacubex/age v0.0.0-20260603010618-28d156b4ea78 github.com/stretchr/testify v1.12.1 - github.com/xtls/xray-core v1.260327.1-0.20260908222543-52a412d9e2f5 + github.com/xtls/xray-core v1.260327.1-0.20260930074004-b26a91de4f32 golang.org/x/mobile v0.0.0-20260908204917-8b95e45f8d3e golang.org/x/sys v0.48.0 google.golang.org/protobuf v1.36.12 @@ -42,8 +42,6 @@ require ( github.com/quic-go/qpack v0.6.0 // indirect github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af // indirect github.com/robfig/cron/v3 v3.0.1 // indirect - github.com/sagernet/sing v0.5.1 // indirect - github.com/sagernet/sing-shadowsocks v0.2.7 // indirect github.com/vishvananda/netlink v1.3.1 // indirect github.com/vishvananda/netns v0.0.5 // indirect github.com/wlynxg/anet v0.0.5 // indirect @@ -60,9 +58,9 @@ require ( golang.org/x/tools v0.50.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect - golang.zx2c4.com/wireguard/windows v1.0.1 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect - google.golang.org/grpc v1.83.2 // indirect + golang.zx2c4.com/wireguard/windows v1.1.1 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800 // indirect + google.golang.org/grpc v1.84.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect lukechampine.com/blake3 v1.4.1 // indirect diff --git a/go.sum b/go.sum index 80aa10af..07a65f36 100644 --- a/go.sum +++ b/go.sum @@ -2,17 +2,11 @@ github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sx github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= github.com/apernet/quic-go v0.61.1-0.20260806010916-184d081eef3e h1:5mgtR5gwIgBKMiGI1QdXldZZ+SNor06Nbu1wCBulQBg= github.com/apernet/quic-go v0.61.1-0.20260806010916-184d081eef3e/go.mod h1:x7qxEvX6MCVtDuBKHj3E+88+BtrbEMuAL5qGUKItjW8= -github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= -github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudflare/circl v1.6.5 h1:O64F26HEqNhznd/hrC5KZXVKYuKM2rx4deZDTc4ihQA= github.com/cloudflare/circl v1.6.5/go.mod h1:h5LNyxAc5nTue9DS5jT+48en2PSDYt3zdGnz5OstK6c= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344 h1:Arcl6UOIS/kgO2nW3A65HN+7CMjSDP/gofXL4CZt1V4= github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344/go.mod h1:GIjDIg/heH5DOkXY3YJ/wNhfHsQHoXGjl8G8amsYQ1I= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= -github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= -github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/golang/mock v1.7.0-rc.1 h1:YojYx61/OLFsiv6Rw1Z96LpldJIy31o+UHmwAUMJ6/U= github.com/golang/mock v1.7.0-rc.1/go.mod h1:s42URUywIqd+OcERslBJvOjepvNymP31m3q8d/GkuRs= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= @@ -79,10 +73,6 @@ github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= -github.com/sagernet/sing v0.5.1 h1:mhL/MZVq0TjuvHcpYcFtmSD1BFOxZ/+8ofbNZcg1k1Y= -github.com/sagernet/sing v0.5.1/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak= -github.com/sagernet/sing-shadowsocks v0.2.7 h1:zaopR1tbHEw5Nk6FAkM05wCslV6ahVegEZaKMv9ipx8= -github.com/sagernet/sing-shadowsocks v0.2.7/go.mod h1:0rIKJZBR65Qi0zwdKezt4s57y/Tl1ofkaq6NlkzVuyE= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0= @@ -93,20 +83,8 @@ github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= github.com/xtls/reality v0.0.0-20260908062103-8cdf7bf9c7f0 h1:rb+fKQFhz+5I2PPuQsNYxI5mUU840XWYtRF0ZBjvkws= github.com/xtls/reality v0.0.0-20260908062103-8cdf7bf9c7f0/go.mod h1:DsJblcWDGt76+FVqBVwbwRhxyyNJsGV48gJLch0OOWI= -github.com/xtls/xray-core v1.260327.1-0.20260908222543-52a412d9e2f5 h1:BsUC2sCXcdVCb09SUh1iWku0ci779t4bUIlKUor1ZRI= -github.com/xtls/xray-core v1.260327.1-0.20260908222543-52a412d9e2f5/go.mod h1:obbr2WDmr/cpQ/YLe1k0HTULnFXCO0rTWIeSrHoFk3o= -go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= -go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= -go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= -go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= -go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= -go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= -go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= -go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= -go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= -go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= -go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +github.com/xtls/xray-core v1.260327.1-0.20260930074004-b26a91de4f32 h1:Bxo6+07IvdWlqxugsn2/sQrFbR7hjrxRRDMjbOInRho= +github.com/xtls/xray-core v1.260327.1-0.20260930074004-b26a91de4f32/go.mod h1:FAjlDAzHXXyki7R1BxruCHFx19B3i8TvQGEy+wsqZWU= go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= @@ -156,14 +134,14 @@ golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeu golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A= golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw= -golang.zx2c4.com/wireguard/windows v1.0.1 h1:eOxiDVbywPC+ZQqvdCK7x+ZwWXKbYv50TtH8ysFIbw8= -golang.zx2c4.com/wireguard/windows v1.0.1/go.mod h1:+fbT3FFdX4zzYDLwJh5+HPEcNN/3HyNdzhNSVsQM+zs= +golang.zx2c4.com/wireguard/windows v1.1.1 h1:8/H97U1v1PNDNcBsMZgU3KFuND9MQdTsU2NOwmCXArE= +golang.zx2c4.com/wireguard/windows v1.1.1/go.mod h1:+fbT3FFdX4zzYDLwJh5+HPEcNN/3HyNdzhNSVsQM+zs= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= -google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800 h1:qEHAMpSaUhtD0p3NbEEI83HwNGFxEwaSJ1G9PLnCBZE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.84.0 h1:soMyaPJ8pAak5PIQ0DGBUir0XRo2fRoMqhNWMLlLxO0= +google.golang.org/grpc v1.84.0/go.mod h1:ljCht0DrxQrXBDRTZp52Qxh3Ffk8CdYm2sj4O2QN2C0= google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/readme/README.zh_CN.md b/readme/README.zh_CN.md index a4aa0830..ac8b6e41 100644 --- a/readme/README.zh_CN.md +++ b/readme/README.zh_CN.md @@ -16,7 +16,7 @@ 依赖 git 和 go。 -默认情况下,编译脚本不会 clone [Xray-core](https://github.com/XTLS/Xray-core),而是通过 Go modules 的 pseudo-version 将 Xray-core 固定到发布版本 `v26.9.9`。 +默认情况下,编译脚本不会 clone [Xray-core](https://github.com/XTLS/Xray-core),而是通过 Go modules 的 pseudo-version 将 Xray-core 固定到发布版本 `v26.9.30`。 传入可选参数 `local` 时,会通过 Go module `replace` 改用已有的本地仓库 `../Xray-core`。 ### 使用方式 @@ -34,7 +34,17 @@ python3 build/main.py windows python3 build/main.py windows local ``` -构建成功或失败后都会恢复 `go.mod` 和 `go.sum`。gomobile 默认解析 `latest`, +默认会为 `go.mod` 和 `go.sum` 保存快照,并在构建成功或失败后恢复。 +传入 `--no-snapshot` 可跳过快照和恢复;初始化 Go 依赖前会先删除现有的 +`go.mod` 和 `go.sum`,再重新初始化模块,即使构建失败也会保留重新生成的文件。 +所有构建目标均支持此参数,可以与 `local` 组合使用,顺序不限: + +```shell +python3 build/main.py android --no-snapshot +python3 build/main.py apple go local --no-snapshot +``` + +gomobile 默认解析 `latest`, 也可通过环境变量 `LIBXRAY_GOMOBILE_VERSION` 指定 Go 模块版本;`gomobile` 与 `gobind` 使用同一个解析版本。 diff --git a/share/hysteria2.go b/share/hysteria2.go index 71b34c00..f6af5ccd 100644 --- a/share/hysteria2.go +++ b/share/hysteria2.go @@ -239,7 +239,10 @@ func hysteria2ShareLink(outbound conf.OutboundDetourConfig) (*url.URL, error) { query.Set("obfs", "salamander") query.Set("obfs-password", obfs.Password) case "udphop": - var hop conf.UDPHop + var hop struct { + conf.UDPHop + Sockopt *json.RawMessage `json:"sockopt"` + } if err := json.Unmarshal(*entry.Settings, &hop); err != nil || index != len(mask.Udp)-1 || hop.Mode != "intervalLocal,intervalRemote" || len(hop.RemoteIPs) > 0 || hop.Sockopt != nil || hop.Interval.From < 5 || hop.Interval.From != hop.Interval.To { diff --git a/share/hysteria2_test.go b/share/hysteria2_test.go index ad2d200a..1b0cb780 100644 --- a/share/hysteria2_test.go +++ b/share/hysteria2_test.go @@ -1,6 +1,7 @@ package share import ( + "context" "encoding/base64" "encoding/json" "net" @@ -12,6 +13,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + xnet "github.com/xtls/xray-core/common/net" "github.com/xtls/xray-core/infra/conf" "github.com/xtls/xray-core/transport/internet/finalmask" ) @@ -155,6 +157,10 @@ func TestHysteria2ExportRejectsUnrepresentableSecurityAndMasks(t *testing.T) { raw := json.RawMessage(`{"mode":"intervalRemote","interval":30,"remotePorts":"443,444"}`) s.FinalMask = &conf.FinalMask{Udp: []conf.Mask{{Type: "udphop", Settings: &raw}}} }, + func(s *conf.StreamConfig) { + raw := json.RawMessage(`{"mode":"intervalLocal,intervalRemote","interval":30,"remotePorts":"443,444","sockopt":{}}`) + s.FinalMask = &conf.FinalMask{Udp: []conf.Mask{{Type: "udphop", Settings: &raw}}} + }, } { config, err := convertShareLinksForTest("hy2://auth@host?sni=host") require.NoError(t, err) @@ -189,19 +195,22 @@ func TestHysteria2PortHoppingReceivesAfterHop(t *testing.T) { mask := config.OutboundConfigs[0].StreamSetting.FinalMask.Udp[0] built, err := mask.Build(false) require.NoError(t, err) - raw, err := net.ListenPacket("udp", "127.0.0.1:0") - require.NoError(t, err) - t.Cleanup(func() { _ = raw.Close() }) - conn, err := finalmask.NewUdpmaskManager([]finalmask.Udpmask{built.(finalmask.Udpmask)}).WrapPacketConnClient(raw) + manager := finalmask.NewFinalMask(nil, []finalmask.UDPMask{built.(finalmask.UDPMask)}, nil, nil, + func(context.Context, xnet.Destination) (net.PacketConn, net.Addr, error) { + raw, err := net.ListenPacket("udp", "127.0.0.1:0") + return raw, server.LocalAddr(), err + }, nil) + dest := xnet.UDPDestination(xnet.IPAddress(net.IPv4(127, 0, 0, 1)), xnet.Port(port)) + conn, err := manager.DialUDP(context.Background(), dest) require.NoError(t, err) t.Cleanup(func() { _ = conn.Close() }) exchange := func() string { t.Helper() require.NoError(t, conn.SetDeadline(time.Now().Add(2*time.Second))) - _, err := conn.WriteTo([]byte("echo"), server.LocalAddr()) + _, err := conn.Write([]byte("echo")) require.NoError(t, err) buf := make([]byte, 128) - n, _, err := conn.ReadFrom(buf) + n, err := conn.Read(buf) require.NoError(t, err) assert.True(t, strings.HasSuffix(string(buf[:n]), ":echo")) return string(buf[:n]) From 3c694b23290f9849fe52284a345ebd4343bc90cd Mon Sep 17 00:00:00 2001 From: yiguodev <147401898+yiguodev@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:26:40 +0800 Subject: [PATCH 26/38] ci: refresh build toolchains and pin Ubuntu 24.04 (#161) * ci: refresh workflow dependencies and pin Ubuntu 24.04 * ci: track action dependencies by major version --- .github/workflows/build.yml | 14 +++++++++----- .github/workflows/libxray-test.yml | 2 +- .github/workflows/release-go-mirror.yml | 2 +- .github/workflows/validate.yml | 6 ++++++ 4 files changed, 17 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b59ce4f3..33b69bd5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -11,7 +11,7 @@ concurrency: cancel-in-progress: false env: - NDK_VERSION: r29 + NDK_VERSION: r30 jobs: build: @@ -154,16 +154,18 @@ jobs: # ========================= - name: Setup Java (Android) if: matrix.target == 'android' - uses: actions/setup-java@v5 + uses: actions/setup-java@v6 with: distribution: temurin - java-version: "21" + java-version: latest + check-latest: true - name: Setup Android SDK if: matrix.target == 'android' - uses: android-actions/setup-android@v3 + uses: android-actions/setup-android@v4 - name: Setup Android NDK + id: ndk if: matrix.target == 'android' uses: nttld/setup-ndk@v1 with: @@ -181,6 +183,8 @@ jobs: - name: Build Android if: matrix.target == 'android' + env: + ANDROID_NDK_HOME: ${{ steps.ndk.outputs.ndk-path }} run: | python3 build/main.py android @@ -277,7 +281,7 @@ jobs: contents: write steps: - name: Download all artifacts - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v8 with: pattern: libxray-* path: dist/ diff --git a/.github/workflows/libxray-test.yml b/.github/workflows/libxray-test.yml index 32dcc8a8..7a7eb1b8 100644 --- a/.github/workflows/libxray-test.yml +++ b/.github/workflows/libxray-test.yml @@ -6,7 +6,7 @@ on: jobs: test: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 permissions: contents: read steps: diff --git a/.github/workflows/release-go-mirror.yml b/.github/workflows/release-go-mirror.yml index a24f2ed5..6d4223d3 100644 --- a/.github/workflows/release-go-mirror.yml +++ b/.github/workflows/release-go-mirror.yml @@ -25,7 +25,7 @@ on: jobs: mirror: name: Mirror CalVer tag to v1.YYMMDD.N - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 permissions: contents: write steps: diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index df716d43..c43c4933 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -23,6 +23,12 @@ jobs: go-version: stable check-latest: true + - name: Setup Python + uses: actions/setup-python@v7 + with: + python-version: "3.x" + check-latest: true + - name: Download modules run: go mod download From e02cdaaed9993966b328aba6b36e87bb47fdf324 Mon Sep 17 00:00:00 2001 From: Arkadii Date: Tue, 6 Oct 2026 06:32:18 +0400 Subject: [PATCH 27/38] build: reduce Android artifact size (#162) strip symbol and DWARF tables from Android artifacts --- build/app/android.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/build/app/android.py b/build/app/android.py index 4d62b7f7..7be74dca 100644 --- a/build/app/android.py +++ b/build/app/android.py @@ -28,7 +28,9 @@ def build(self): "android", "-androidapi", "21", - "-ldflags=-checklinkname=0 -extldflags=-Wl,-z,max-page-size=16384", + "-trimpath", + "-ldflags=-checklinkname=0 -s -w -buildid= " + "-extldflags=-Wl,-z,max-page-size=16384", ] ) if ret.returncode != 0: From 34eaa84897536a6433a189b99f4989a6da13c1c9 Mon Sep 17 00:00:00 2001 From: yiguo Date: Wed, 7 Oct 2026 20:03:55 +0800 Subject: [PATCH 28/38] docs: update OneXray issue link after organization rename --- docs/hysteria2-share-restoration-research.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/hysteria2-share-restoration-research.md b/docs/hysteria2-share-restoration-research.md index ad750a14..77423f99 100644 --- a/docs/hysteria2-share-restoration-research.md +++ b/docs/hysteria2-share-restoration-research.md @@ -318,7 +318,7 @@ These checks confirm the current sharing baseline, native Hysteria JSON build coverage, and intentional `unsupported share format` results for Hysteria2 URI input. They are not tests of a restored implementation or real VPN traffic. -[issue-201]: https://github.com/OneXray/OneXray/issues/201 +[issue-201]: https://github.com/YuanDevTeam/OneXray/issues/201 [core-update]: https://github.com/XTLS/libXray/commit/36351d7530b2233f3c8d404e6f58cffda0cbc8e5 [removal]: https://github.com/XTLS/libXray/commit/613752d75f41629e8a43d043cf2e4c14592690fd [main-release]: https://github.com/XTLS/libXray/commit/50b95979f5db551bd273165cf469e5daaf791341 From 671b85ce495cb0539261d0099d89a027b8cad787 Mon Sep 17 00:00:00 2001 From: yiguo Date: Wed, 7 Oct 2026 20:11:05 +0800 Subject: [PATCH 29/38] docs: remove obsolete Hysteria2 restoration research --- docs/hysteria2-share-restoration-research.md | 348 ------------------- 1 file changed, 348 deletions(-) delete mode 100644 docs/hysteria2-share-restoration-research.md diff --git a/docs/hysteria2-share-restoration-research.md b/docs/hysteria2-share-restoration-research.md deleted file mode 100644 index 77423f99..00000000 --- a/docs/hysteria2-share-restoration-research.md +++ /dev/null @@ -1,348 +0,0 @@ -# Hysteria2 share restoration research - -Research date: 2026-09-26. This note distinguishes restoring the former share -format from implementing every feature in the latest Hysteria specification. - -## Implementation status — 2026-09-26 - -Implemented on `feat/restore-hysteria2-share`; the App changes remain on its -existing `dev-26.9.5-2` branch. The sections below retain the pre-implementation -research. The current supported fields and security boundaries are specified -in [parse_share](../README.md#parse_share), not by the historical implementation. - -- Both URI aliases import; export uses `hysteria2://`. IPv6, userinfo escaping, - Salamander, mixed/Base64/Age inputs and both local/remote hopping are covered. - Official `pinSHA256` and verification bypass are rejected, not reinterpreted. -- `go test ./... -count=1` and the Hysteria2 race tests passed, including a real - UDP reply after the five-second socket hop. Invoke API remains version 3. -- Standard Android and Apple Go builds succeeded and their artifacts were - copied to the App. Module files were unchanged after the builds. No adjacent - Xray-core, VCore, database schema or Pigeon API was changed. -- The App propagates Windows/Linux interface policy to the hopping mask's - independent socket options. Its 48 affected Flutter tests and analyze passed. -- Android emulator validation used a separate application ID and the official - Hysteria v2.12.3 server. Import, automatic queued probes, database preservation, - URI export/re-import, native VPN start/switch/stop and actual HTTP downloads - passed for plain, Salamander and hopping nodes. Six approximately 1 MiB - transfers succeeded across 30 seconds of hopping; both destination ports and - changed local ports were observed. The share UI displayed the generated URI. -- The controlled certificate used the explicit Xray `pcs` extension. Public-PKI - interoperability, the reporter's provider, and physical iOS devices were not - tested. The server deliberately blocked non-test destinations, so location - lookup and background Geodata download failures in this isolated run were - expected; they did not invalidate measured delay or interrupt the VPN. -- App copy and three website languages were updated. Hugo, documentation/SEO - checks and all 13 SEO regression tests passed. This is not deployment evidence. - -Local harnesses, server checksums, screenshots and the detailed results are -under workspace `references/hysteria2-share-restoration/`. Test VPN, isolated -package, ADB forwarding and local servers were stopped/removed after validation; -the developer's existing App database was not modified. No commit or push was -requested or performed during implementation. - -## Recommendation and estimated cost - -Restore Hysteria2 URI import and export as a focused share-conversion change, -not a protocol implementation or an App architecture rewrite. Keep Clash/Mihomo -and legacy VMessQrCode unsupported. Keep the existing core dependency and Invoke -API version/response shapes unchanged. - -The ordinary valid-certificate path is low-cost. Correct URI edge cases, -security-policy boundaries, and live port-hopping checks make the complete -restoration low-to-medium cost. A planning estimate is **one to two focused -development days** for the scoped converter, regression coverage, App artifacts, -copy updates, and an available Android test endpoint. This is an engineering -estimate, not measured implementation time; it excludes a core TLS rewrite, -new protocol variants, release review, unavailable device checks, and platform -issues discovered by live testing. - -| Area | Expected work | Cost / boundary | -| --- | --- | --- | -| libXray | Restore URI dispatch, protocol-specific parsing/export, and mask conversion; fix URL edge cases and security rejection behavior | Main implementation work; a few hundred lines rather than a new protocol stack | -| Regression coverage | Replace Hysteria2 rejection fixtures; cover both directions, Base64/Age, mixed input, and retained unsupported formats | Existing tests/helpers are reusable | -| App | Refresh native libraries; update five subscription-description translations and their tests; add Hysteria outbound import/share coverage | No database, Pigeon, or routing-model redesign indicated by the ordinary path | -| Public documentation | Update libXray EN/ZH support statements, App sharing contract, and website support descriptions | Small, but must reflect actual TLS/URI limitations | -| Device acceptance | Android import, queued probe, VPN traffic, export/re-import, and hopping; Apple build and eventual iOS device check | Parsing/build success alone is not acceptance | - -## Issue and deletion history - -[OneXray issue #201][issue-201] reports an App Store installation of OneXray -26.9.4 with Xray-core 26.9.9 on iOS, using Custom Routing. It contains no example -link, configuration, or log. The removed URI support matches the reported -regression, but the report does not establish whether the user's exact failure -is import, sharing, or runtime connectivity. Do not claim it has been reproduced -or fixed on iOS from this research. - -The relevant libXray history is: - -1. [`36351d7`][core-update] (2026-09-09) updated the core dependency to the exact - revision still used today and migrated Hysteria2 port hopping to UDP masks. -2. [`613752d`][removal] (2026-09-09), `refactor(share): remove Clash and Hysteria2 - URI support`, removed `hy2`/`hysteria2` recognition, the outbound parser, - share generation, TLS-default dispatch, and `share/hysteria_mask.go`. -3. The change entered main in [`50b9597`][main-release], carrying libXray tags - `v26.9.9` and `v1.260909.0`. These are library tags, not an App release number. - -Use the Hysteria2 portions of `613752d^` as a reference, not a wholesale revert. -The deletion also removed Clash/Mihomo, and later sharing work removed legacy -VMessQrCode and improved other protocols. Preserve those changes. The former -Hysteria parser/export/helper amount to roughly 200-250 implementation lines, -excluding tests; an updated restoration need not reproduce the old file layout. - -Current `share/marshal_share.go` still projects native Hysteria outbounds, -`hysteriaSettings`, TLS fields, and FinalMask. The native JSON regression in -`share/marshal_share_test.go` still verifies Hysteria2 port-hopping JSON can -survive projection and build. Only the URI path was intentionally removed. -See the current [sharing implementation][current-share] and [API contract][current-readme]. - -## App integration scope - -OneXray was inspected at `6d2e777f4fa8c23fe9cb04dc827251148b32bbed`, branch -`dev-26.9.5-2`, in the adjacent App repository. - -- `lib/service/shared/share/xray_share_reader.dart` delegates standard parsing - to `AppHostApi.convertShareLinksToXrayJson` without a Dart protocol whitelist. -- `lib/service/servers/outbound/map.dart` and `state_db.dart` preserve outbound - maps and store them through the existing CoreConfig representation. -- `lib/service/connect/compiler.dart` clones selected outbound maps and applies - managed tags/socket policy; it does not reconstruct Hysteria settings. -- `lib/pages/shared/share/controller.dart` already delegates standard link - generation to `convertXrayJsonToShareLinks`. -- `prototypeSubscriptionDescription` in all five `lib/l10n/*.arb` languages, - plus `test/pages/servers/subscription/form_test.dart`, currently says only - VMessAEAD/VLESS links are supported. Correct that capability statement when - support is delivered, without adding a new import UI. - -Thus ordinary links should flow through clipboard/file/QR/subscription import, -storage, existing queued probes, selection, and sharing after the native library -is updated. This is a source-derived integration assessment, not device proof. -Port hopping additionally needs the socket-policy review described below. - -## Source boundaries - -- libXray inspected at `55cb29e214c5b77a1ba485da639a9e6b9b98adca`. -- The dependency in `go.mod` is - `github.com/xtls/xray-core v1.260327.1-0.20260908222543-52a412d9e2f5`. - Its cached module metadata resolves to - `52a412d9e2f5c2a5142b1b4e2ab3771dacb8b120`. Source inspection used - `/Users/yiguo/go/pkg/mod/github.com/xtls/xray-core@v1.260327.1-0.20260908222543-52a412d9e2f5/`, - not a sibling checkout. -- Official Hysteria `master` resolved to - `e1366b173ccf5706e1e4630fe8aa654a4b574085` (2026-09-13). Its former - `apernet/hysteria` URLs redirect to `HyNetworks/hysteria`. - Online documentation was checked on the research date and may evolve. - -## URI contract and core mapping - -The [official URI specification][uri] defines `hysteria2` and `hy2`, optional -userinfo, default port 443, port lists/ranges, TLS parameters, and an optional -display-name fragment. Bandwidth and client listening modes are deliberately -excluded. `mport` and hopping-interval query parameters are not defined there; -supporting them would be a separately documented compatibility extension. - -| Input | Mapping to the pinned core | Constraints | -| --- | --- | --- | -| `hysteria2://` / `hy2://` | Outbound `protocol: "hysteria"`; `settings.version: 2`; stream `network: "hysteria"`, `security: "tls"`, `hysteriaSettings.version: 2` | The core also accepts `method` as the stream selector and gives it precedence over `network`. Both version fields are required. [Core config][core-hysteria], [stream config][core-stream], [transport config][core-method]. | -| `auth@` / `username:password@` | `streamSettings.hysteriaSettings.auth` | Preserve decoded credentials, including a colon when password is present. Auth does not belong in outbound `settings`. [Official parser/generator][hy-client], [core transport][core-method]. | -| Host and single port | `settings.address`, `settings.port` | Apply 443 when omitted. URI IPv6 literals require brackets; the address value should contain the literal without authority brackets. [URI][uri], [RFC 3986 section 3.2.2][rfc-host]. | -| `sni` | `streamSettings.tlsSettings.serverName` | If absent, the Hysteria dialer uses the destination address through `tls.WithDestination`. [Dialer][core-dialer], [TLS runtime][core-tls]. | -| `obfs=salamander&obfs-password=...` | `streamSettings.finalmask.udp` entry with `type: "salamander"`, `settings.password` | No `packetSize` for ordinary Salamander. Password must be at least four bytes at connection wrapping time; a JSON build alone does not exercise that check. [Mask schema][core-mask], [Salamander][core-salamander]. | -| Multi-port authority, e.g. `host:443,5000-5002` | One base `settings.port` plus an outermost `udphop` UDP mask containing the entire port set | Details below. [Port hopping][hopping], [mask schema][core-mask]. | -| `#name` | Display metadata only | Decode/encode as a URI fragment, without changing connection fields. [URI][uri]. | - -The official implementation obtains decoded values through `User.Username()` -and `User.Password()`. Decode userinfo exactly once: a literal `+` in userinfo -is not a space, and `%25` must not be decoded again. Generate userinfo with URI -userinfo escaping; query escaping has different `+` behavior. Query values such -as an obfuscation password containing `+` need `%2B`. [Official URL parser][hy-url]. - -Go 1.27.1's standard `net/url` rejects commas and ranges in the authority port. -Hysteria's URL fork explicitly admits those separators. A restoration therefore -needs a deliberate multi-port authority path while retaining normal escaping -and IPv6 handling. Validate malformed escapes, empty hosts, invalid port bounds, -reversed/empty ranges, and IPv6 with an omitted port. [Official URL fork][hy-url], -local `/opt/homebrew/Cellar/go/1.27.1/libexec/src/net/url/url.go:762`. - -## TLS compatibility requires an explicit policy - -Official Hysteria sets `InsecureSkipVerify` independently and compares -`pinSHA256` against the leaf certificate's DER SHA-256. Consequently, with -`insecure=0` or omission, normal trust/hostname validation runs before the pin -callback. Its parser accepts `strconv.ParseBool` spellings in addition to the -documented `1`/`0`. [Official TLS and URI code][hy-client], -[official TLS connection construction][hy-core-client]. - -| URI TLS state | Pinned-core behavior / restoration implication | -| --- | --- | -| No pin, `insecure` absent or `0` | Normal TLS verification; direct mapping. | -| No pin, `insecure=1` | Unsupported: `TLSConfig.Build` rejects `allowInsecure: true`. Do not silently drop the flag or claim the original connection semantics are supported. | -| Pin with `insecure=1` | `tlsSettings.pinnedPeerCertSha256` provides the intended check when the supplied fingerprint identifies the leaf; never emit `allowInsecure: true`. Xray can also accept a matching CA, which official Hysteria's leaf-only pin would reject. | -| Pin with `insecure` absent or `0` | Not equivalent: Xray's leaf pin bypasses ordinary trust/hostname validation. Adding `verifyPeerCertByName` does not restore it because a matching leaf returns success first. Reject or explicitly resolve this contract before claiming faithful import. | - -The pinned-core field is a **hex string**, accepting colon-separated OpenSSL -hex and comma-separated pins, with each decoded hash exactly 32 bytes. The -official URI expresses a single certificate fingerprint. Do not base64-encode -it or substitute a digest of concatenated chain bytes. Export cannot faithfully turn arbitrary multiple pins, -CA pins, or other TLS policy into one official `pinSHA256` parameter. -[Core TLS builder, lines 361-378][core-security], -[core TLS verification, lines 284-405 and 561 onward][core-tls]. - -The current [Project X TLS documentation][xray-tls-doc] still describes -`allowInsecure` as deprecated; the pinned source rejects it. The dependency -source is authoritative for this restoration. - -## Current UDP mask and hopping shape - -For ordinary Salamander plus hopping, the source-supported shape is: - -```json -{ - "finalmask": { - "udp": [ - { "type": "salamander", "settings": { "password": "example-password" } }, - { - "type": "udphop", - "settings": { - "mode": "intervalLocal,intervalRemote", - "interval": 30, - "remotePorts": "443,5000-5002" - } - } - ] - } -} -``` - -This object belongs under `streamSettings`. Omit the Salamander entry when -obfuscation is absent. A concrete base port is still required by the outbound; -using the first supplied port is a reasonable converter convention, while the -hopping mask chooses an initial remote port randomly. - -`remotePorts` uses `conf.PortList`; `interval` uses `conf.Int32Range`, in seconds. -Set 30 explicitly to match Hysteria's default: Xray's runtime rejects either -interval bound below 5 and supplies no default. The mask manager reverses the -configured list before wrapping; `udphop` must be the last configured entry -because its wrapper requires level zero. These are the current `finalmask.udp` -fields, not transport-level `obfs`/`hopPorts` fields or a `udpmasks` JSON field. -[Schema][core-mask], [range types][core-common], [mask ordering][core-mask-runtime], -[hopping wrapper][core-hop-config], [hopping runtime][core-hop-runtime]. - -The official client changes the local UDP socket as well as the remote port. -Both interval modes above match that source behavior. In the pinned Xray code, -the receive goroutine is only started in the local-hop branch; using only -`intervalRemote` appears unable to receive replies. This is a source-derived -concern, not a reproduced connection failure. The wrapper also rejects -`FakePacketConn`, so upstream proxy chaining is not established by this mapping. -[Official hopping runtime][hy-hop], [Xray hopping runtime][core-hop-runtime], -[wrapper restriction][core-hop-config]. - -Local hopping opens replacement sockets with the mask's own `settings.sockopt`. -The App currently places its Windows/Linux interface selection in the parent -`streamSettings.sockopt`; the core builds the mask and parent socket settings -separately. Before claiming hopping works on every platform, check that the -managed interface/protection policy reaches replacement sockets as well. -This may require a small runtime-configuration adjustment, not a database or -native API redesign. Windows/Linux live verification remains outside the -current macOS-host validation boundary. [Stream builder][core-stream], -[hopping runtime][core-hop-runtime], App `compiler.dart:570`. - -## Restoration scope and validation boundary - -The latest official docs also include Gecko and separate realm schemes. -The pinned core has related mechanisms (`salamander.settings.packetSize` and a -`realm` mask), but their presence does not make them part of restoring the -former ordinary Hysteria2 links. Treat them as separate work. ECH is different: -the former and current TLS helper already maps `ech` to `echConfigList`, so -reuse and test that mapping rather than introducing a new mechanism. Unsupported -connection/security parameters must not be silently discarded. -[Latest URI specification][uri], [mask schema][core-mask], -[TLS builder][core-security], [pre-removal TLS helper][old-stream]. - -For export, preserve only configurations representable by the selected URI -contract; do not flatten extra UDP masks, remote-IP hopping, or distinct TLS -policies into a link that appears equivalent. Parser round trips and successful -core config construction are necessary checks but do not prove connectivity. - -The former helper supported nonstandard `up`, `down`, `ports`, and -`hop-interval` query fields. Treat those as a compatibility decision, not as -official URI requirements. Recommended export follows the official URI contract -and does not emit client-specific bandwidth values. Keep complete unrepresentable -configurations in native Xray JSON instead of claiming a lossless standard link. - -## Suggested implementation and acceptance order - -1. Implement a protocol-local parser/exporter, reusing existing sharing helpers. - Add both schemes to dispatch and export canonical `hysteria2://` links. - Cover auth escaping, default 443, IPv4/IPv6, SNI, name-to-`tag`, Salamander, - and standard multi-port authority handling. Avoid vendoring a whole URL fork - only to parse port lists. Keep the current per-item filtering contract. -2. Resolve the TLS table above explicitly. Start with normal certificate - verification; reject any security mode that cannot preserve the requested - meaning. Do not silently turn insecure or pinned-only input into another - TLS policy. The old helper itself never read `insecure` or `pinSHA256`, so - restoring its code alone would not provide full official-format support. -3. Update conversion, generation, projection, Invoke, and Age tests. Include - mixed subscriptions, empty/invalid parameters, export/re-import, and an - assertion that Clash/Mihomo and VMessQrCode remain unsupported. Exercise - the actual constructed mask with packet replies and a hop; the old tests - covered construction/wrapping, not successful hopping traffic. -4. Build with the documented `build/main.py android` and `build/main.py apple go` - paths, without `local` core replacement; copy verified artifacts to the App. - Update App import/share regressions and capability copy. Preserve API version - 3 and `data.outbounds`. -5. Use an authorized known-good Hysteria2 endpoint or a controlled test server - for Android end-to-end acceptance. Verify plain TLS, Salamander, TCP and UDP, - and sustained traffic across at least one hop. Keep evidence under workspace - `references/`. Android success does not close the iOS validation gap in #201; - follow with an iOS device check. Do not start a macOS VPN or claim unperformed - Windows/Linux validation. - -The current Android emulator is available for the later implementation phase. -No implementation, dependency, branch, device, or GitHub state was changed in -this research. Only this research note was added; the proposed conversion has -not been exercised against an official Hysteria server. - -## Baseline checks performed - -On 2026-09-26, from the current libXray checkout: - -```text -go test ./share -count=1 -PASS - -go test . -run '^TestInvokeConvertShareLinksRejectsRemovedFormats$' -count=1 -v -PASS (Clash, Hysteria2, Hy2) -``` - -These checks confirm the current sharing baseline, native Hysteria JSON build -coverage, and intentional `unsupported share format` results for Hysteria2 URI -input. They are not tests of a restored implementation or real VPN traffic. - -[issue-201]: https://github.com/YuanDevTeam/OneXray/issues/201 -[core-update]: https://github.com/XTLS/libXray/commit/36351d7530b2233f3c8d404e6f58cffda0cbc8e5 -[removal]: https://github.com/XTLS/libXray/commit/613752d75f41629e8a43d043cf2e4c14592690fd -[main-release]: https://github.com/XTLS/libXray/commit/50b95979f5db551bd273165cf469e5daaf791341 -[current-share]: https://github.com/XTLS/libXray/tree/55cb29e214c5b77a1ba485da639a9e6b9b98adca/share -[current-readme]: https://github.com/XTLS/libXray/blob/55cb29e214c5b77a1ba485da639a9e6b9b98adca/README.md#share -[old-stream]: https://github.com/XTLS/libXray/blob/36351d7530b2233f3c8d404e6f58cffda0cbc8e5/share/stream.go - -[uri]: https://v2.hysteria.network/docs/developers/URI-Scheme/ -[hopping]: https://v2.hysteria.network/docs/advanced/Port-Hopping/ -[rfc-host]: https://www.rfc-editor.org/rfc/rfc3986.html#section-3.2.2 -[xray-tls-doc]: https://xtls.github.io/en/config/transports/tls.html -[hy-client]: https://github.com/HyNetworks/hysteria/blob/e1366b173ccf5706e1e4630fe8aa654a4b574085/app/cmd/client.go -[hy-core-client]: https://github.com/HyNetworks/hysteria/blob/e1366b173ccf5706e1e4630fe8aa654a4b574085/core/client/client.go#L74 -[hy-url]: https://github.com/HyNetworks/hysteria/blob/e1366b173ccf5706e1e4630fe8aa654a4b574085/app/internal/url/url.go -[hy-hop]: https://github.com/HyNetworks/hysteria/blob/e1366b173ccf5706e1e4630fe8aa654a4b574085/extras/transport/udphop/conn.go -[core-hysteria]: https://github.com/XTLS/Xray-core/blob/52a412d9e2f5c2a5142b1b4e2ab3771dacb8b120/infra/conf/hysteria.go#L13 -[core-stream]: https://github.com/XTLS/Xray-core/blob/52a412d9e2f5c2a5142b1b4e2ab3771dacb8b120/infra/conf/transport_internet.go#L44 -[core-method]: https://github.com/XTLS/Xray-core/blob/52a412d9e2f5c2a5142b1b4e2ab3771dacb8b120/infra/conf/transport_method.go#L752 -[core-dialer]: https://github.com/XTLS/Xray-core/blob/52a412d9e2f5c2a5142b1b4e2ab3771dacb8b120/transport/internet/hysteria/dialer.go#L293 -[core-security]: https://github.com/XTLS/Xray-core/blob/52a412d9e2f5c2a5142b1b4e2ab3771dacb8b120/infra/conf/transport_security.go#L299 -[core-tls]: https://github.com/XTLS/Xray-core/blob/52a412d9e2f5c2a5142b1b4e2ab3771dacb8b120/transport/internet/tls/config.go -[core-mask]: https://github.com/XTLS/Xray-core/blob/52a412d9e2f5c2a5142b1b4e2ab3771dacb8b120/infra/conf/transport_finalmask.go -[core-common]: https://github.com/XTLS/Xray-core/blob/52a412d9e2f5c2a5142b1b4e2ab3771dacb8b120/infra/conf/common.go#L215 -[core-salamander]: https://github.com/XTLS/Xray-core/blob/52a412d9e2f5c2a5142b1b4e2ab3771dacb8b120/transport/internet/finalmask/salamander/salamander.go#L30 -[core-mask-runtime]: https://github.com/XTLS/Xray-core/blob/52a412d9e2f5c2a5142b1b4e2ab3771dacb8b120/transport/internet/finalmask/finalmask.go#L21 -[core-hop-config]: https://github.com/XTLS/Xray-core/blob/52a412d9e2f5c2a5142b1b4e2ab3771dacb8b120/transport/internet/finalmask/udphop/config.go#L10 -[core-hop-runtime]: https://github.com/XTLS/Xray-core/blob/52a412d9e2f5c2a5142b1b4e2ab3771dacb8b120/transport/internet/finalmask/udphop/conn.go From f70baa470d681976f436fe8814852c0dd51dd998 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Sat, 10 Oct 2026 15:57:19 +0800 Subject: [PATCH 30/38] chore: ignore local environment files (#163) --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 872c7051..c38262db 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.env # Binaries for programs and plugins *.exe *.exe~ From c0cc8d4a86d32e71e2b4e9e6377723597a069a3d Mon Sep 17 00:00:00 2001 From: yiguodev <147401898+yiguodev@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:23:38 +0800 Subject: [PATCH 31/38] chore: update Xray-core to v26.10.10 and refresh dependencies (#164) --- README.md | 2 +- build/app/build.py | 4 +- go.mod | 53 ++++++++--------- go.sum | 125 +++++++++++++++++++++-------------------- readme/README.zh_CN.md | 2 +- 5 files changed, 95 insertions(+), 91 deletions(-) diff --git a/README.md b/README.md index 0ad9161a..40f0ed3f 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ Compile script. It is recommended to always use this script to compile libXray. depends on git and go. -By default, the build script does not clone [Xray-core](https://github.com/XTLS/Xray-core). It uses Go modules and pins Xray-core to release tag `v26.9.30` through its pseudo-version. +By default, the build script does not clone [Xray-core](https://github.com/XTLS/Xray-core). It uses Go modules and pins Xray-core to release tag `v26.10.10` through its pseudo-version. Pass the optional `local` argument to use an existing local checkout at `../Xray-core` through a Go module `replace`. ### Usage diff --git a/build/app/build.py b/build/app/build.py index 74bc5712..d89d8707 100644 --- a/build/app/build.py +++ b/build/app/build.py @@ -9,8 +9,8 @@ LIBXRAY_MOD_NAME = "github.com/xtls/libxray" XRAY_CORE_MOD_NAME = "github.com/xtls/xray-core" -# Go modules resolve the Xray-core v26.9.30 release tag through this version. -DEFAULT_XRAY_CORE_VERSION = "v1.260327.1-0.20260930074004-b26a91de4f32" +# Go modules resolve the Xray-core v26.10.10 release tag through this version. +DEFAULT_XRAY_CORE_VERSION = "v1.260327.1-0.20261010092107-701af60772cd" LOCAL_XRAY_CORE_DIR_NAME = "Xray-core" diff --git a/go.mod b/go.mod index 4b9f2687..331bd873 100644 --- a/go.mod +++ b/go.mod @@ -5,39 +5,39 @@ go 1.27.1 require ( github.com/metacubex/age v0.0.0-20260603010618-28d156b4ea78 github.com/stretchr/testify v1.12.1 - github.com/xtls/xray-core v1.260327.1-0.20260930074004-b26a91de4f32 + github.com/xtls/xray-core v1.260327.1-0.20261010092107-701af60772cd golang.org/x/mobile v0.0.0-20260908204917-8b95e45f8d3e - golang.org/x/sys v0.48.0 + golang.org/x/sys v0.49.0 google.golang.org/protobuf v1.36.12 ) require ( - github.com/andybalholm/brotli v1.0.6 // indirect + github.com/andybalholm/brotli v1.2.6 // indirect github.com/apernet/quic-go v0.61.1-0.20260806010916-184d081eef3e // indirect github.com/cloudflare/circl v1.6.5 // indirect github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344 // indirect - github.com/google/btree v1.1.2 // indirect - github.com/google/gopacket v1.1.19 // indirect + github.com/google/btree v1.1.3 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect - github.com/huin/goupnp v1.2.0 // indirect - github.com/jackpal/go-nat-pmp v1.0.2 // indirect + github.com/huin/goupnp v1.3.0 // indirect + github.com/jackpal/go-nat-pmp v1.1.0 // indirect github.com/juju/ratelimit v1.0.2 // indirect - github.com/klauspost/compress v1.17.4 // indirect + github.com/klauspost/compress v1.20.1 // indirect github.com/klauspost/cpuid/v2 v2.4.0 // indirect - github.com/koron/go-ssdp v0.0.4 // indirect + github.com/koron/go-ssdp v0.9.1 // indirect github.com/kr/text v0.2.0 // indirect github.com/libp2p/go-nat v1.0.1-0.20250821073202-01afc089f138 // indirect - github.com/libp2p/go-netroute v0.2.1 // indirect + github.com/libp2p/go-netroute v0.4.0 // indirect github.com/metacubex/hkdf v0.1.0 // indirect github.com/metacubex/hpke v0.1.0 // indirect github.com/metacubex/mlkem v0.1.0 // indirect github.com/miekg/dns v1.1.73 // indirect github.com/pelletier/go-toml v1.9.5 // indirect - github.com/pion/dtls/v3 v3.1.5 // indirect + github.com/pion/dtls/v3 v3.1.10 // indirect github.com/pion/logging v0.2.4 // indirect github.com/pion/stun/v3 v3.1.7 // indirect - github.com/pion/transport/v4 v4.1.0 // indirect + github.com/pion/transport/v4 v4.1.1 // indirect + github.com/pion/transport/v5 v5.1.1 // indirect github.com/pires/go-proxyproto v0.15.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af // indirect @@ -45,23 +45,26 @@ require ( github.com/vishvananda/netlink v1.3.1 // indirect github.com/vishvananda/netns v0.0.5 // indirect github.com/wlynxg/anet v0.0.5 // indirect - github.com/xtls/reality v0.0.0-20260908062103-8cdf7bf9c7f0 // indirect + github.com/xtls/reality v0.0.0-20260921001439-3c98159dee38 // indirect + github.com/yuin/gopher-lua v1.1.2 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect - go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect - golang.org/x/crypto v0.57.0 // indirect - golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect - golang.org/x/mod v0.41.0 // indirect - golang.org/x/net v0.59.0 // indirect - golang.org/x/sync v0.23.0 // indirect - golang.org/x/text v0.42.0 // indirect - golang.org/x/time v0.14.0 // indirect - golang.org/x/tools v0.50.0 // indirect + go4.org/netipx v0.0.0-20260823151212-3075585bcbeb // indirect + golang.org/x/crypto v0.58.0 // indirect + golang.org/x/crypto/x509roots/fallback v0.0.0-20261009200856-99e4382b128e // indirect + golang.org/x/exp v0.0.0-20261009195045-ca0d7ba23607 // indirect + golang.org/x/mod v0.42.0 // indirect + golang.org/x/net v0.61.0 // indirect + golang.org/x/sync v0.24.0 // indirect + golang.org/x/text v0.43.0 // indirect + golang.org/x/time v0.16.0 // indirect + golang.org/x/tools v0.51.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect - golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect + golang.zx2c4.com/wireguard v0.0.0-20261006164505-2631ce99a06f // indirect golang.zx2c4.com/wireguard/windows v1.1.1 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20261005182115-fad411399dd8 // indirect google.golang.org/grpc v1.84.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect - gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect + gvisor.dev/gvisor v0.0.0-20261010064756-cd110d9dec04 // indirect + layeh.com/gopher-luar v1.0.11 // indirect lukechampine.com/blake3 v1.4.1 // indirect ) diff --git a/go.sum b/go.sum index 07a65f36..c1e9cc21 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,10 @@ -github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI= -github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/andybalholm/brotli v1.2.6 h1:ftYnfj6usCp+UGV5kSJ3+chpMQgU+gJf/AxsUQ52REI= +github.com/andybalholm/brotli v1.2.6/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/apernet/quic-go v0.61.1-0.20260806010916-184d081eef3e h1:5mgtR5gwIgBKMiGI1QdXldZZ+SNor06Nbu1wCBulQBg= github.com/apernet/quic-go v0.61.1-0.20260806010916-184d081eef3e/go.mod h1:x7qxEvX6MCVtDuBKHj3E+88+BtrbEMuAL5qGUKItjW8= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/cloudflare/circl v1.6.5 h1:O64F26HEqNhznd/hrC5KZXVKYuKM2rx4deZDTc4ihQA= github.com/cloudflare/circl v1.6.5/go.mod h1:h5LNyxAc5nTue9DS5jT+48en2PSDYt3zdGnz5OstK6c= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= @@ -11,36 +14,34 @@ github.com/golang/mock v1.7.0-rc.1 h1:YojYx61/OLFsiv6Rw1Z96LpldJIy31o+UHmwAUMJ6/ github.com/golang/mock v1.7.0-rc.1/go.mod h1:s42URUywIqd+OcERslBJvOjepvNymP31m3q8d/GkuRs= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU= -github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8= -github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/huin/goupnp v1.2.0 h1:uOKW26NG1hsSSbXIZ1IR7XP9Gjd1U8pnLaCMgntmkmY= -github.com/huin/goupnp v1.2.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8= -github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus= -github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc= +github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc= +github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8= +github.com/jackpal/go-nat-pmp v1.1.0 h1:UInMLPV1VQdP860ggNiz0YxGvJH/bWzxL099y+1EdCs= +github.com/jackpal/go-nat-pmp v1.1.0/go.mod h1:m9o4DK1wHA4h2pPpErD5vwzWLf91tJcfNQ3QyUIbh5A= github.com/juju/ratelimit v1.0.2 h1:sRxmtRiajbvrcLQT7S+JbqU0ntsb9W2yhSdNN8tWfaI= github.com/juju/ratelimit v1.0.2/go.mod h1:qapgC/Gy+xNh9UxzV13HGGl/6UXNN+ct+vwSgWNm/qk= -github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4= -github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw= github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU= -github.com/koron/go-ssdp v0.0.4 h1:1IDwrghSKYM7yLf7XCzbByg2sJ/JcNOZRXS2jczTwz0= -github.com/koron/go-ssdp v0.0.4/go.mod h1:oDXq+E5IL5q0U8uSBcoAXzTzInwy5lEgC91HoKtbmZk= +github.com/koron/go-ssdp v0.9.1 h1:zvxbAAuJftJIZ8Jh8mda+LI7V92hYZf/sKprmOxpxwA= +github.com/koron/go-ssdp v0.9.1/go.mod h1:C43c047jWkDaeg9YuZlSh/QGqOieuWV6dbhWi/jcaLk= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/libp2p/go-nat v1.0.1-0.20250821073202-01afc089f138 h1:YohuNPT/1k3VcThCQlBZ43PCPWPfMRS1zcxWBF2SLK8= github.com/libp2p/go-nat v1.0.1-0.20250821073202-01afc089f138/go.mod h1:TXQg5tfSy+bUjnhT5728j5j/MBj7keIYqqZ1+8k/ui8= -github.com/libp2p/go-netroute v0.2.1 h1:V8kVrpD8GK0Riv15/7VN6RbUQ3URNZVosw7H2v9tksU= -github.com/libp2p/go-netroute v0.2.1/go.mod h1:hraioZr0fhBjG0ZRXJJ6Zj2IVEVNx6tDTFQfSmcq7mQ= +github.com/libp2p/go-netroute v0.4.0 h1:sZZx9hyANYUx9PZyqcgE/E1GUG3iEtTZHUEvdtXT7/Q= +github.com/libp2p/go-netroute v0.4.0/go.mod h1:Nkd5ShYgSMS5MUKy/MU2T57xFoOKvvLR92Lic48LEyA= github.com/metacubex/age v0.0.0-20260603010618-28d156b4ea78 h1:LqWr0vb9zDNuQS+jJd4fnRYk/SEI7KJ7TDe/L4WFK48= github.com/metacubex/age v0.0.0-20260603010618-28d156b4ea78/go.mod h1:BTBG/iVY7rg3qq5WdVCg0GFk58CSvCDSbjy8I7kEx/c= github.com/metacubex/hkdf v0.1.0 h1:fPA6VzXK8cU1foc/TOmGCDmSa7pZbxlnqhl3RNsthaA= @@ -53,14 +54,16 @@ github.com/miekg/dns v1.1.73 h1:uhT8nJxmTrPJYClxVxTCX+CVn6qnzSiybRk72Z6DgrE= github.com/miekg/dns v1.1.73/go.mod h1:RW2Obtfd5NZHvOFe3zYG0W8koWOQtAzyHaLo8vASBuQ= github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= -github.com/pion/dtls/v3 v3.1.5 h1:9xJtVsHwMYeSjPp5Hh1FTis4DchnQWtnOa5o+6ygqfc= -github.com/pion/dtls/v3 v3.1.5/go.mod h1:gz1K4jg6c+fq86oQMH4pilpCEOEPwmEr2jY+VcF/mkU= +github.com/pion/dtls/v3 v3.1.10 h1:HWC+QCZitP/ApADS/6+g7UIw2YmLgoK3CsynnjPJgMo= +github.com/pion/dtls/v3 v3.1.10/go.mod h1:iKFQNYrjsN2TiA2YKKMqB9MOZaFpjFULBI/A4sW0eyc= github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8= github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so= github.com/pion/stun/v3 v3.1.7 h1:uRXMTlGLf89WgItGNyZ6aR5jMTX0NBbybXADpQCzn+E= github.com/pion/stun/v3 v3.1.7/go.mod h1:Nq77RW4aRrSNrltf2ksUJLjxWeipj4lnlgdsYIxC8g8= -github.com/pion/transport/v4 v4.1.0 h1:8S+nF2reM2cJuqC6g78OVy2BBgmbdns+acx3jA97BvQ= -github.com/pion/transport/v4 v4.1.0/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM= +github.com/pion/transport/v4 v4.1.1 h1:kEBtbp1sRad0tnkREDNJ3fmop/CR8p/YxXnpFbn9tqo= +github.com/pion/transport/v4 v4.1.1/go.mod h1:Oxo5oODQvjFF7NkHBBz3f4fZXLB7x03uLVuzfYeA5pE= +github.com/pion/transport/v5 v5.1.1 h1:Sauq8YJqRopgRfIgnpfKpdpKG14g0N4jWlRgpWsEfN8= +github.com/pion/transport/v5 v5.1.1/go.mod h1:Qxw6fCEjFWQkRDZOhS4Vf+neJBcihauvA3uyEa1J1F0= github.com/pires/go-proxyproto v0.15.0 h1:dTshmNbFm/D+0+sbrxUuddPOZ5Y0B7c5NhtsBkm6LqI= github.com/pires/go-proxyproto v0.15.0/go.mod h1:OXsCrKwrK2tXS9YrI5tkHx5xaQlO8FH3lFW76orFh24= github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0= @@ -81,65 +84,61 @@ github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zd github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= -github.com/xtls/reality v0.0.0-20260908062103-8cdf7bf9c7f0 h1:rb+fKQFhz+5I2PPuQsNYxI5mUU840XWYtRF0ZBjvkws= -github.com/xtls/reality v0.0.0-20260908062103-8cdf7bf9c7f0/go.mod h1:DsJblcWDGt76+FVqBVwbwRhxyyNJsGV48gJLch0OOWI= -github.com/xtls/xray-core v1.260327.1-0.20260930074004-b26a91de4f32 h1:Bxo6+07IvdWlqxugsn2/sQrFbR7hjrxRRDMjbOInRho= -github.com/xtls/xray-core v1.260327.1-0.20260930074004-b26a91de4f32/go.mod h1:FAjlDAzHXXyki7R1BxruCHFx19B3i8TvQGEy+wsqZWU= +github.com/xtls/reality v0.0.0-20260921001439-3c98159dee38 h1:Q5KwiMm+WRyw2kwPW4+fIQxNio1CyrWo/eAEaAHxl/Q= +github.com/xtls/reality v0.0.0-20260921001439-3c98159dee38/go.mod h1:/YQ6FwmAtkDuo8K3ujKQH1Br5eGNFauBipdl1gdiebk= +github.com/xtls/xray-core v1.260327.1-0.20261010092107-701af60772cd h1:Am/9Pzw5PZr7+hMN1Vs5faeSXRoutnrwMAgtUDnh/J8= +github.com/xtls/xray-core v1.260327.1-0.20261010092107-701af60772cd/go.mod h1:YAACF9L/Pu1GznixHOBtrBqVcE95nhFjgU+gIEdWKlo= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yuin/gopher-lua v0.0.0-20190206043414-8bfc7677f583/go.mod h1:gqRgreBUhTSL0GeU64rtZ3Uq3wtjOa/TB2YfrtkCbVQ= +github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA= +github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8= go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= -go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M= -go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= -golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= -golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM= -golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= -golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +go4.org/netipx v0.0.0-20260823151212-3075585bcbeb h1:XBM4hvfwGAttkkiTIFfeigdfcL1xIfdKXqFdgiHGtDs= +go4.org/netipx v0.0.0-20260823151212-3075585bcbeb/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= +golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= +golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= +golang.org/x/crypto/x509roots/fallback v0.0.0-20261009200856-99e4382b128e h1:jsB4A6J5vF93GeMqp72mLAOcFfGXgHtxpgeLk7OJB4M= +golang.org/x/crypto/x509roots/fallback v0.0.0-20261009200856-99e4382b128e/go.mod h1:HPze8vhfG6fO06AM+VSvxRm4E3+5Yk375mgrJ5M2z1E= +golang.org/x/exp v0.0.0-20261009195045-ca0d7ba23607 h1:o259es6R0mYtmCeID0xv+dkp5kQSf6qp8zs0j4MdUaI= +golang.org/x/exp v0.0.0-20261009195045-ca0d7ba23607/go.mod h1:SRjTzy5WcYqRRqquLUvHeeovSwLO0CZ3xcltZNO4Fck= golang.org/x/mobile v0.0.0-20260908204917-8b95e45f8d3e h1:zYnHsDmxfxKH/x9DjYNAdoIdMsP8enB1Ncb10b3pdHk= golang.org/x/mobile v0.0.0-20260908204917-8b95e45f8d3e/go.mod h1:+g6JKqLjAprgkdZKuuBhkhIN9zvSktraeSABDN2M7p8= -golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= -golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= -golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg= +golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs= +golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw= +golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= -golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= +golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.0.0-20190204203706-41f3e6584952/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= -golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= -golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= -golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= -golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= -golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU= -golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0= +golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= +golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= +golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= +golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE= +golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s= +golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI= +golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA= golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= -golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A= -golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw= +golang.zx2c4.com/wireguard v0.0.0-20261006164505-2631ce99a06f h1:gJI6OVshWW0mvGyTVmW8mJoaw2OM69wVCeZkwpX+Jdc= +golang.zx2c4.com/wireguard v0.0.0-20261006164505-2631ce99a06f/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw= golang.zx2c4.com/wireguard/windows v1.1.1 h1:8/H97U1v1PNDNcBsMZgU3KFuND9MQdTsU2NOwmCXArE= golang.zx2c4.com/wireguard/windows v1.1.1/go.mod h1:+fbT3FFdX4zzYDLwJh5+HPEcNN/3HyNdzhNSVsQM+zs= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800 h1:qEHAMpSaUhtD0p3NbEEI83HwNGFxEwaSJ1G9PLnCBZE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20261005182115-fad411399dd8 h1:0SXBqli5dnuCKMjI/X3FM226zEaEa0GUcSCJtN1vL2o= +google.golang.org/genproto/googleapis/rpc v0.0.0-20261005182115-fad411399dd8/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc= google.golang.org/grpc v1.84.0 h1:soMyaPJ8pAak5PIQ0DGBUir0XRo2fRoMqhNWMLlLxO0= google.golang.org/grpc v1.84.0/go.mod h1:ljCht0DrxQrXBDRTZp52Qxh3Ffk8CdYm2sj4O2QN2C0= google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= @@ -150,7 +149,9 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= -gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 h1:Lk6hARj5UPY47dBep70OD/TIMwikJ5fGUGX0Rm3Xigk= -gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0/go.mod h1:QkHjoMIBaYtpVufgwv3keYAbln78mBoCuShZrPrer1Q= +gvisor.dev/gvisor v0.0.0-20261010064756-cd110d9dec04 h1:iVC12KE9T4FE4+ygenM2M0GGtZ5RLIGrXmY9T5UPdZk= +gvisor.dev/gvisor v0.0.0-20261010064756-cd110d9dec04/go.mod h1:LOvux1Z/goEJudXUp4E7WwCxxJeeMf5kVoprLEZvOHo= +layeh.com/gopher-luar v1.0.11 h1:8zJudpKI6HWkoh9eyyNFaTM79PY6CAPcIr6X/KTiliw= +layeh.com/gopher-luar v1.0.11/go.mod h1:TPnIVCZ2RJBndm7ohXyaqfhzjlZ+OA2SZR/YwL8tECk= lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo= diff --git a/readme/README.zh_CN.md b/readme/README.zh_CN.md index ac8b6e41..4f3b0175 100644 --- a/readme/README.zh_CN.md +++ b/readme/README.zh_CN.md @@ -16,7 +16,7 @@ 依赖 git 和 go。 -默认情况下,编译脚本不会 clone [Xray-core](https://github.com/XTLS/Xray-core),而是通过 Go modules 的 pseudo-version 将 Xray-core 固定到发布版本 `v26.9.30`。 +默认情况下,编译脚本不会 clone [Xray-core](https://github.com/XTLS/Xray-core),而是通过 Go modules 的 pseudo-version 将 Xray-core 固定到发布版本 `v26.10.10`。 传入可选参数 `local` 时,会通过 Go module `replace` 改用已有的本地仓库 `../Xray-core`。 ### 使用方式 From 4e1f7843e6cddb750ac7abc23ef36800820a8098 Mon Sep 17 00:00:00 2001 From: yiguodev <147401898+yiguodev@users.noreply.github.com> Date: Tue, 13 Oct 2026 09:40:03 +0800 Subject: [PATCH 32/38] feat: decode full subscription text and remove exit location probes (#165) * feat: decode subscription text and remove exit location probes * test: retain forced outbound isolation coverage --- AGENTS.md | 5 +- README.md | 65 +++++++++----- invoke.go | 29 +++++-- invoke_decode_subscription_test.go | 98 +++++++++++++++++++++ invoke_model.go | 25 ++++-- invoke_probes_test.go | 54 ++++++------ readme/README.zh_CN.md | 54 ++++++++---- share/decode_subscription.go | 14 +++ share/decode_subscription_test.go | 115 +++++++++++++++++++++++++ xray/ping_batch.go | 47 +---------- xray/ping_location_test.go | 131 ----------------------------- 11 files changed, 380 insertions(+), 257 deletions(-) create mode 100644 invoke_decode_subscription_test.go create mode 100644 share/decode_subscription.go create mode 100644 share/decode_subscription_test.go delete mode 100644 xray/ping_location_test.go diff --git a/AGENTS.md b/AGENTS.md index 907ff9a4..82cd69c9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,9 +23,8 @@ changes need the relevant implementation and tests, not the entire API guide. managed-instance overlap before loading configuration and hold the lifecycle lock through worker completion and instance cleanup. Close temporary instances on every exit path; unmanaged overlaps require caller-owned process isolation. -- When changing [batch probes](README.md#pingbatch), preserve input order, - per-item failure isolation, and raw `locationJson`; provider parsing belongs - to the App. +- When changing [batch probes](README.md#pingbatch), preserve the five-item + limit, input order, per-item failure isolation, and delay-only results. - Traffic comes directly from [Xray metrics](README.md#metrics). The library manages the Core lifecycle only; it does not sample or persist traffic. - When changing [age subscriptions](README.md#age-encrypted-subscriptions), diff --git a/README.md b/README.md index 40f0ed3f..a2579749 100644 --- a/README.md +++ b/README.md @@ -234,11 +234,15 @@ Design notes: also serializes these operations with one another. Instances created outside the managed APIs are not detected or restored; callers requiring overlap with them must still use separate processes. +8. `decodeSubscriptionText` returns the complete plaintext after optional Age + decryption. It does not parse subscription formats, project outbounds or + construct an Xray instance; callers own classification and validation. Supported methods: ```text getFreePorts +decodeSubscriptionText convertShareLinksToXrayJson convertXrayJsonToShareLinks generateAgeKeyPair @@ -415,9 +419,42 @@ container or a decryption failure returns `success: false` with `data: null`. Error text never includes rejected candidates or decrypted subscription text. Callers must not import/replace a subscription when no usable nodes remain. +### decodeSubscriptionText + +Returns the complete subscription text, including full Xray JSON objects or +arrays and their DNS, routing, balancers and unknown fields. This method only +unwraps Age armor: unencrypted text, including URI lists and Base64 strings, +is returned exactly as supplied. It does not validate JSON or decode Base64. + +```json +{ + "apiVersion": 3, + "method": "decodeSubscriptionText", + "payload": { + "text": "-----BEGIN AGE ENCRYPTED FILE-----\n...", + "age": { + "secretKey": "AGE-SECRET-KEY-1..." + } + } +} +``` + +Success returns `data: {"text": ""}`; the `text` field is +present even for empty text. Errors return `data: null` and the existing Age +key, armor, decryption or plaintext-size error. Both unencrypted and decrypted +plaintext are limited to 16 MiB of UTF-8 bytes. The complete Invoke request and +encoded response retain their separate 16 MiB limits, so envelope or escaping +overhead can reject text below the plaintext limit. The Go helper is +`share.DecodeSubscriptionText(text, secretKey)`. + +The caller must classify the returned text before importing nodes or storing a +complete configuration. This method does not create a temporary Core and can +be called while a managed instance is running. + ### age-encrypted subscriptions -`convertShareLinksToXrayJson` accepts an optional native age secret key. Only +`decodeSubscriptionText` and `convertShareLinksToXrayJson` accept an optional +native age secret key. Only X25519 (`AGE-SECRET-KEY-1...`) and ML-KEM-768 + X25519 hybrid (`AGE-SECRET-KEY-PQ-1...`) identities are accepted. Recognized age armor is decrypted in memory and limited to 16 MiB of plaintext. @@ -454,7 +491,8 @@ The response contains both `secretKey` and `publicKey`. The integrating application must persist the pair and send only `publicKey` as `X-Age-Public-Key`. libXray does not perform the subscription HTTP request, persist keys, or add headers. Applications must never send the secret key over -HTTP or write decrypted subscription text to disk. +HTTP or log secret keys or decrypted subscription text. Configuration +persistence belongs to the integrating application. ### xray_json @@ -484,8 +522,7 @@ by the `proxy` tag, and finally by the first outbound. } ], "timeout": 5, - "url": "https://cp.cloudflare.com/", - "locationUrl": "https://ip-check-perf.radar.cloudflare.com/" + "url": "https://cp.cloudflare.com/" } } ``` @@ -501,22 +538,10 @@ The result array has the same length and order as the input config array. Outbound dependencies referenced by `streamSettings.sockopt.dialerProxy` are included automatically. Xray-core rejects the removed `proxySettings` field. -`locationUrl` is optional and must be an absolute HTTP(S) URL. When omitted, -no location request is made and no location fields are returned. When supplied, -each prepared item sends its latency HEAD and then its location GET using the -same client forced through that item's selected outbound and dependencies. -Each request has the configured timeout (so an item may take up to twice it). -Location time is not included in `delay`, and the two results are independent: -`success`, `delay` and `error` describe latency only; a location failure does not -invalidate a successful latency result, and GET is still attempted after a -latency failure. - -A successful GET adds the unmodified response body as the `locationJson` -string. The App owns JSON parsing and provider-specific field handling. The -provider must return HTTP 200 and at most 64 KiB; transport or body-read -failures instead add `locationError`. Errors do not echo the URL, credentials -or response body. Invalid outbound configs retain their ordinary per-item -failure and do not perform either request. +Each prepared item sends one latency HEAD through its selected outbound and +dependencies, using the configured timeout. Its result contains only +`success`, `delay` and an optional `error`. Invalid outbound configurations +retain their per-item failure and do not send a request. ### testXray diff --git a/invoke.go b/invoke.go index 1f832412..50e83024 100644 --- a/invoke.go +++ b/invoke.go @@ -37,6 +37,8 @@ func Invoke(requestJSON string) string { switch request.Method { case LibXrayMethodGetFreePorts: return invokeGetFreePorts(request.Payload) + case LibXrayMethodDecodeSubscriptionText: + return invokeDecodeSubscriptionText(request.Payload) case LibXrayMethodConvertShareLinksToXrayJson: return invokeConvertShareLinksToXrayJson(request.Payload) case LibXrayMethodConvertXrayJsonToShareLinks: @@ -130,6 +132,22 @@ func invokeGetFreePorts(payload json.RawMessage) string { return encodeInvokeResponse(&GetFreePortsResponse{Ports: ports}, nil) } +func invokeDecodeSubscriptionText(payload json.RawMessage) string { + request, err := decodePayload[DecodeSubscriptionTextRequest](payload) + if err != nil { + return encodeInvokeResponse(nil, err) + } + secretKey := "" + if request.Age != nil { + secretKey = request.Age.SecretKey + } + text, err := share.DecodeSubscriptionText(request.Text, secretKey) + if err != nil { + return encodeInvokeResponse(nil, err) + } + return encodeInvokeResponse(&DecodeSubscriptionTextResponse{Text: text}, nil) +} + func invokeConvertShareLinksToXrayJson(payload json.RawMessage) string { request, err := decodePayload[ConvertShareLinksToXrayJsonRequest](payload) if err != nil { @@ -196,11 +214,10 @@ func invokePingBatch(payload json.RawMessage) string { } } - results, err := xray.PingBatchWithLocation( + results, err := xray.PingBatch( configs, request.Timeout, request.URL, - request.LocationURL, ) if err != nil { return encodeInvokeResponse(nil, err) @@ -209,11 +226,9 @@ func invokePingBatch(payload json.RawMessage) string { responseResults := make([]PingBatchItemResponse, len(results)) for i, result := range results { responseResults[i] = PingBatchItemResponse{ - Success: result.Success, - Delay: result.Delay, - Error: result.Error, - LocationJSON: result.LocationJSON, - LocationError: result.LocationError, + Success: result.Success, + Delay: result.Delay, + Error: result.Error, } } return encodeInvokeResponse(&PingBatchResponse{Results: responseResults}, nil) diff --git a/invoke_decode_subscription_test.go b/invoke_decode_subscription_test.go new file mode 100644 index 00000000..e6c9e960 --- /dev/null +++ b/invoke_decode_subscription_test.go @@ -0,0 +1,98 @@ +package libXray + +import ( + "bytes" + "encoding/json" + "io" + "strings" + "testing" + + "github.com/metacubex/age" + "github.com/metacubex/age/armor" + "github.com/xtls/libxray/share" +) + +func TestInvokeDecodeSubscriptionTextPreservesWholeDocument(t *testing.T) { + const config = `{"remarks":"完整配置","outbounds":[{"tag":"proxy","protocol":"future-protocol","mux":{"enabled":true}}],"dns":{"servers":["fakedns"]},"routing":{"balancers":[{"tag":"pool","selector":["proxy"]}]},"observatory":{"subjectSelector":["proxy"]}}` + for _, text := range []string{"", config, "[\n" + config + ",\n" + config + "\n]\n", "not JSON"} { + response := invokeForTest(t, LibXrayMethodDecodeSubscriptionText, DecodeSubscriptionTextRequest{Text: text}) + if !response.Success || response.Err != "" { + t.Fatalf("decode failed: %s", response.Err) + } + var data map[string]json.RawMessage + if err := json.Unmarshal(response.Data, &data); err != nil { + t.Fatal(err) + } + if len(data) != 1 || data["text"] == nil { + t.Fatal("decode response must contain only text, including empty text") + } + if decoded := decodeDataObject[DecodeSubscriptionTextResponse](t, response); decoded.Text != text { + t.Fatal("decode response projected or changed the source") + } + } +} + +func TestInvokeDecodeSubscriptionTextDecryptsWithoutProjection(t *testing.T) { + pair, err := share.GenerateAgeKeyPair(share.AgeKeyTypeX25519) + if err != nil { + t.Fatal(err) + } + const text = `[{"outbounds":[{"protocol":"unknown","streamSettings":{"sockopt":{"dialerProxy":"entry"}}}],"routing":{"rules":[]},"dns":{"hosts":{"example.com":"203.0.113.1"}}}]` + response := invokeForTest(t, LibXrayMethodDecodeSubscriptionText, DecodeSubscriptionTextRequest{ + Text: encryptDecodeTextForTest(t, pair.PublicKey, text), + Age: &AgeDecryptConfig{SecretKey: pair.SecretKey}, + }) + if !response.Success || decodeDataObject[DecodeSubscriptionTextResponse](t, response).Text != text { + t.Fatalf("full subscription decode failed: %s", response.Err) + } + for _, payload := range []any{ + DecodeSubscriptionTextRequest{Text: "-----BEGIN AGE ENCRYPTED FILE-----\ninvalid"}, + map[string]any{"text": []string{"invalid field type"}}, + } { + failed := invokeForTest(t, LibXrayMethodDecodeSubscriptionText, payload) + if failed.Success || string(failed.Data) != "null" || failed.Err == "" { + t.Fatal("failed decoding must return null data and an error") + } + } +} + +func TestInvokeDecodeSubscriptionTextKeepsEnvelopeLimits(t *testing.T) { + pair, err := share.GenerateAgeKeyPair(share.AgeKeyTypeX25519) + if err != nil { + t.Fatal(err) + } + // The armor fits the request limit; JSON's HTML escaping expands the decoded + // response beyond its envelope limit without exceeding the plaintext limit. + text := strings.Repeat("<", 3*1024*1024) + response := invokeForTest(t, LibXrayMethodDecodeSubscriptionText, DecodeSubscriptionTextRequest{ + Text: encryptDecodeTextForTest(t, pair.PublicKey, text), + Age: &AgeDecryptConfig{SecretKey: pair.SecretKey}, + }) + if response.Success || string(response.Data) != "null" || response.Err != invokeJSONSizeLimitMessage("response") { + t.Fatalf("oversize decoded envelope error = %s", response.Err) + } +} + +func encryptDecodeTextForTest(t *testing.T, publicKey, text string) string { + t.Helper() + recipient, err := age.ParseX25519Recipient(publicKey) + if err != nil { + t.Fatal(err) + } + var output bytes.Buffer + armored := armor.NewWriter(&output) + writer, err := age.Encrypt(armored, recipient) + if err != nil { + t.Fatal(err) + } + if _, err := io.WriteString(writer, text); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + if err := armored.Close(); err != nil { + t.Fatal(err) + } + return output.String() +} diff --git a/invoke_model.go b/invoke_model.go index 89143175..a3223b6f 100644 --- a/invoke_model.go +++ b/invoke_model.go @@ -11,6 +11,7 @@ const LibXrayAPIVersion = 3 const ( LibXrayMethodGetFreePorts LibXrayMethod = "getFreePorts" + LibXrayMethodDecodeSubscriptionText LibXrayMethod = "decodeSubscriptionText" LibXrayMethodConvertShareLinksToXrayJson LibXrayMethod = "convertShareLinksToXrayJson" LibXrayMethodConvertXrayJsonToShareLinks LibXrayMethod = "convertXrayJsonToShareLinks" LibXrayMethodGenerateAgeKeyPair LibXrayMethod = "generateAgeKeyPair" @@ -42,6 +43,15 @@ type AgeDecryptConfig struct { SecretKey string `json:"secretKey,omitempty"` } +type DecodeSubscriptionTextRequest struct { + Text string `json:"text,omitempty"` + Age *AgeDecryptConfig `json:"age,omitempty"` +} + +type DecodeSubscriptionTextResponse struct { + Text string `json:"text"` +} + type ConvertShareLinksToXrayJsonRequest struct { Text string `json:"text,omitempty"` Age *AgeDecryptConfig `json:"age,omitempty"` @@ -78,10 +88,9 @@ type CountGeoDataRequest struct { } type PingBatchRequest struct { - Configs []PingBatchItemRequest `json:"configs,omitempty"` - Timeout int `json:"timeout,omitempty"` - URL string `json:"url,omitempty"` - LocationURL string `json:"locationUrl,omitempty"` + Configs []PingBatchItemRequest `json:"configs,omitempty"` + Timeout int `json:"timeout,omitempty"` + URL string `json:"url,omitempty"` } type PingBatchItemRequest struct { @@ -94,11 +103,9 @@ type PingBatchResponse struct { } type PingBatchItemResponse struct { - Success bool `json:"success"` - Delay int64 `json:"delay"` - Error string `json:"error,omitempty"` - LocationJSON *string `json:"locationJson,omitempty"` - LocationError string `json:"locationError,omitempty"` + Success bool `json:"success"` + Delay int64 `json:"delay"` + Error string `json:"error,omitempty"` } type RunXrayRequest struct { diff --git a/invoke_probes_test.go b/invoke_probes_test.go index 8e8caff9..57597840 100644 --- a/invoke_probes_test.go +++ b/invoke_probes_test.go @@ -2,10 +2,9 @@ package libXray import ( "encoding/json" - "fmt" "net/http" "net/http/httptest" - "strings" + "sync/atomic" "testing" ) @@ -35,7 +34,7 @@ func TestInvokeShareOutboundsResponseShape(t *testing.T) { } } -func TestInvokePingLocationAndZeroDelayWireFields(t *testing.T) { +func TestInvokePingZeroDelayWireField(t *testing.T) { raw, err := json.Marshal(PingBatchItemResponse{Success: true, Delay: 0}) if err != nil { t.Fatal(err) @@ -43,40 +42,41 @@ func TestInvokePingLocationAndZeroDelayWireFields(t *testing.T) { if string(raw) != `{"success":true,"delay":0}` { t.Fatalf("zero latency response = %s", raw) } - empty := "" - raw, err = json.Marshal(PingBatchItemResponse{Success: true, Delay: 0, LocationJSON: &empty}) - if err != nil { - t.Fatal(err) - } - if string(raw) != `{"success":true,"delay":0,"locationJson":""}` { - t.Fatalf("empty location response = %s", raw) - } - locationBody := `{"ip_address":"203.0.113.1","country":"SG"}` +} + +func TestInvokePingBatchUsesEachForcedOutboundAndOnlyLatencyHeads(t *testing.T) { + var heads, unexpected atomic.Int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - fmt.Fprint(w, locationBody) + if r.Method == http.MethodHead { + heads.Add(1) + } else { + unexpected.Add(1) + } + w.WriteHeader(http.StatusNoContent) })) defer server.Close() request := PingBatchRequest{ - Configs: []PingBatchItemRequest{{XrayJson: `{"outbounds":[{"protocol":"freedom"}]}`}}, - Timeout: 1, URL: server.URL, LocationURL: server.URL, + Configs: []PingBatchItemRequest{ + {XrayJson: `{"outbounds":[{"protocol":"freedom","tag":"proxy"}]}`}, + {XrayJson: `{"outbounds":[{"protocol":"blackhole","tag":"proxy"}]}`}, + }, + Timeout: 1, URL: server.URL, } response := invokeForTest(t, LibXrayMethodPingBatch, request) if !response.Success { t.Fatalf("error = %s", response.Err) } - result := decodeDataObject[PingBatchResponse](t, response).Results[0] - if !result.Success || result.LocationJSON == nil || *result.LocationJSON != `{"ip_address":"203.0.113.1","country":"SG"}` { - t.Fatalf("result = %+v", result) + results := decodeDataObject[PingBatchResponse](t, response).Results + if len(results) != 2 { + t.Fatalf("results = %d, want 2", len(results)) + } + if !results[0].Success || results[0].Error != "" { + t.Fatalf("freedom result = %+v", results[0]) } - locationBody = "" - response = invokeForTest(t, LibXrayMethodPingBatch, request) - result = decodeDataObject[PingBatchResponse](t, response).Results[0] - if !response.Success || result.LocationJSON == nil || *result.LocationJSON != "" { - t.Fatalf("empty location response = %+v", response) + if results[1].Success || results[1].Error == "" { + t.Fatalf("blackhole outbound escaped through another client: %+v", results[1]) } - request.LocationURL = "" - response = invokeForTest(t, LibXrayMethodPingBatch, request) - if !response.Success || strings.Contains(string(response.Data), "location") { - t.Fatalf("latency-only response = %+v", response) + if heads.Load() != 1 || unexpected.Load() != 0 { + t.Fatalf("requests = HEAD %d, other %d", heads.Load(), unexpected.Load()) } } diff --git a/readme/README.zh_CN.md b/readme/README.zh_CN.md index 4f3b0175..b006640b 100644 --- a/readme/README.zh_CN.md +++ b/readme/README.zh_CN.md @@ -168,11 +168,13 @@ void CGoFree(char* value); 5. 完整的 UTF-8 编码 Invoke 请求和响应 JSON 包体限制为 16 MiB。任一方向超过限制时,Invoke 将返回 `success: false`、`data: null` 和对应的大小限制错误。 6. `convertShareLinksToXrayJson` 会使用当前 Xray-core 配置构建器校验每个已解析的 outbound。无效 outbound 会被忽略;如果没有剩余的有效 outbound,该方法返回失败。校验不会创建或启动 Xray instance。Xray JSON 输入仅作为节点来源,只保留根级 `outbounds`,忽略其他根字段。响应仅包含 libXray 分享链接支持的字段,不支持的字段和生成的空字段会被省略;XHTTP `extra` 与 FinalMask mask `settings` 中的原始 JSON 保持不变。每次成功响应仅包含投影后的 `outbounds` 列表。可选的 `age.secretKey` 会在现有解析流程前于内存中解密官方 age ASCII armor;明文输入保持原有行为。 7. Xray-core 的系统拨号 DNS client 和 outbound manager 属于进程级状态。`pingBatch`、`testXray` 及对应导出的 Go 入口均取得受管理生命周期锁,在加载/构建配置前拒绝同进程已运行的 `runXray` instance。批量测速在全部 worker 和临时核心关闭后才释放锁,这些操作也彼此串行。由管理 API 之外创建的 instance 不在检测或恢复范围内;可能与它们重叠的调用仍须使用独立进程。 +8. `decodeSubscriptionText` 在可选的 Age 解密后返回完整明文,不解析订阅格式、不投影 outbound、不构造 Xray instance;分类和配置校验由调用方负责。 支持的 method: ```text getFreePorts +decodeSubscriptionText convertShareLinksToXrayJson convertXrayJsonToShareLinks generateAgeKeyPair @@ -315,9 +317,38 @@ outbound,不做节点 hash 比较、去重或失败节点计数。 返回 `success: false` 与 `data: null`。错误文案不含被拒绝的候选或解密明文。 调用方不得在可用节点为零时导入或覆盖订阅。 +### decodeSubscriptionText + +返回完整订阅文本,包括完整 Xray JSON 对象或数组及其中的 DNS、路由、均衡器 +和未知字段。该方法只解开 Age armor;未加密文本(包括 URI 列表和 Base64 +字符串)原样返回,不校验 JSON,也不解码 Base64。 + +```json +{ + "apiVersion": 3, + "method": "decodeSubscriptionText", + "payload": { + "text": "-----BEGIN AGE ENCRYPTED FILE-----\n...", + "age": { + "secretKey": "AGE-SECRET-KEY-1..." + } + } +} +``` + +成功返回 `data: {"text": "<完整明文>"}`,空文本也始终包含 `text`。失败返回 +`data: null` 和既有 Age 密钥、armor、解密或明文大小错误。未加密和解密后的 +明文均限制为 16 MiB UTF-8 字节;完整 Invoke 请求和编码后的响应仍各有 16 MiB +上限,因此包体或转义开销可能使未达到明文上限的文本被拒绝。Go helper 为 +`share.DecodeSubscriptionText(text, secretKey)`。 + +调用方先分类返回文本,再选择节点导入或完整配置存储流程。此方法不创建临时 +Core,可在受管理 instance 运行时调用。 + ### age 加密订阅 -`convertShareLinksToXrayJson` 接受可选的 age 原生私钥。仅支持 X25519 +`decodeSubscriptionText` 和 `convertShareLinksToXrayJson` 接受可选的 age 原生 +私钥。仅支持 X25519 (`AGE-SECRET-KEY-1...`)和 ML-KEM-768 + X25519 hybrid (`AGE-SECRET-KEY-PQ-1...`)identity。识别到 age armor 后会在内存中完成 解密,解密后明文上限为 16 MiB。 @@ -352,8 +383,8 @@ outbound,不做节点 hash 比较、去重或失败节点计数。 响应同时包含 `secretKey` 和 `publicKey`。接入 App 必须持久化该密钥对,并且 只将 `publicKey` 作为 `X-Age-Public-Key` 发送。libXray 不负责订阅 HTTP 请求、 -密钥持久化或请求 Header;严禁通过 HTTP 发送私钥,也不能把解密后的订阅文本 -写入磁盘。 +密钥持久化或请求 Header;严禁通过 HTTP 发送私钥,不能记录私钥或解密后的 +订阅文本日志。配置持久化由接入 App 负责。 ### xray_json @@ -382,8 +413,7 @@ outbound,不做节点 hash 比较、去重或失败节点计数。 } ], "timeout": 5, - "url": "https://cp.cloudflare.com/", - "locationUrl": "https://ip-check-perf.radar.cloudflare.com/" + "url": "https://cp.cloudflare.com/" } } ``` @@ -398,17 +428,9 @@ outbound,不做节点 hash 比较、去重或失败节点计数。 通过 `streamSettings.sockopt.dialerProxy` 引用的 outbound 依赖会被自动包含。 已废弃的 `proxySettings` 字段由 Xray-core 拒绝。 -`locationUrl` 为可选的绝对 HTTP(S) 地址。省略时不请求位置、不返回位置字段; -传入时,每个完成准备的配置先执行测速 HEAD,再执行位置 GET,两者使用同一个 -强制经过当前所选 outbound 及其依赖的 client。每个请求各有一次配置的超时, -因此单项最多可能使用两倍超时时间。位置请求时间不计入 `delay`;两个结果独立: -`success`、`delay`、`error` 只表示延迟结果,位置失败不影响成功的延迟,延迟 -失败后仍尝试位置 GET。 - -GET 成功后把响应正文原样放入 `locationJson` 字符串。JSON 解析和数据源专属字段 -处理由 App 负责。数据源必须返回 HTTP 200,正文最大 64 KiB;传输或正文读取失败 -改为返回 `locationError`。错误不回显 URL、凭据或响应正文。无效 outbound 保留 -原有逐项失败结果,不发出这两个请求。 +每个完成准备的配置使用指定超时,通过其所选 outbound 及依赖发送一次延迟 +HEAD。逐项结果仅包含 `success`、`delay` 和可选的 `error`;无效 outbound +保留逐项失败,不发送请求。 ### testXray diff --git a/share/decode_subscription.go b/share/decode_subscription.go new file mode 100644 index 00000000..cc1f941c --- /dev/null +++ b/share/decode_subscription.go @@ -0,0 +1,14 @@ +package share + +// DecodeSubscriptionText decrypts Age armor or returns the original plaintext. +// It does not parse or validate the subscription's format or Xray configuration. +func DecodeSubscriptionText(text, secretKey string) (string, error) { + plaintext, _, err := decryptShareText(text, secretKey) + if err != nil { + return "", err + } + if len(plaintext) > maxAgePlaintextBytes { + return "", ErrAgePlaintextTooLarge + } + return plaintext, nil +} diff --git a/share/decode_subscription_test.go b/share/decode_subscription_test.go new file mode 100644 index 00000000..08c54495 --- /dev/null +++ b/share/decode_subscription_test.go @@ -0,0 +1,115 @@ +package share + +import ( + "errors" + "strings" + "testing" +) + +const fullSubscriptionText = `[ + { + "remarks": "ordinary", + "inbounds": [{"tag":"socks","protocol":"socks","port":10808}], + "outbounds": [{"tag":"proxy","protocol":"vless","settings":{"vnext":[{"address":"example.com","port":443,"users":[{"id":"provider-value"}]}]},"mux":{"enabled":true}}], + "dns": {"servers":["https://dns.example/dns-query"]}, + "routing": {"rules":[{"inboundTag":["socks"],"outboundTag":"proxy"}]} + }, + { + "remarks": "virtual pool", + "outbounds": [{"tag":"pool-1","protocol":"vless","streamSettings":{"sockopt":{"dialerProxy":"entry"}}},{"tag":"entry","protocol":"trojan"}], + "routing": {"balancers":[{"tag":"pool","selector":["pool-"]}],"rules":[{"balancerTag":"pool"}]}, + "observatory": {"subjectSelector":["pool-"]}, + "futureOptions": {"preserve":true} + } +] +` + +func TestDecodeSubscriptionTextPreservesPlaintextWithoutParsing(t *testing.T) { + for _, text := range []string{"", fullSubscriptionText, " \r\nnot a config\r\n", "dmxlc3M6Ly9leGFtcGxl"} { + decoded, err := DecodeSubscriptionText(text, "unused-invalid-key") + if err != nil || decoded != text { + t.Fatalf("plaintext was changed: input length %d, output length %d, error %v", len(text), len(decoded), err) + } + } +} + +func TestDecodeSubscriptionTextPreservesEncryptedFullConfig(t *testing.T) { + for _, keyType := range []AgeKeyType{AgeKeyTypeX25519, AgeKeyTypeHybrid} { + t.Run(string(keyType), func(t *testing.T) { + pair, err := GenerateAgeKeyPair(keyType) + if err != nil { + t.Fatal(err) + } + armored := encryptAgeForTest(t, pair, fullSubscriptionText) + decoded, err := DecodeSubscriptionText(" \r\n"+armored+" \r\n", pair.SecretKey) + if err != nil || decoded != fullSubscriptionText { + t.Fatalf("complete config was changed: output length %d, error %v", len(decoded), err) + } + }) + } +} + +func TestDecodeSubscriptionTextRejectsAgeFailuresWithoutReturningText(t *testing.T) { + pair, err := GenerateAgeKeyPair(AgeKeyTypeX25519) + if err != nil { + t.Fatal(err) + } + wrong, err := GenerateAgeKeyPair(AgeKeyTypeX25519) + if err != nil { + t.Fatal(err) + } + armored := encryptAgeForTest(t, pair, fullSubscriptionText) + for _, test := range []struct { + name, text, key string + want error + }{ + {"missing", armored, "", ErrAgeSecretKeyMissing}, + {"invalid", armored, "invalid-key", ErrAgeSecretKeyInvalid}, + {"wrong", armored, wrong.SecretKey, ErrAgeDecryptFailed}, + {"armor", ageArmorHeader + "\ninvalid", pair.SecretKey, ErrAgeArmorMalformed}, + } { + t.Run(test.name, func(t *testing.T) { + decoded, err := DecodeSubscriptionText(test.text, test.key) + if decoded != "" || !errors.Is(err, test.want) { + t.Fatalf("output length %d, error %v, want %v", len(decoded), err, test.want) + } + if test.key != "" && strings.Contains(err.Error(), test.key) { + t.Fatal("error exposed the secret key") + } + }) + } +} + +func TestDecodeSubscriptionTextLimitsUTF8PlaintextBytes(t *testing.T) { + // The last ASCII byte fills the remainder after the three-byte characters. + text := strings.Repeat("界", maxAgePlaintextBytes/3) + "a" + if len(text) != maxAgePlaintextBytes { + t.Fatal("fixture must fill the exact byte limit") + } + decoded, err := DecodeSubscriptionText(text, "") + if err != nil || decoded != text { + t.Fatalf("exact byte limit was rejected: %v", err) + } + decoded, err = DecodeSubscriptionText(text+"b", "") + if decoded != "" || !errors.Is(err, ErrAgePlaintextTooLarge) { + t.Fatalf("oversize plaintext output length %d, error %v", len(decoded), err) + } +} + +func TestDecodeSubscriptionTextLimitsDecryptedBytes(t *testing.T) { + pair, err := GenerateAgeKeyPair(AgeKeyTypeX25519) + if err != nil { + t.Fatal(err) + } + for _, size := range []int{maxAgePlaintextBytes, maxAgePlaintextBytes + 1} { + text := strings.Repeat("x", size) + decoded, err := DecodeSubscriptionText(encryptAgeForTest(t, pair, text), pair.SecretKey) + if size == maxAgePlaintextBytes { + if err != nil || decoded != text { + t.Fatalf("exact decrypted byte limit was rejected: %v", err) + } + } else if decoded != "" || !errors.Is(err, ErrAgePlaintextTooLarge) { + t.Fatalf("oversize decrypted output length %d, error %v", len(decoded), err) + } + } +} diff --git a/xray/ping_batch.go b/xray/ping_batch.go index 0fcf010e..c663598c 100644 --- a/xray/ping_batch.go +++ b/xray/ping_batch.go @@ -31,11 +31,9 @@ type PingBatchItem struct { } type PingBatchResult struct { - Success bool - Delay int64 - Error string - LocationJSON *string - LocationError string + Success bool + Delay int64 + Error string } type pingOutboundConfig struct { @@ -52,20 +50,9 @@ func PingBatch( timeout int, targetURL string, ) ([]PingBatchResult, error) { - return PingBatchWithLocation(items, timeout, targetURL, "") -} - -// PingBatchWithLocation uses the same forced-outbound client for latency and -// optional location probes. The two probe results are independent. -func PingBatchWithLocation(items []PingBatchItem, timeout int, targetURL, locationURL string) ([]PingBatchResult, error) { if err := validatePingBatchRequest(items, timeout, targetURL); err != nil { return nil, err } - if locationURL != "" { - if err := validatePingBatchRequest(items, timeout, locationURL); err != nil { - return nil, errors.New("ping batch location URL must be an absolute HTTP or HTTPS URL") - } - } coreServerMu.Lock() defer coreServerMu.Unlock() if coreServer != nil { @@ -122,7 +109,6 @@ func PingBatchWithLocation(items []PingBatchItem, timeout int, targetURL, locati item.outboundTag, timeout, targetURL, - locationURL, ) } }() @@ -367,7 +353,6 @@ func probeOutbound( outboundTag string, timeout int, targetURL string, - locationURL string, ) PingBatchResult { httpTimeout := time.Second * time.Duration(timeout) transport := &http.Transport{ @@ -399,35 +384,9 @@ func probeOutbound( if err != nil { result = failedPingBatchResult(delay, err) } - if locationURL != "" { - locationJSON, err := probeLocation(client, locationURL) - if err != nil { - result.LocationError = err.Error() - } else { - result.LocationJSON = &locationJSON - } - } return result } -func probeLocation(client *http.Client, locationURL string) (string, error) { - response, err := client.Get(locationURL) - if err != nil { - // Do not include a provider URL, credentials or response body in errors. - return "", errors.New("location request failed") - } - defer response.Body.Close() - if response.StatusCode != http.StatusOK { - return "", fmt.Errorf("location request returned HTTP %d", response.StatusCode) - } - const maxLocationBytes = 64 * 1024 - body, err := io.ReadAll(io.LimitReader(response.Body, maxLocationBytes+1)) - if err != nil || len(body) > maxLocationBytes { - return "", errors.New("unable to read location response") - } - return string(body), nil -} - func failedPingBatchResult(delay int64, err error) PingBatchResult { if delay != nodep.PingDelayTimeout { delay = nodep.PingDelayError diff --git a/xray/ping_location_test.go b/xray/ping_location_test.go deleted file mode 100644 index ad4418dc..00000000 --- a/xray/ping_location_test.go +++ /dev/null @@ -1,131 +0,0 @@ -package xray - -import ( - "fmt" - "net/http" - "net/http/httptest" - "strings" - "sync/atomic" - "testing" - "time" -) - -func TestPingBatchLocationUsesEachForcedOutbound(t *testing.T) { - var heads, gets atomic.Int32 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodHead { - heads.Add(1) - return - } - gets.Add(1) - fmt.Fprint(w, `{"ip_address":"203.0.113.9","country":"jp"}`) - })) - defer server.Close() - results, err := PingBatchWithLocation([]PingBatchItem{ - {XrayJSON: `{"outbounds":[{"protocol":"freedom","tag":"proxy"}]}`}, - {XrayJSON: `{"outbounds":[{"protocol":"blackhole","tag":"proxy"}]}`}, - }, 1, server.URL, server.URL) - if err != nil { - t.Fatal(err) - } - first, second := results[0], results[1] - if !first.Success || first.LocationJSON == nil || *first.LocationJSON != `{"ip_address":"203.0.113.9","country":"jp"}` || first.LocationError != "" { - t.Fatalf("first result = %+v", first) - } - if second.Success || second.LocationJSON != nil || second.LocationError == "" { - t.Fatalf("blocked outbound escaped through another client: %+v", second) - } - if heads.Load() != 1 || gets.Load() != 1 { - t.Fatalf("requests = HEAD %d GET %d", heads.Load(), gets.Load()) - } -} - -func TestPingBatchLocationFailureDoesNotFailLatency(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodGet { - w.WriteHeader(http.StatusServiceUnavailable) - } - })) - defer server.Close() - items := []PingBatchItem{{XrayJSON: `{"outbounds":[{"protocol":"freedom"}]}`}} - results, err := PingBatchWithLocation(items, 1, server.URL, server.URL) - if err != nil { - t.Fatal(err) - } - result := results[0] - if !result.Success || result.Error != "" || result.LocationJSON != nil || result.LocationError != "location request returned HTTP 503" { - t.Fatalf("result = %+v", result) - } - results, err = PingBatch(items, 1, server.URL) - if err != nil { - t.Fatal(err) - } - if !results[0].Success || results[0].LocationJSON != nil || results[0].LocationError != "" { - t.Fatalf("latency-only result = %+v", results[0]) - } -} - -func TestPingBatchLocationCanSucceedWhenLatencyFails(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodHead { - conn, _, err := w.(http.Hijacker).Hijack() - if err == nil { - conn.Close() - } - return - } - fmt.Fprint(w, `{"ip_address":"2001:db8::1","country":"US"}`) - })) - defer server.Close() - results, err := PingBatchWithLocation([]PingBatchItem{{XrayJSON: `{"outbounds":[{"protocol":"freedom"}]}`}}, 1, server.URL, server.URL) - if err != nil { - t.Fatal(err) - } - if results[0].Success || results[0].LocationJSON == nil || *results[0].LocationJSON != `{"ip_address":"2001:db8::1","country":"US"}` || results[0].LocationError != "" { - t.Fatalf("result = %+v", results[0]) - } -} - -func TestProbeLocationReturnsOriginalBody(t *testing.T) { - for _, body := range []string{"", " \n{\"provider_specific\": true}\n", strings.Repeat("x", 64*1024)} { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, body) })) - locationJSON, err := probeLocation(server.Client(), server.URL) - server.Close() - if err != nil || locationJSON != body { - t.Fatalf("location JSON length = %d, error = %v", len(locationJSON), err) - } - } -} - -func TestProbeLocationRejectsOversizedResponseWithoutLeakingInput(t *testing.T) { - body := "private-source" + strings.Repeat("x", 64*1024+1-len("private-source")) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - fmt.Fprint(w, body) - })) - defer server.Close() - locationJSON, err := probeLocation(server.Client(), server.URL) - if err == nil || locationJSON != "" { - t.Fatalf("location JSON = %q, error = %v", locationJSON, err) - } - if strings.Contains(err.Error(), "private-source") { - t.Fatal("response body leaked") - } -} - -func TestProbeLocationHidesURLAndCredentialsOnRequestFailure(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { <-r.Context().Done() })) - defer server.Close() - client := server.Client() - client.Timeout = 5 * time.Millisecond - _, err := probeLocation(client, strings.Replace(server.URL, "://", "://private-user:private-password@", 1)) - if err == nil || err.Error() != "location request failed" { - t.Fatalf("error = %v", err) - } -} - -func TestPingBatchRejectsInvalidLocationURL(t *testing.T) { - _, err := PingBatchWithLocation([]PingBatchItem{{}}, 1, "https://example.com", "file:///private/file") - if err == nil || !strings.Contains(err.Error(), "location URL") { - t.Fatalf("error = %v", err) - } -} From 75ccb623c807a1113a064278e3e544b0049c16a5 Mon Sep 17 00:00:00 2001 From: yiguodev <147401898+yiguodev@users.noreply.github.com> Date: Sun, 18 Oct 2026 10:38:27 +0800 Subject: [PATCH 33/38] chore: update Xray-core to v26.10.17 and refresh dependencies (#166) --- .github/workflows/build.yml | 6 ++-- README.md | 2 +- build/app/build.py | 4 +-- go.mod | 30 +++++++++---------- go.sum | 57 ++++++++++++++++++------------------- readme/README.zh_CN.md | 2 +- 6 files changed, 48 insertions(+), 53 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 33b69bd5..6e70092d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -30,17 +30,17 @@ jobs: - runner: ubuntu-24.04 target: android artifact: android - - runner: windows-2025 + - runner: windows-2025-vs2026 target: windows arch: x64 goarch: amd64 artifact: windows-x64 - - runner: windows-11-arm + - runner: windows-11-vs2026-arm target: windows arch: arm64 goarch: arm64 artifact: windows-arm64 - - runner: macos-26 + - runner: xcode-27 # macOS 27 preview target: apple artifact: apple-cgo diff --git a/README.md b/README.md index a2579749..dd35db93 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ Compile script. It is recommended to always use this script to compile libXray. depends on git and go. -By default, the build script does not clone [Xray-core](https://github.com/XTLS/Xray-core). It uses Go modules and pins Xray-core to release tag `v26.10.10` through its pseudo-version. +By default, the build script does not clone [Xray-core](https://github.com/XTLS/Xray-core). It uses Go modules and pins Xray-core to release tag `v26.10.17` through its pseudo-version. Pass the optional `local` argument to use an existing local checkout at `../Xray-core` through a Go module `replace`. ### Usage diff --git a/build/app/build.py b/build/app/build.py index d89d8707..b2562525 100644 --- a/build/app/build.py +++ b/build/app/build.py @@ -9,8 +9,8 @@ LIBXRAY_MOD_NAME = "github.com/xtls/libxray" XRAY_CORE_MOD_NAME = "github.com/xtls/xray-core" -# Go modules resolve the Xray-core v26.10.10 release tag through this version. -DEFAULT_XRAY_CORE_VERSION = "v1.260327.1-0.20261010092107-701af60772cd" +# Go modules resolve the Xray-core v26.10.17 release tag through this version. +DEFAULT_XRAY_CORE_VERSION = "v1.260327.1-0.20261017152356-92ccc939aaf4" LOCAL_XRAY_CORE_DIR_NAME = "Xray-core" diff --git a/go.mod b/go.mod index 331bd873..08082dd7 100644 --- a/go.mod +++ b/go.mod @@ -1,31 +1,29 @@ module github.com/xtls/libxray -go 1.27.1 +go 1.27.2 require ( github.com/metacubex/age v0.0.0-20260603010618-28d156b4ea78 github.com/stretchr/testify v1.12.1 - github.com/xtls/xray-core v1.260327.1-0.20261010092107-701af60772cd - golang.org/x/mobile v0.0.0-20260908204917-8b95e45f8d3e + github.com/xtls/xray-core v1.260327.1-0.20261017152356-92ccc939aaf4 + golang.org/x/mobile v0.0.0-20261011034528-197770444867 golang.org/x/sys v0.49.0 google.golang.org/protobuf v1.36.12 ) require ( github.com/andybalholm/brotli v1.2.6 // indirect - github.com/apernet/quic-go v0.61.1-0.20260806010916-184d081eef3e // indirect - github.com/cloudflare/circl v1.6.5 // indirect + github.com/apernet/quic-go v0.63.1-0.20261004180939-a10df75c260c // indirect github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344 // indirect - github.com/google/btree v1.1.3 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/huin/goupnp v1.3.0 // indirect github.com/jackpal/go-nat-pmp v1.1.0 // indirect github.com/juju/ratelimit v1.0.2 // indirect - github.com/klauspost/compress v1.20.1 // indirect - github.com/klauspost/cpuid/v2 v2.4.0 // indirect + github.com/klauspost/compress v1.20.3 // indirect + github.com/klauspost/cpuid/v2 v2.4.1 // indirect github.com/koron/go-ssdp v0.9.1 // indirect - github.com/kr/text v0.2.0 // indirect + github.com/kr/pretty v0.3.1 // indirect github.com/libp2p/go-nat v1.0.1-0.20250821073202-01afc089f138 // indirect github.com/libp2p/go-netroute v0.4.0 // indirect github.com/metacubex/hkdf v0.1.0 // indirect @@ -33,7 +31,7 @@ require ( github.com/metacubex/mlkem v0.1.0 // indirect github.com/miekg/dns v1.1.73 // indirect github.com/pelletier/go-toml v1.9.5 // indirect - github.com/pion/dtls/v3 v3.1.10 // indirect + github.com/pion/dtls/v3 v3.1.14 // indirect github.com/pion/logging v0.2.4 // indirect github.com/pion/stun/v3 v3.1.7 // indirect github.com/pion/transport/v4 v4.1.1 // indirect @@ -45,26 +43,26 @@ require ( github.com/vishvananda/netlink v1.3.1 // indirect github.com/vishvananda/netns v0.0.5 // indirect github.com/wlynxg/anet v0.0.5 // indirect - github.com/xtls/reality v0.0.0-20260921001439-3c98159dee38 // indirect + github.com/xtls/reality v0.0.0-20261014101318-83f621788ca8 // indirect github.com/yuin/gopher-lua v1.1.2 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect go4.org/netipx v0.0.0-20260823151212-3075585bcbeb // indirect golang.org/x/crypto v0.58.0 // indirect - golang.org/x/crypto/x509roots/fallback v0.0.0-20261009200856-99e4382b128e // indirect - golang.org/x/exp v0.0.0-20261009195045-ca0d7ba23607 // indirect + golang.org/x/crypto/x509roots/fallback v0.0.0-20261016151342-0ae6809b7559 // indirect + golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e // indirect golang.org/x/mod v0.42.0 // indirect golang.org/x/net v0.61.0 // indirect golang.org/x/sync v0.24.0 // indirect golang.org/x/text v0.43.0 // indirect golang.org/x/time v0.16.0 // indirect - golang.org/x/tools v0.51.0 // indirect + golang.org/x/tools v0.52.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect golang.zx2c4.com/wireguard v0.0.0-20261006164505-2631ce99a06f // indirect golang.zx2c4.com/wireguard/windows v1.1.1 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20261005182115-fad411399dd8 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20261012220534-4341edf3f941 // indirect google.golang.org/grpc v1.84.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect - gvisor.dev/gvisor v0.0.0-20261010064756-cd110d9dec04 // indirect + gvisor.dev/gvisor v0.0.0-20261017120855-9e9495997e29 // indirect layeh.com/gopher-luar v1.0.11 // indirect lukechampine.com/blake3 v1.4.1 // indirect ) diff --git a/go.sum b/go.sum index c1e9cc21..11c3b31a 100644 --- a/go.sum +++ b/go.sum @@ -1,12 +1,10 @@ github.com/andybalholm/brotli v1.2.6 h1:ftYnfj6usCp+UGV5kSJ3+chpMQgU+gJf/AxsUQ52REI= github.com/andybalholm/brotli v1.2.6/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= -github.com/apernet/quic-go v0.61.1-0.20260806010916-184d081eef3e h1:5mgtR5gwIgBKMiGI1QdXldZZ+SNor06Nbu1wCBulQBg= -github.com/apernet/quic-go v0.61.1-0.20260806010916-184d081eef3e/go.mod h1:x7qxEvX6MCVtDuBKHj3E+88+BtrbEMuAL5qGUKItjW8= +github.com/apernet/quic-go v0.63.1-0.20261004180939-a10df75c260c h1:cxK8qTA0YCsj68A7zLYDCfPgMTOyt3ePkHh+oXEmYU0= +github.com/apernet/quic-go v0.63.1-0.20261004180939-a10df75c260c/go.mod h1:J7UZVOMF2M1LiE3iiUZWPuy8YCPAcLJOwVURrfJCvc0= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= -github.com/cloudflare/circl v1.6.5 h1:O64F26HEqNhznd/hrC5KZXVKYuKM2rx4deZDTc4ihQA= -github.com/cloudflare/circl v1.6.5/go.mod h1:h5LNyxAc5nTue9DS5jT+48en2PSDYt3zdGnz5OstK6c= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344 h1:Arcl6UOIS/kgO2nW3A65HN+7CMjSDP/gofXL4CZt1V4= github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344/go.mod h1:GIjDIg/heH5DOkXY3YJ/wNhfHsQHoXGjl8G8amsYQ1I= @@ -14,8 +12,6 @@ github.com/golang/mock v1.7.0-rc.1 h1:YojYx61/OLFsiv6Rw1Z96LpldJIy31o+UHmwAUMJ6/ github.com/golang/mock v1.7.0-rc.1/go.mod h1:s42URUywIqd+OcERslBJvOjepvNymP31m3q8d/GkuRs= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= -github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -28,10 +24,10 @@ github.com/jackpal/go-nat-pmp v1.1.0 h1:UInMLPV1VQdP860ggNiz0YxGvJH/bWzxL099y+1E github.com/jackpal/go-nat-pmp v1.1.0/go.mod h1:m9o4DK1wHA4h2pPpErD5vwzWLf91tJcfNQ3QyUIbh5A= github.com/juju/ratelimit v1.0.2 h1:sRxmtRiajbvrcLQT7S+JbqU0ntsb9W2yhSdNN8tWfaI= github.com/juju/ratelimit v1.0.2/go.mod h1:qapgC/Gy+xNh9UxzV13HGGl/6UXNN+ct+vwSgWNm/qk= -github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= -github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= -github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw= -github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU= +github.com/klauspost/compress v1.20.3 h1:zWNoFqq/lSuYQRim3dUXlXeA0IqwuZGicdG5ZTfY5j4= +github.com/klauspost/compress v1.20.3/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/klauspost/cpuid/v2 v2.4.1 h1:I9KxadAkvo+QjtcEE2f2tyfEvmIla/T0m+14PrZNl0U= +github.com/klauspost/cpuid/v2 v2.4.1/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU= github.com/koron/go-ssdp v0.9.1 h1:zvxbAAuJftJIZ8Jh8mda+LI7V92hYZf/sKprmOxpxwA= github.com/koron/go-ssdp v0.9.1/go.mod h1:C43c047jWkDaeg9YuZlSh/QGqOieuWV6dbhWi/jcaLk= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -54,8 +50,8 @@ github.com/miekg/dns v1.1.73 h1:uhT8nJxmTrPJYClxVxTCX+CVn6qnzSiybRk72Z6DgrE= github.com/miekg/dns v1.1.73/go.mod h1:RW2Obtfd5NZHvOFe3zYG0W8koWOQtAzyHaLo8vASBuQ= github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= -github.com/pion/dtls/v3 v3.1.10 h1:HWC+QCZitP/ApADS/6+g7UIw2YmLgoK3CsynnjPJgMo= -github.com/pion/dtls/v3 v3.1.10/go.mod h1:iKFQNYrjsN2TiA2YKKMqB9MOZaFpjFULBI/A4sW0eyc= +github.com/pion/dtls/v3 v3.1.14 h1:GYLEMc4IKGL29lWRJux6zUf0XwwPxlsZ7RUlGk8DLsI= +github.com/pion/dtls/v3 v3.1.14/go.mod h1:iKFQNYrjsN2TiA2YKKMqB9MOZaFpjFULBI/A4sW0eyc= github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8= github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so= github.com/pion/stun/v3 v3.1.7 h1:uRXMTlGLf89WgItGNyZ6aR5jMTX0NBbybXADpQCzn+E= @@ -66,6 +62,7 @@ github.com/pion/transport/v5 v5.1.1 h1:Sauq8YJqRopgRfIgnpfKpdpKG14g0N4jWlRgpWsEf github.com/pion/transport/v5 v5.1.1/go.mod h1:Qxw6fCEjFWQkRDZOhS4Vf+neJBcihauvA3uyEa1J1F0= github.com/pires/go-proxyproto v0.15.0 h1:dTshmNbFm/D+0+sbrxUuddPOZ5Y0B7c5NhtsBkm6LqI= github.com/pires/go-proxyproto v0.15.0/go.mod h1:OXsCrKwrK2tXS9YrI5tkHx5xaQlO8FH3lFW76orFh24= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0= github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= @@ -74,8 +71,8 @@ github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af h1:er github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= -github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= -github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0= @@ -84,10 +81,10 @@ github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zd github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= -github.com/xtls/reality v0.0.0-20260921001439-3c98159dee38 h1:Q5KwiMm+WRyw2kwPW4+fIQxNio1CyrWo/eAEaAHxl/Q= -github.com/xtls/reality v0.0.0-20260921001439-3c98159dee38/go.mod h1:/YQ6FwmAtkDuo8K3ujKQH1Br5eGNFauBipdl1gdiebk= -github.com/xtls/xray-core v1.260327.1-0.20261010092107-701af60772cd h1:Am/9Pzw5PZr7+hMN1Vs5faeSXRoutnrwMAgtUDnh/J8= -github.com/xtls/xray-core v1.260327.1-0.20261010092107-701af60772cd/go.mod h1:YAACF9L/Pu1GznixHOBtrBqVcE95nhFjgU+gIEdWKlo= +github.com/xtls/reality v0.0.0-20261014101318-83f621788ca8 h1:RjLnVvlzK4q6zWaSBPNRGsrgx2fbJDeUWffjw574uzs= +github.com/xtls/reality v0.0.0-20261014101318-83f621788ca8/go.mod h1:ptlSkJ8k/7IDauNH0SA96JPOo+nYGLTv2Y3UMpwvAsY= +github.com/xtls/xray-core v1.260327.1-0.20261017152356-92ccc939aaf4 h1:FlGykoNEWRZN5ExwxSEU5lnuEWU5CD3SJFyKKWLChl8= +github.com/xtls/xray-core v1.260327.1-0.20261017152356-92ccc939aaf4/go.mod h1:K9/USkul3qakG3i/i53FcMEmSBzc8YScZMa5vPZZtOg= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yuin/gopher-lua v0.0.0-20190206043414-8bfc7677f583/go.mod h1:gqRgreBUhTSL0GeU64rtZ3Uq3wtjOa/TB2YfrtkCbVQ= @@ -101,12 +98,12 @@ go4.org/netipx v0.0.0-20260823151212-3075585bcbeb h1:XBM4hvfwGAttkkiTIFfeigdfcL1 go4.org/netipx v0.0.0-20260823151212-3075585bcbeb/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= -golang.org/x/crypto/x509roots/fallback v0.0.0-20261009200856-99e4382b128e h1:jsB4A6J5vF93GeMqp72mLAOcFfGXgHtxpgeLk7OJB4M= -golang.org/x/crypto/x509roots/fallback v0.0.0-20261009200856-99e4382b128e/go.mod h1:HPze8vhfG6fO06AM+VSvxRm4E3+5Yk375mgrJ5M2z1E= -golang.org/x/exp v0.0.0-20261009195045-ca0d7ba23607 h1:o259es6R0mYtmCeID0xv+dkp5kQSf6qp8zs0j4MdUaI= -golang.org/x/exp v0.0.0-20261009195045-ca0d7ba23607/go.mod h1:SRjTzy5WcYqRRqquLUvHeeovSwLO0CZ3xcltZNO4Fck= -golang.org/x/mobile v0.0.0-20260908204917-8b95e45f8d3e h1:zYnHsDmxfxKH/x9DjYNAdoIdMsP8enB1Ncb10b3pdHk= -golang.org/x/mobile v0.0.0-20260908204917-8b95e45f8d3e/go.mod h1:+g6JKqLjAprgkdZKuuBhkhIN9zvSktraeSABDN2M7p8= +golang.org/x/crypto/x509roots/fallback v0.0.0-20261016151342-0ae6809b7559 h1:M68k3yCPiXhrSyh9Twe3Kv7nOsvUumZDN/3kdSwUqw0= +golang.org/x/crypto/x509roots/fallback v0.0.0-20261016151342-0ae6809b7559/go.mod h1:HPze8vhfG6fO06AM+VSvxRm4E3+5Yk375mgrJ5M2z1E= +golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e h1:HgI6W1OolKK6tcV74is+rl9SMT0njsHlp0TbQiGXI/A= +golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e/go.mod h1:DBoxwWhjaW8DJJw/YeM4zpeOKMC1FMXSblsYy+1mFSw= +golang.org/x/mobile v0.0.0-20261011034528-197770444867 h1:rav73cdd7OQUPgt1aIFbZQTiPNnjfueKXvfOiLCY0Ss= +golang.org/x/mobile v0.0.0-20261011034528-197770444867/go.mod h1:HR/iBzuQmVTl/us733QPwrqHp43a8m9ZLgyKXD3ST6U= golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg= golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs= golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw= @@ -123,8 +120,8 @@ golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE= golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s= -golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI= -golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA= +golang.org/x/tools v0.52.0 h1:ryxg4U63oaUYk7uOLTF37n43f4qgtGvqV4Ioit1BaEs= +golang.org/x/tools v0.52.0/go.mod h1:ItcP5UNaVGG+Yc4eIYmaFLNJ4soEopkx5Y2d4lcEurs= golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= @@ -137,8 +134,8 @@ golang.zx2c4.com/wireguard/windows v1.1.1 h1:8/H97U1v1PNDNcBsMZgU3KFuND9MQdTsU2N golang.zx2c4.com/wireguard/windows v1.1.1/go.mod h1:+fbT3FFdX4zzYDLwJh5+HPEcNN/3HyNdzhNSVsQM+zs= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20261005182115-fad411399dd8 h1:0SXBqli5dnuCKMjI/X3FM226zEaEa0GUcSCJtN1vL2o= -google.golang.org/genproto/googleapis/rpc v0.0.0-20261005182115-fad411399dd8/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20261012220534-4341edf3f941 h1:QG6hS4zFplRZvOQedzei4t2pT/zkQc3VR1u+S9/rcAw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20261012220534-4341edf3f941/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc= google.golang.org/grpc v1.84.0 h1:soMyaPJ8pAak5PIQ0DGBUir0XRo2fRoMqhNWMLlLxO0= google.golang.org/grpc v1.84.0/go.mod h1:ljCht0DrxQrXBDRTZp52Qxh3Ffk8CdYm2sj4O2QN2C0= google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= @@ -149,8 +146,8 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= -gvisor.dev/gvisor v0.0.0-20261010064756-cd110d9dec04 h1:iVC12KE9T4FE4+ygenM2M0GGtZ5RLIGrXmY9T5UPdZk= -gvisor.dev/gvisor v0.0.0-20261010064756-cd110d9dec04/go.mod h1:LOvux1Z/goEJudXUp4E7WwCxxJeeMf5kVoprLEZvOHo= +gvisor.dev/gvisor v0.0.0-20261017120855-9e9495997e29 h1:78i+xrM/m4yMyXrQfZAceZuxZMiLaR3N+qQcttsRb5w= +gvisor.dev/gvisor v0.0.0-20261017120855-9e9495997e29/go.mod h1:d8j5eTxmSBFBRPmPqa/Ua4njH0ZXjx40fsTq9ximTD8= layeh.com/gopher-luar v1.0.11 h1:8zJudpKI6HWkoh9eyyNFaTM79PY6CAPcIr6X/KTiliw= layeh.com/gopher-luar v1.0.11/go.mod h1:TPnIVCZ2RJBndm7ohXyaqfhzjlZ+OA2SZR/YwL8tECk= lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= diff --git a/readme/README.zh_CN.md b/readme/README.zh_CN.md index b006640b..54235df8 100644 --- a/readme/README.zh_CN.md +++ b/readme/README.zh_CN.md @@ -16,7 +16,7 @@ 依赖 git 和 go。 -默认情况下,编译脚本不会 clone [Xray-core](https://github.com/XTLS/Xray-core),而是通过 Go modules 的 pseudo-version 将 Xray-core 固定到发布版本 `v26.10.10`。 +默认情况下,编译脚本不会 clone [Xray-core](https://github.com/XTLS/Xray-core),而是通过 Go modules 的 pseudo-version 将 Xray-core 固定到发布版本 `v26.10.17`。 传入可选参数 `local` 时,会通过 Go module `replace` 改用已有的本地仓库 `../Xray-core`。 ### 使用方式 From dbe95129fa96689c4643ddca52c278448eec6df1 Mon Sep 17 00:00:00 2001 From: yiguodev <147401898+yiguodev@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:01:56 +0800 Subject: [PATCH 34/38] chore: update Xray-core to v26.10.21 (#167) --- README.md | 2 +- build/app/build.py | 4 ++-- go.mod | 4 ++-- go.sum | 8 ++++---- readme/README.zh_CN.md | 2 +- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index dd35db93..24c23344 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ Compile script. It is recommended to always use this script to compile libXray. depends on git and go. -By default, the build script does not clone [Xray-core](https://github.com/XTLS/Xray-core). It uses Go modules and pins Xray-core to release tag `v26.10.17` through its pseudo-version. +By default, the build script does not clone [Xray-core](https://github.com/XTLS/Xray-core). It uses Go modules and pins Xray-core to release tag `v26.10.21` through its pseudo-version. Pass the optional `local` argument to use an existing local checkout at `../Xray-core` through a Go module `replace`. ### Usage diff --git a/build/app/build.py b/build/app/build.py index b2562525..6dd37c8b 100644 --- a/build/app/build.py +++ b/build/app/build.py @@ -9,8 +9,8 @@ LIBXRAY_MOD_NAME = "github.com/xtls/libxray" XRAY_CORE_MOD_NAME = "github.com/xtls/xray-core" -# Go modules resolve the Xray-core v26.10.17 release tag through this version. -DEFAULT_XRAY_CORE_VERSION = "v1.260327.1-0.20261017152356-92ccc939aaf4" +# Go modules resolve the Xray-core v26.10.21 release tag through this version. +DEFAULT_XRAY_CORE_VERSION = "v1.260327.1-0.20261021115930-1b1647cd9ddf" LOCAL_XRAY_CORE_DIR_NAME = "Xray-core" diff --git a/go.mod b/go.mod index 08082dd7..edf22e10 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.27.2 require ( github.com/metacubex/age v0.0.0-20260603010618-28d156b4ea78 github.com/stretchr/testify v1.12.1 - github.com/xtls/xray-core v1.260327.1-0.20261017152356-92ccc939aaf4 + github.com/xtls/xray-core v1.260327.1-0.20261021115930-1b1647cd9ddf golang.org/x/mobile v0.0.0-20261011034528-197770444867 golang.org/x/sys v0.49.0 google.golang.org/protobuf v1.36.12 @@ -48,7 +48,7 @@ require ( go.yaml.in/yaml/v3 v3.0.5 // indirect go4.org/netipx v0.0.0-20260823151212-3075585bcbeb // indirect golang.org/x/crypto v0.58.0 // indirect - golang.org/x/crypto/x509roots/fallback v0.0.0-20261016151342-0ae6809b7559 // indirect + golang.org/x/crypto/x509roots/fallback v0.0.0-20261020153306-9f2b28d4331a // indirect golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e // indirect golang.org/x/mod v0.42.0 // indirect golang.org/x/net v0.61.0 // indirect diff --git a/go.sum b/go.sum index 11c3b31a..4070cb20 100644 --- a/go.sum +++ b/go.sum @@ -83,8 +83,8 @@ github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= github.com/xtls/reality v0.0.0-20261014101318-83f621788ca8 h1:RjLnVvlzK4q6zWaSBPNRGsrgx2fbJDeUWffjw574uzs= github.com/xtls/reality v0.0.0-20261014101318-83f621788ca8/go.mod h1:ptlSkJ8k/7IDauNH0SA96JPOo+nYGLTv2Y3UMpwvAsY= -github.com/xtls/xray-core v1.260327.1-0.20261017152356-92ccc939aaf4 h1:FlGykoNEWRZN5ExwxSEU5lnuEWU5CD3SJFyKKWLChl8= -github.com/xtls/xray-core v1.260327.1-0.20261017152356-92ccc939aaf4/go.mod h1:K9/USkul3qakG3i/i53FcMEmSBzc8YScZMa5vPZZtOg= +github.com/xtls/xray-core v1.260327.1-0.20261021115930-1b1647cd9ddf h1:Eqz6sj4Up2NtymyVY2UtXNpHZqJGrMwKsXnGOySyUNI= +github.com/xtls/xray-core v1.260327.1-0.20261021115930-1b1647cd9ddf/go.mod h1:rbK8dL0A1f4uYpNa3ljvB1U5qrcb4KQ4Pxb8yGdWZXo= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yuin/gopher-lua v0.0.0-20190206043414-8bfc7677f583/go.mod h1:gqRgreBUhTSL0GeU64rtZ3Uq3wtjOa/TB2YfrtkCbVQ= @@ -98,8 +98,8 @@ go4.org/netipx v0.0.0-20260823151212-3075585bcbeb h1:XBM4hvfwGAttkkiTIFfeigdfcL1 go4.org/netipx v0.0.0-20260823151212-3075585bcbeb/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= -golang.org/x/crypto/x509roots/fallback v0.0.0-20261016151342-0ae6809b7559 h1:M68k3yCPiXhrSyh9Twe3Kv7nOsvUumZDN/3kdSwUqw0= -golang.org/x/crypto/x509roots/fallback v0.0.0-20261016151342-0ae6809b7559/go.mod h1:HPze8vhfG6fO06AM+VSvxRm4E3+5Yk375mgrJ5M2z1E= +golang.org/x/crypto/x509roots/fallback v0.0.0-20261020153306-9f2b28d4331a h1:V5oicZXS0oqZx30gY/apgSVD7YvNBfrIOdcMnlelJY4= +golang.org/x/crypto/x509roots/fallback v0.0.0-20261020153306-9f2b28d4331a/go.mod h1:HPze8vhfG6fO06AM+VSvxRm4E3+5Yk375mgrJ5M2z1E= golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e h1:HgI6W1OolKK6tcV74is+rl9SMT0njsHlp0TbQiGXI/A= golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e/go.mod h1:DBoxwWhjaW8DJJw/YeM4zpeOKMC1FMXSblsYy+1mFSw= golang.org/x/mobile v0.0.0-20261011034528-197770444867 h1:rav73cdd7OQUPgt1aIFbZQTiPNnjfueKXvfOiLCY0Ss= diff --git a/readme/README.zh_CN.md b/readme/README.zh_CN.md index 54235df8..8f58be7e 100644 --- a/readme/README.zh_CN.md +++ b/readme/README.zh_CN.md @@ -16,7 +16,7 @@ 依赖 git 和 go。 -默认情况下,编译脚本不会 clone [Xray-core](https://github.com/XTLS/Xray-core),而是通过 Go modules 的 pseudo-version 将 Xray-core 固定到发布版本 `v26.10.17`。 +默认情况下,编译脚本不会 clone [Xray-core](https://github.com/XTLS/Xray-core),而是通过 Go modules 的 pseudo-version 将 Xray-core 固定到发布版本 `v26.10.21`。 传入可选参数 `local` 时,会通过 Go module `replace` 改用已有的本地仓库 `../Xray-core`。 ### 使用方式 From 9c5630421d18311902ac1a19854a280878813af9 Mon Sep 17 00:00:00 2001 From: Amir Ar <275349426+theZebco@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:15:50 +0330 Subject: [PATCH 35/38] test: runXray/stopXray keep the failover with the core; the config's env reaches geodata --- invoke_lifecycle_test.go | 157 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 invoke_lifecycle_test.go diff --git a/invoke_lifecycle_test.go b/invoke_lifecycle_test.go new file mode 100644 index 00000000..ef69d021 --- /dev/null +++ b/invoke_lifecycle_test.go @@ -0,0 +1,157 @@ +package libXray + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync" + "testing" + + "github.com/xtls/libxray/failover" + "github.com/xtls/libxray/xray" +) + +// A balancer over a dead (blackhole) and a live (freedom) outbound, as the +// failover package's own tests use. +const lifecycleTestConfig = `{ + "log": {"loglevel": "none"}, + "outbounds": [ + {"tag": "proxy-00", "protocol": "blackhole"}, + {"tag": "proxy-01", "protocol": "freedom"} + ], + "routing": {"balancers": [{"tag": "smart", "selector": ["proxy-"], "strategy": {"type": "random"}}]} +}` + +func failoverSettingsForTest(t *testing.T) json.RawMessage { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + t.Cleanup(srv.Close) + raw, err := json.Marshal(failover.Settings{ + BalancerTag: "smart", + Order: []string{"proxy-00", "proxy-01"}, + ProbeURL: srv.URL + "/generate_204", + }) + if err != nil { + t.Fatal(err) + } + return raw +} + +func failoverRunningForTest(t *testing.T) bool { + t.Helper() + response := invokeForTest(t, LibXrayMethodGetFailoverState, nil) + if !response.Success { + t.Fatalf("getFailoverState failed: %s", response.Err) + } + var state failover.State + if err := json.Unmarshal(response.Data, &state); err != nil { + t.Fatal(err) + } + return state.Running +} + +func TestInvokeRunXrayStartsFailover(t *testing.T) { + response := invokeForTest(t, LibXrayMethodRunXray, RunXrayRequest{ + XrayJson: lifecycleTestConfig, + Failover: failoverSettingsForTest(t), + }) + defer xrayStopForTest(t) + if !response.Success { + t.Fatalf("runXray failed: %s", response.Err) + } + if !failoverRunningForTest(t) { + t.Fatal("runXray with failover settings left no controller running") + } +} + +func TestInvokeRefusedDuplicateRunXrayKeepsFailover(t *testing.T) { + settings := failoverSettingsForTest(t) + first := invokeForTest(t, LibXrayMethodRunXray, RunXrayRequest{XrayJson: lifecycleTestConfig, Failover: settings}) + defer xrayStopForTest(t) + if !first.Success { + t.Fatalf("runXray failed: %s", first.Err) + } + duplicate := invokeForTest(t, LibXrayMethodRunXray, RunXrayRequest{XrayJson: lifecycleTestConfig}) + if duplicate.Success || duplicate.Err != xray.ErrAlreadyRunning.Error() { + t.Fatalf("duplicate runXray: success=%v error=%q", duplicate.Success, duplicate.Err) + } + if !failoverRunningForTest(t) { + t.Fatal("a refused duplicate runXray stopped the running tunnel's failover") + } +} + +// Two starts and a stop at once, in any order: afterwards the controller runs +// exactly when the core does. Without the lifecycle lock a stop can land +// between RunXray and failover.Start and leave a controller on a closed core. +func TestInvokeConcurrentRunAndStopKeepFailoverWithTheCore(t *testing.T) { + payload, err := json.Marshal(RunXrayRequest{XrayJson: lifecycleTestConfig, Failover: failoverSettingsForTest(t)}) + if err != nil { + t.Fatal(err) + } + runJSON, err := json.Marshal(&LibXrayInvokeRequest{APIVersion: LibXrayAPIVersion, Method: LibXrayMethodRunXray, Payload: payload}) + if err != nil { + t.Fatal(err) + } + stopJSON, err := json.Marshal(&LibXrayInvokeRequest{APIVersion: LibXrayAPIVersion, Method: LibXrayMethodStopXray}) + if err != nil { + t.Fatal(err) + } + for round := 0; round < 10; round++ { + var wg sync.WaitGroup + for _, request := range []string{string(runJSON), string(runJSON), string(stopJSON)} { + wg.Add(1) + go func() { + defer wg.Done() + Invoke(request) + }() + } + wg.Wait() + if core, controller := xray.GetXrayState(), failoverRunningForTest(t); core != controller { + t.Fatalf("round %d: core running=%v, failover running=%v", round, core, controller) + } + xrayStopForTest(t) + } +} + +// The app sends its asset directory in the config's root env (Invoke's own +// env is gone). It must be applied before the routing rules load geodata — +// proven with a geosite rule that only the env can resolve. +func TestInvokeRunXrayConfigEnvLocatesGeodata(t *testing.T) { + const assetKey = "xray.location.asset" + t.Setenv(assetKey, "") + datDir := t.TempDir() + writeGeoSiteDatForTest(t, filepath.Join(datDir, "geosite.dat")) + config := func(env map[string]string) string { + t.Helper() + raw, err := json.Marshal(map[string]any{ + "env": env, + "log": map[string]string{"loglevel": "none"}, + "outbounds": []any{map[string]string{"tag": "direct", "protocol": "freedom"}}, + "routing": map[string]any{"rules": []any{ + map[string]any{"domain": []string{"geosite:test"}, "outboundTag": "direct"}, + }}, + }) + if err != nil { + t.Fatal(err) + } + return string(raw) + } + + without := invokeForTest(t, LibXrayMethodRunXray, RunXrayRequest{XrayJson: config(nil)}) + if without.Success { + xrayStopForTest(t) + t.Fatal("a geosite rule loaded with no asset location: the test proves nothing") + } + with := invokeForTest(t, LibXrayMethodRunXray, RunXrayRequest{XrayJson: config(map[string]string{assetKey: datDir})}) + defer xrayStopForTest(t) + if !with.Success { + t.Fatalf("runXray with the asset location in the config's env failed: %s", with.Err) + } + if got := os.Getenv(assetKey); got != datDir { + t.Fatalf("%s = %q, want %q", assetKey, got, datDir) + } +} From 02a9e7f4a2626757b7ff0eb88f093520b0c6ba5c Mon Sep 17 00:00:00 2001 From: Amir Ar <275349426+theZebco@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:15:51 +0330 Subject: [PATCH 36/38] xray: Instance() under the core's lock --- xray/instance_test.go | 32 ++++++++++++++++++++++++++++++++ xray/xray.go | 5 +++++ 2 files changed, 37 insertions(+) create mode 100644 xray/instance_test.go diff --git a/xray/instance_test.go b/xray/instance_test.go new file mode 100644 index 00000000..0924de7d --- /dev/null +++ b/xray/instance_test.go @@ -0,0 +1,32 @@ +package xray + +import "testing" + +// Instance is read by the failover and the probe while the tunnel starts and +// stops. Run with -race (validate.yml does): an unlocked read of the core +// fails here. +func TestInstanceRacesRunAndStop(t *testing.T) { + const config = `{"log": {"loglevel": "none"}, "outbounds": [{"protocol": "freedom"}]}` + done := make(chan struct{}) + go func() { + defer close(done) + for i := 0; i < 20; i++ { + if err := RunXray(config); err != nil { + t.Error(err) + return + } + if err := StopXray(); err != nil { + t.Error(err) + return + } + } + }() + for { + select { + case <-done: + return + default: + _ = Instance() + } + } +} diff --git a/xray/xray.go b/xray/xray.go index 1ea7f0cb..80534886 100644 --- a/xray/xray.go +++ b/xray/xray.go @@ -63,7 +63,12 @@ func GetXrayState() bool { } // Instance is the running core, or nil — for components that attach to it. +// RunXray holds coreServerMu across the core's construction and start, so +// this must never be reached from code the core runs while starting (log +// handlers, dialer or observatory callbacks). func Instance() *core.Instance { + coreServerMu.Lock() + defer coreServerMu.Unlock() return coreServer } From c019082cdcf22a371723e3ba8909748228166f9b Mon Sep 17 00:00:00 2001 From: Amir Ar <275349426+theZebco@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:17:44 +0330 Subject: [PATCH 37/38] ci: Go 1.27.3 enforced on what we build; gomobile pinned Xray-core v26.10.21's certificate fix needs the binaries built with Go 1.27.3. go.mod's go line is now that floor and every setup-go reads it (setup-go sets GOTOOLCHAIN=local, so the go command refuses an older toolchain). Each build leg also checks its own artifacts with `go version -m` - the Go version and the xray-core version go.mod requires - and the Windows DLL must import no C++ runtime. Android's gomobile comes from go.mod instead of @latest. --- .github/workflows/build.yml | 25 ++++++- .github/workflows/libxray-test.yml | 3 +- .github/workflows/validate.yml | 8 +- build/test_verify_core.py | 71 ++++++++++++++++++ build/verify_core.py | 113 +++++++++++++++++++++++++++++ go.mod | 2 +- 6 files changed, 215 insertions(+), 7 deletions(-) create mode 100644 build/test_verify_core.py create mode 100644 build/verify_core.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 39f30642..8eccecd2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -66,11 +66,13 @@ jobs: # ========================= # Go setup # ========================= + # go.mod's `go` line is the floor: Xray-core v26.10.21's certificate fix + # needs Go 1.27.3, and setup-go's GOTOOLCHAIN=local makes the go command + # refuse anything older. - name: Setup Go uses: actions/setup-go@v7 with: - go-version: stable - check-latest: true + go-version-file: go.mod - name: Download Go modules run: go mod download @@ -177,6 +179,8 @@ jobs: env: ANDROID_NDK_HOME: ${{ steps.ndk.outputs.ndk-path }} run: | + # gomobile at the version go.mod pins, not whatever @latest is today. + export LIBXRAY_GOMOBILE_VERSION="$(go list -m -f '{{.Version}}' golang.org/x/mobile)" python3 build/main.py android - name: Test Windows desktop Core @@ -223,6 +227,23 @@ jobs: run: | python3 build/main.py apple go + # The certificate fix rides on both the core and the toolchain: check + # what was built, not what the runner says it has. A clean Windows 10 + # has no C++ runtime, so the DLL must not import one. + - name: Verify the built core + if: matrix.target == 'linux' || matrix.target == 'windows' || matrix.target == 'android' + shell: bash + run: | + set -e + case "${{ matrix.target }}" in + linux) python build/verify_core.py linux_so/libXray.so ;; + windows) + python -m pip install pefile==2024.8.26 + python build/verify_core.py --no-cxx-runtime windows_dll/libXray.dll + ;; + android) python build/verify_core.py libXray.aar ;; + esac + # ========================= # Collect artifacts # ========================= diff --git a/.github/workflows/libxray-test.yml b/.github/workflows/libxray-test.yml index 7a7eb1b8..6831cd06 100644 --- a/.github/workflows/libxray-test.yml +++ b/.github/workflows/libxray-test.yml @@ -16,8 +16,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v7 with: - go-version: stable - check-latest: true + go-version-file: go.mod cache-dependency-path: go.sum - name: Go test diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index c43c4933..ba6a70b2 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -20,8 +20,7 @@ jobs: - uses: actions/setup-go@v7 with: - go-version: stable - check-latest: true + go-version-file: go.mod - name: Setup Python uses: actions/setup-python@v7 @@ -45,6 +44,11 @@ jobs: nm -D linux_so/libXray.so | grep -q ' CGoFree$' ./bin/xray -h + - name: Verify the built core + run: | + python3 build/test_verify_core.py + python3 build/verify_core.py linux_so/libXray.so + - name: Verify source was restored run: git diff --exit-code diff --git a/build/test_verify_core.py b/build/test_verify_core.py new file mode 100644 index 00000000..1c977350 --- /dev/null +++ b/build/test_verify_core.py @@ -0,0 +1,71 @@ +"""Tests for build/verify_core.py's checks. + + python3 build/test_verify_core.py +""" +import unittest + +import verify_core + +GO_MOD = """module github.com/xtls/libxray + +go 1.27.3 + +require ( + github.com/metacubex/age v0.0.0-20260603010618-28d156b4ea78 + github.com/xtls/xray-core v1.260327.1-0.20261021115930-1b1647cd9ddf + golang.org/x/mobile v0.0.0-20261011034528-197770444867 +) +""" + +XRAY_CORE = "v1.260327.1-0.20261021115930-1b1647cd9ddf" + + +def report(go: str, xray_core: str = XRAY_CORE) -> str: + return ( + f"linux_so/libXray.so: go{go}\n" + "\tpath\tgithub.com/xtls/libxray/cgo_bridge\n" + "\tmod\tgithub.com/xtls/libxray\t(devel)\t\n" + f"\tdep\tgithub.com/xtls/xray-core\t{xray_core}\th1:abc=\n" + "\tbuild\t-buildmode=c-shared\n" + ) + + +class VerifyCoreTest(unittest.TestCase): + def test_go_mod_names_the_floor_and_the_core(self): + self.assertEqual(verify_core.parse_go_mod(GO_MOD), ((1, 27, 3), XRAY_CORE)) + + def test_a_single_line_require_is_read_too(self): + text = "module m\n\ngo 1.27.3\n\nrequire github.com/xtls/xray-core v1.2.3\n" + self.assertEqual(verify_core.parse_go_mod(text), ((1, 27, 3), "v1.2.3")) + + def test_go_mod_without_the_core_is_an_error(self): + with self.assertRaises(ValueError): + verify_core.parse_go_mod("module m\n\ngo 1.27.3\n") + + def test_the_floor_and_later_pass(self): + for go in ("1.27.3", "1.27.10", "1.28", "1.28.1"): + self.assertEqual(verify_core.problems("x", report(go), (1, 27, 3), XRAY_CORE), [], go) + + def test_older_go_fails_compared_as_numbers(self): + for go in ("1.27.2", "1.27", "1.26.9"): + found = verify_core.problems("x", report(go), (1, 27, 3), XRAY_CORE) + self.assertEqual(len(found), 1, go) + self.assertIn("needs go1.27.3 or later", found[0]) + + def test_another_core_fails(self): + found = verify_core.problems("x", report("1.27.3", "v1.260327.1-0.20260627131803-45cf2898ab12"), (1, 27, 3), XRAY_CORE) + self.assertEqual(len(found), 1) + self.assertIn("expected " + XRAY_CORE, found[0]) + + def test_no_build_information_fails(self): + found = verify_core.problems("x", "", (1, 27, 3), XRAY_CORE) + self.assertIn("x: no Go build information", found) + + def test_a_cxx_runtime_import_is_found(self): + names = ["KERNEL32.dll", "VCRUNTIME140.dll", "msvcrt.dll", "msvcp140.dll", "api-ms-win-crt-runtime-l1-1-0.dll"] + self.assertEqual(verify_core.cxx_runtime_imports(names), ["VCRUNTIME140.dll", "msvcp140.dll"]) + self.assertEqual(verify_core.cxx_runtime_imports(["KERNEL32.dll", "msvcrt.dll"]), []) + + +if __name__ == "__main__": + unittest.main() diff --git a/build/verify_core.py b/build/verify_core.py new file mode 100644 index 00000000..ccb5185d --- /dev/null +++ b/build/verify_core.py @@ -0,0 +1,113 @@ +"""Checks what a build produced, not the runner it ran on. + +Every Go binary in the given artifacts must be built with at least the Go +version go.mod's `go` line names, from the xray-core version go.mod requires: +Xray-core v26.10.21's certificate fix depends on both. An Android AAR is +checked through each of its jni/*/libgojni.so. With --no-cxx-runtime, a +Windows DLL must not import a C++ runtime, which a clean Windows 10 lacks. + + python build/verify_core.py linux_so/libXray.so + python build/verify_core.py --no-cxx-runtime windows_dll/libXray.dll + python build/verify_core.py libXray.aar +""" +import argparse +import re +import subprocess +import sys +import tempfile +import zipfile +from pathlib import Path + +GO_MOD = Path(__file__).resolve().parent.parent / "go.mod" +XRAY_CORE = "github.com/xtls/xray-core" + + +def version_tuple(version: str) -> tuple[int, ...]: + return tuple(int(part) for part in version.split(".")) + + +def version_text(version: tuple[int, ...]) -> str: + return ".".join(str(part) for part in version) + + +def parse_go_mod(text: str) -> tuple[tuple[int, ...], str]: + """The `go` line's version and the xray-core version go.mod requires.""" + go = re.search(r"^go\s+(\d+(?:\.\d+)*)\s*$", text, re.M) + xray = re.search(rf"^\s*(?:require\s+)?{re.escape(XRAY_CORE)}\s+(\S+)", text, re.M) + if not go or not xray: + raise ValueError("go.mod has no go line or no xray-core requirement") + return version_tuple(go.group(1)), xray.group(1) + + +def parse_version_m(text: str) -> tuple[tuple[int, ...] | None, str | None]: + """The Go version and xray-core version `go version -m` reports.""" + first = text.splitlines()[0] if text.strip() else "" + go = re.search(r":\s+go(\d+(?:\.\d+)*)", first) + dep = re.search(rf"^\s*dep\s+{re.escape(XRAY_CORE)}\s+(\S+)", text, re.M) + return (version_tuple(go.group(1)) if go else None, dep.group(1) if dep else None) + + +def cxx_runtime_imports(dll_names: list[str]) -> list[str]: + return sorted(name for name in dll_names if name.upper().startswith(("VCRUNTIME", "MSVCP"))) + + +def problems(binary: str, report: str, floor: tuple[int, ...], xray_core: str) -> list[str]: + go, dep = parse_version_m(report) + found = [] + if go is None: + found.append(f"{binary}: no Go build information") + elif go < floor: + found.append(f"{binary}: built with go{version_text(go)}, needs go{version_text(floor)} or later") + if dep != xray_core: + found.append(f"{binary}: xray-core {dep}, expected {xray_core}") + return found + + +def go_binaries(artifact: Path, workdir: Path) -> list[Path]: + if artifact.suffix != ".aar": + return [artifact] + with zipfile.ZipFile(artifact) as aar: + names = [n for n in aar.namelist() if n.startswith("jni/") and n.endswith("/libgojni.so")] + for name in names: + aar.extract(name, workdir) + if not names: + raise SystemExit(f"{artifact}: no jni/*/libgojni.so") + return [workdir / name for name in names] + + +def dll_imports(path: Path) -> list[str]: + import pefile # the Windows leg installs it + + pe = pefile.PE(str(path), fast_load=True) + pe.parse_data_directories(directories=[pefile.DIRECTORY_ENTRY["IMAGE_DIRECTORY_ENTRY_IMPORT"]]) + return [entry.dll.decode() for entry in getattr(pe, "DIRECTORY_ENTRY_IMPORT", [])] + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--no-cxx-runtime", action="store_true") + parser.add_argument("artifacts", nargs="+", type=Path) + args = parser.parse_args(argv) + floor, xray_core = parse_go_mod(GO_MOD.read_text()) + + found = [] + with tempfile.TemporaryDirectory() as tmp: + for artifact in args.artifacts: + for binary in go_binaries(artifact, Path(tmp)): + run = subprocess.run(["go", "version", "-m", str(binary)], capture_output=True, text=True) + report = run.stdout if run.returncode == 0 else "" + these = problems(str(binary), report, floor, xray_core) + found += these + if not these: + print(f"{binary}: go>={version_text(floor)}, xray-core {xray_core}") + if args.no_cxx_runtime and artifact.suffix.lower() == ".dll": + runtime = cxx_runtime_imports(dll_imports(artifact)) + if runtime: + found.append(f"{artifact}: imports a C++ runtime: {', '.join(runtime)}") + for problem in found: + print(problem, file=sys.stderr) + return 1 if found else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/go.mod b/go.mod index edf22e10..37f6c722 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/xtls/libxray -go 1.27.2 +go 1.27.3 require ( github.com/metacubex/age v0.0.0-20260603010618-28d156b4ea78 From 4b12779d080485af84dbd762687bb35e70f8cb5d Mon Sep 17 00:00:00 2001 From: Amir Ar <275349426+theZebco@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:54:23 +0330 Subject: [PATCH 38/38] ci: the DLL check also refuses MinGW's runtime libraries and an unreadable import table --- .github/workflows/build.yml | 1 + build/test_verify_core.py | 12 ++++++++++++ build/verify_core.py | 24 +++++++++++++++++++----- 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8eccecd2..650296d7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -238,6 +238,7 @@ jobs: case "${{ matrix.target }}" in linux) python build/verify_core.py linux_so/libXray.so ;; windows) + # Pinned like the actions: the check must not change under us. python -m pip install pefile==2024.8.26 python build/verify_core.py --no-cxx-runtime windows_dll/libXray.dll ;; diff --git a/build/test_verify_core.py b/build/test_verify_core.py index 1c977350..36e45506 100644 --- a/build/test_verify_core.py +++ b/build/test_verify_core.py @@ -66,6 +66,18 @@ def test_a_cxx_runtime_import_is_found(self): self.assertEqual(verify_core.cxx_runtime_imports(names), ["VCRUNTIME140.dll", "msvcp140.dll"]) self.assertEqual(verify_core.cxx_runtime_imports(["KERNEL32.dll", "msvcrt.dll"]), []) + def test_mingw_runtime_libraries_are_found_too(self): + names = ["KERNEL32.dll", "libwinpthread-1.dll", "libgcc_s_seh-1.dll", "libstdc++-6.dll"] + self.assertEqual( + verify_core.cxx_runtime_imports(names), ["libgcc_s_seh-1.dll", "libstdc++-6.dll", "libwinpthread-1.dll"] + ) + + def test_a_dll_with_only_system_imports_passes(self): + self.assertEqual(verify_core.dll_problems("libXray.dll", ["KERNEL32.dll", "msvcrt.dll", "WS2_32.dll"]), []) + + def test_no_imports_read_is_a_failure_not_a_pass(self): + self.assertEqual(verify_core.dll_problems("libXray.dll", []), ["libXray.dll: no imports could be read"]) + if __name__ == "__main__": unittest.main() diff --git a/build/verify_core.py b/build/verify_core.py index ccb5185d..b61155d9 100644 --- a/build/verify_core.py +++ b/build/verify_core.py @@ -47,8 +47,21 @@ def parse_version_m(text: str) -> tuple[tuple[int, ...] | None, str | None]: return (version_tuple(go.group(1)) if go else None, dep.group(1) if dep else None) +# Runtimes a clean Windows 10 lacks: MSVC's C++ runtime and MinGW's shared +# runtime libraries. +_FOREIGN_RUNTIMES = ("VCRUNTIME", "MSVCP", "LIBSTDC++", "LIBGCC_S", "LIBWINPTHREAD") + + def cxx_runtime_imports(dll_names: list[str]) -> list[str]: - return sorted(name for name in dll_names if name.upper().startswith(("VCRUNTIME", "MSVCP"))) + return sorted(name for name in dll_names if name.upper().startswith(_FOREIGN_RUNTIMES)) + + +def dll_problems(artifact: str, dll_names: list[str]) -> list[str]: + # A PE with no imports parsed means the parse failed, not a clean DLL. + if not dll_names: + return [f"{artifact}: no imports could be read"] + runtime = cxx_runtime_imports(dll_names) + return [f"{artifact}: imports a runtime a clean Windows lacks: {', '.join(runtime)}"] if runtime else [] def problems(binary: str, report: str, floor: tuple[int, ...], xray_core: str) -> list[str]: @@ -99,11 +112,12 @@ def main(argv: list[str] | None = None) -> int: these = problems(str(binary), report, floor, xray_core) found += these if not these: - print(f"{binary}: go>={version_text(floor)}, xray-core {xray_core}") + go, _ = parse_version_m(report) + print(f"{binary}: go{version_text(go)} (floor {version_text(floor)}), xray-core {xray_core}") if args.no_cxx_runtime and artifact.suffix.lower() == ".dll": - runtime = cxx_runtime_imports(dll_imports(artifact)) - if runtime: - found.append(f"{artifact}: imports a C++ runtime: {', '.join(runtime)}") + names = dll_imports(artifact) + found += dll_problems(str(artifact), names) + print(f"{artifact}: imports {', '.join(sorted(names))}") for problem in found: print(problem, file=sys.stderr) return 1 if found else 0