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)) +}