Skip to content

Commit e663ac1

Browse files
authored
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
1 parent 6f806fc commit e663ac1

21 files changed

Lines changed: 1303 additions & 64 deletions

README.md

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ Compile script. It is recommended to always use this script to compile libXray.
4040

4141
depends on git and go.
4242

43-
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.
43+
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.
4444
Pass the optional `local` argument to use an existing local checkout at `../Xray-core` through a Go module `replace`.
4545

4646
### Usage
@@ -161,6 +161,14 @@ Design notes:
161161
5. `convertShareLinksToXrayJson` validates each parsed outbound with the current
162162
Xray-core config builder. Invalid outbounds are omitted, and the method fails
163163
if none remain. Validation does not create or start an Xray instance.
164+
6. Xray-core keeps its system dialer DNS client and outbound manager in
165+
process-wide state. Creating another Xray instance through `ping`,
166+
`pingBatch`, `testXray`, or the exported Go APIs while `runXray` or
167+
`runXrayFromJson` is active may replace that state and affect the running
168+
instance. Closing the temporary instance does not restore the previous
169+
state. libXray does not serialize, isolate, or restore concurrent instances;
170+
callers that require overlapping instances must place them in separate
171+
processes.
164172

165173
Supported methods:
166174

@@ -170,6 +178,7 @@ convertShareLinksToXrayJson
170178
convertXrayJsonToShareLinks
171179
countGeoData
172180
ping
181+
pingBatch
173182
testXray
174183
runXray
175184
runXrayFromJson
@@ -282,6 +291,44 @@ Some tools used to parse shared links.
282291

283292
Latency testing.
284293

294+
### pingBatch
295+
296+
Tests multiple outbound configurations concurrently in one temporary Xray
297+
instance. Each config file is parsed only for its `outbounds`; all other root
298+
fields are ignored. The target outbound is selected by `outboundTag`, then by
299+
the `proxy` tag, and finally by the first outbound.
300+
301+
```json
302+
{
303+
"apiVersion": 1,
304+
"method": "pingBatch",
305+
"payload": {
306+
"configs": [
307+
{
308+
"configPath": "/path/to/node-1.json"
309+
},
310+
{
311+
"configPath": "/path/to/full-config.json",
312+
"outboundTag": "media"
313+
}
314+
],
315+
"timeout": 5,
316+
"url": "https://cp.cloudflare.com/"
317+
}
318+
}
319+
```
320+
321+
Each request accepts at most five configurations and tests all accepted
322+
configurations concurrently. Requests containing more than five configurations
323+
fail before any configuration is tested.
324+
325+
The top-level response succeeds when the batch itself was accepted. Each item
326+
has its own result; `delay` is `10000` for an error and `11000` for a timeout.
327+
The result array has the same length and order as the input config array.
328+
Outbound dependencies referenced by
329+
`streamSettings.sockopt.dialerProxy` or `proxySettings.tag` are included
330+
automatically.
331+
285332
### metrics
286333

287334
Refer to the following configuration:

android_wrapper.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,14 +37,14 @@ type ProcessFinder interface {
3737
}
3838

3939
func RegisterDialerController(controller DialerController) {
40-
c.RegisterDialerController(func(fd uintptr) {
41-
controller.ProtectFd(int(fd))
40+
c.RegisterDialerController(func(fd uintptr) bool {
41+
return controller.ProtectFd(int(fd))
4242
})
4343
}
4444

4545
func RegisterListenerController(controller DialerController) {
46-
c.RegisterListenerController(func(fd uintptr) {
47-
controller.ProtectFd(int(fd))
46+
c.RegisterListenerController(func(fd uintptr) bool {
47+
return controller.ProtectFd(int(fd))
4848
})
4949
}
5050

build/app/build.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88

99
LIBXRAY_MOD_NAME = "github.com/xtls/libxray"
1010
XRAY_CORE_MOD_NAME = "github.com/xtls/xray-core"
11-
# Go modules resolve the Xray-core v26.7.11 release tag through this version.
12-
DEFAULT_XRAY_CORE_VERSION = "v1.260327.1-0.20260711155151-50231eaff98c"
11+
# Go modules resolve the Xray-core v26.7.28 release tag through this version.
12+
DEFAULT_XRAY_CORE_VERSION = "v1.260327.1-0.20260728075948-5ca6f4b7d4dc"
1313
LOCAL_XRAY_CORE_DIR_NAME = "Xray-core"
1414

1515

controller/controller.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,20 @@
11
package controller
2+
3+
import (
4+
"errors"
5+
"syscall"
6+
)
7+
8+
var errProtectSocket = errors.New("protect Android VPN socket failed")
9+
10+
func protectSocket(conn syscall.RawConn, controller func(fd uintptr) bool) error {
11+
var protectErr error
12+
if err := conn.Control(func(fd uintptr) {
13+
if !controller(fd) {
14+
protectErr = errProtectSocket
15+
}
16+
}); err != nil {
17+
return err
18+
}
19+
return protectErr
20+
}

controller/controller_android.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,17 @@ import (
1212

1313
// RegisterDialerController -> callback before connection begins.
1414
// Depends on xray:api:beta
15-
func RegisterDialerController(controller func(fd uintptr)) {
15+
func RegisterDialerController(controller func(fd uintptr) bool) {
1616
xinternet.RegisterDialerController(func(network, address string, conn syscall.RawConn) error {
17-
return conn.Control(controller)
17+
return protectSocket(conn, controller)
1818
})
1919
}
2020

2121
// RegisterListenerController -> callback before listener begins.
2222
// Depends on xray:api:beta
23-
func RegisterListenerController(controller func(fd uintptr)) {
23+
func RegisterListenerController(controller func(fd uintptr) bool) {
2424
xinternet.RegisterListenerController(func(network, address string, conn syscall.RawConn) error {
25-
return conn.Control(controller)
25+
return protectSocket(conn, controller)
2626
})
2727
}
2828

controller/controller_test.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package controller
2+
3+
import (
4+
"errors"
5+
"testing"
6+
)
7+
8+
type rawConnStub struct {
9+
fd uintptr
10+
controlErr error
11+
}
12+
13+
func (conn rawConnStub) Control(callback func(fd uintptr)) error {
14+
if conn.controlErr != nil {
15+
return conn.controlErr
16+
}
17+
callback(conn.fd)
18+
return nil
19+
}
20+
21+
func (rawConnStub) Read(func(fd uintptr) (done bool)) error {
22+
return nil
23+
}
24+
25+
func (rawConnStub) Write(func(fd uintptr) (done bool)) error {
26+
return nil
27+
}
28+
29+
func TestProtectSocketPropagatesControllerFailure(t *testing.T) {
30+
err := protectSocket(rawConnStub{fd: 42}, func(fd uintptr) bool {
31+
return fd != 42
32+
})
33+
if !errors.Is(err, errProtectSocket) {
34+
t.Fatalf("protectSocket() error = %v, want %v", err, errProtectSocket)
35+
}
36+
}
37+
38+
func TestProtectSocketPropagatesRawConnFailure(t *testing.T) {
39+
controlErr := errors.New("control failed")
40+
err := protectSocket(
41+
rawConnStub{controlErr: controlErr},
42+
func(uintptr) bool { return true },
43+
)
44+
if !errors.Is(err, controlErr) {
45+
t.Fatalf("protectSocket() error = %v, want %v", err, controlErr)
46+
}
47+
}
48+
49+
func TestProtectSocketSucceeds(t *testing.T) {
50+
err := protectSocket(rawConnStub{fd: 42}, func(fd uintptr) bool {
51+
return fd == 42
52+
})
53+
if err != nil {
54+
t.Fatalf("protectSocket() error = %v", err)
55+
}
56+
}

go.mod

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ go 1.26.3
44

55
require (
66
github.com/stretchr/testify v1.11.1
7-
github.com/xtls/xray-core v1.260327.1-0.20260711155151-50231eaff98c
7+
github.com/xtls/xray-core v1.260327.1-0.20260728075948-5ca6f4b7d4dc
88
google.golang.org/protobuf v1.36.11
99
gopkg.in/yaml.v3 v3.0.1
1010
)
@@ -53,7 +53,7 @@ require (
5353
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect
5454
golang.zx2c4.com/wireguard/windows v1.0.1 // indirect
5555
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect
56-
google.golang.org/grpc v1.82.0 // indirect
56+
google.golang.org/grpc v1.82.1 // indirect
5757
gopkg.in/yaml.v2 v2.4.0 // indirect
5858
gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect
5959
lukechampine.com/blake3 v1.4.1 // indirect

go.sum

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,8 @@ github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
7474
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
7575
github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f h1:iy2JRioxmUpoJ3SzbFPyTxHZMbR/rSHP7dOOgYaq1O8=
7676
github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f/go.mod h1:DsJblcWDGt76+FVqBVwbwRhxyyNJsGV48gJLch0OOWI=
77-
github.com/xtls/xray-core v1.260327.1-0.20260711155151-50231eaff98c h1:SbB1ez0bqZllbzaVj0PC+Vje3dRA8m/7jW1ussjDSgM=
78-
github.com/xtls/xray-core v1.260327.1-0.20260711155151-50231eaff98c/go.mod h1:Jts8yHqPCpvsdL5CW5xMd8H9d2fkg1cILeBNqEwRXNw=
77+
github.com/xtls/xray-core v1.260327.1-0.20260728075948-5ca6f4b7d4dc h1:fkOkmgHWbF2Q8MdV9VxrsyxRz4OndcrUXUkh1ANBTg0=
78+
github.com/xtls/xray-core v1.260327.1-0.20260728075948-5ca6f4b7d4dc/go.mod h1:wukQoBGnQ6GaLTGuKwv8rCTgf80QxPj+6iznDZHQEWo=
7979
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
8080
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
8181
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
9898
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc=
9999
golang.org/x/mobile v0.0.0-20260709172247-6129f5bee9d5 h1:Mn1OzFmF0ZKX/ZayHz/UdnWHufPp1wlD9lZ5U8LRDFY=
100100
golang.org/x/mobile v0.0.0-20260709172247-6129f5bee9d5/go.mod h1:YX+n47s+53POxN3dx9cIGxG3hGUm/lD64hvrRJFbcSA=
101-
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
102-
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
103101
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
104102
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
105103
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=
114112
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
115113
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
116114
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
117-
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
118-
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
119115
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
120116
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
117+
golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM=
118+
golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
119+
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM=
120+
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8=
121121
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
122122
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
123123
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=
128128
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
129129
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw=
130130
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
131-
google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU=
132-
google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
131+
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
132+
google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
133133
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
134134
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
135135
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

invoke.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ func Invoke(requestJSON string) string {
4545
return invokeCountGeoData(request.Payload)
4646
case LibXrayMethodPing:
4747
return invokePing(request.Payload)
48+
case LibXrayMethodPingBatch:
49+
return invokePingBatch(request.Payload)
4850
case LibXrayMethodTestXray:
4951
return invokeTestXray(request.Payload)
5052
case LibXrayMethodRunXray:
@@ -178,6 +180,40 @@ func invokePing(payload json.RawMessage) string {
178180
return encodeInvokeResponse(&PingResponse{Delay: delay}, nil)
179181
}
180182

183+
func invokePingBatch(payload json.RawMessage) string {
184+
request, err := decodePayload[PingBatchRequest](payload)
185+
if err != nil {
186+
return encodeInvokeResponse(nil, err)
187+
}
188+
189+
configs := make([]xray.PingBatchItem, len(request.Configs))
190+
for i, config := range request.Configs {
191+
configs[i] = xray.PingBatchItem{
192+
ConfigPath: config.ConfigPath,
193+
OutboundTag: config.OutboundTag,
194+
}
195+
}
196+
197+
results, err := xray.PingBatch(
198+
configs,
199+
request.Timeout,
200+
request.URL,
201+
)
202+
if err != nil {
203+
return encodeInvokeResponse(nil, err)
204+
}
205+
206+
responseResults := make([]PingBatchItemResponse, len(results))
207+
for i, result := range results {
208+
responseResults[i] = PingBatchItemResponse{
209+
Success: result.Success,
210+
Delay: result.Delay,
211+
Error: result.Error,
212+
}
213+
}
214+
return encodeInvokeResponse(&PingBatchResponse{Results: responseResults}, nil)
215+
}
216+
181217
func invokeTestXray(payload json.RawMessage) string {
182218
request, err := decodePayload[RunXrayRequest](payload)
183219
if err != nil {

invoke_model.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const (
1111
LibXrayMethodConvertXrayJsonToShareLinks LibXrayMethod = "convertXrayJsonToShareLinks"
1212
LibXrayMethodCountGeoData LibXrayMethod = "countGeoData"
1313
LibXrayMethodPing LibXrayMethod = "ping"
14+
LibXrayMethodPingBatch LibXrayMethod = "pingBatch"
1415
LibXrayMethodTestXray LibXrayMethod = "testXray"
1516
LibXrayMethodRunXray LibXrayMethod = "runXray"
1617
LibXrayMethodRunXrayFromJson LibXrayMethod = "runXrayFromJson"
@@ -62,6 +63,27 @@ type PingResponse struct {
6263
Delay int64 `json:"delay,omitempty"`
6364
}
6465

66+
type PingBatchRequest struct {
67+
Configs []PingBatchItemRequest `json:"configs,omitempty"`
68+
Timeout int `json:"timeout,omitempty"`
69+
URL string `json:"url,omitempty"`
70+
}
71+
72+
type PingBatchItemRequest struct {
73+
ConfigPath string `json:"configPath,omitempty"`
74+
OutboundTag string `json:"outboundTag,omitempty"`
75+
}
76+
77+
type PingBatchResponse struct {
78+
Results []PingBatchItemResponse `json:"results,omitempty"`
79+
}
80+
81+
type PingBatchItemResponse struct {
82+
Success bool `json:"success"`
83+
Delay int64 `json:"delay,omitempty"`
84+
Error string `json:"error,omitempty"`
85+
}
86+
6587
type RunXrayRequest struct {
6688
ConfigPath string `json:"configPath,omitempty"`
6789
}

0 commit comments

Comments
 (0)