From c871d2ebb6719a36a3bfb6c24187552364e87522 Mon Sep 17 00:00:00 2001 From: yiguo Date: Sat, 29 Aug 2026 18:35:24 +0800 Subject: [PATCH 01/16] Use outbound tags for share names --- AGENTS.md | 4 ++-- README.md | 15 ++++++++------- invoke_model.go | 2 +- invoke_test.go | 22 ++++++++++++---------- readme/README.zh_CN.md | 14 +++++++------- share/clash_meta_test.go | 2 ++ share/generate_share_test.go | 6 +++--- share/marshal_share.go | 2 -- share/marshal_share_test.go | 9 ++++----- share/parse_share_test.go | 6 ++++-- share/validate_outbound.go | 3 --- share/xray_json.go | 7 +------ 12 files changed, 44 insertions(+), 48 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fe403bbe..9009a81e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,12 +32,12 @@ the generic API. # Invoke API Contract -The current API version is `2`. Requests using an omitted or different +The current API version is `3`. Requests using an omitted or different `apiVersion` are rejected. ```json { - "apiVersion": 2, + "apiVersion": 3, "method": "runXray", "payload": { "xrayJson": "{\"outbounds\":[...]}" diff --git a/README.md b/README.md index bab9ab30..d9a5b121 100644 --- a/README.md +++ b/README.md @@ -157,7 +157,7 @@ The request is a JSON object: ```json { - "apiVersion": 2, + "apiVersion": 3, "method": "runXray", "payload": { "xrayJson": "{\"outbounds\":[...]}" @@ -177,7 +177,7 @@ The response is a JSON object: Design notes: -1. Invoke currently accepts only `apiVersion: 2`. Xray configurations are +1. Invoke currently accepts only `apiVersion: 3`. 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 @@ -299,7 +299,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 @@ -324,7 +325,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 +343,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 +376,7 @@ by the `proxy` tag, and finally by the first outbound. ```json { - "apiVersion": 2, + "apiVersion": 3, "method": "pingBatch", "payload": { "configs": [ @@ -411,7 +412,7 @@ configuration file: ```json { - "apiVersion": 2, + "apiVersion": 3, "method": "testXray", "payload": { "xrayJson": "{\"outbounds\":[...]}" diff --git a/invoke_model.go b/invoke_model.go index f317f0ea..15286c71 100644 --- a/invoke_model.go +++ b/invoke_model.go @@ -5,7 +5,7 @@ import "encoding/json" type LibXrayMethod string -const LibXrayAPIVersion = 2 +const LibXrayAPIVersion = 3 const ( LibXrayMethodGetFreePorts LibXrayMethod = "getFreePorts" diff --git a/invoke_test.go b/invoke_test.go index 78cd23f3..9da81538 100644 --- a/invoke_test.go +++ b/invoke_test.go @@ -338,8 +338,11 @@ func TestInvokeConvertShareLinksFiltersBuildInvalidOutbounds(t *testing.T) { 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) } } @@ -377,7 +380,6 @@ func TestInvokeConvertShareLinksReturnsProjectedObject(t *testing.T) { 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) } @@ -610,7 +612,7 @@ func TestInvokeRemovedMethods(t *testing.T) { for _, method := range []string{"ping", "runXrayFromJson", "deriveAgePublicKey"} { 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 +664,17 @@ func TestInvokeAPIVersion(t *testing.T) { t.Fatal("omitted apiVersion should fail") } - response = invokeRawForTest(t, `{"apiVersion":1,"method":"xrayVersion"}`) + response = invokeRawForTest(t, `{"apiVersion":2,"method":"xrayVersion"}`) if response.Success { - t.Fatal("v1 apiVersion should fail") + t.Fatal("v2 apiVersion should fail") } 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 +685,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 +698,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..a4195ca2 100644 --- a/readme/README.zh_CN.md +++ b/readme/README.zh_CN.md @@ -121,7 +121,7 @@ void CGoFree(char* value); ```json { - "apiVersion": 2, + "apiVersion": 3, "method": "runXray", "payload": { "xrayJson": "{\"outbounds\":[...]}" @@ -141,7 +141,7 @@ void CGoFree(char* value); 设计决定: -1. Invoke 当前只接受 `apiVersion: 2`。Xray 配置通过 `xrayJson` 传递 UTF-8 JSON 文本;libXray 不读取配置文件路径。 +1. Invoke 当前只接受 `apiVersion: 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` 传入数据目录。 @@ -218,7 +218,7 @@ LibXray.resetDNS(); ## share -libXray 使用 `sendThrough` 来存储节点名称。 +libXray 使用 `tag` 存储节点名称。`sendThrough` 保留 Xray 原生语义,用于指定本地绑定地址。 ### clash_meta @@ -243,7 +243,7 @@ libXray 使用 `sendThrough` 来存储节点名称。 ```json { - "apiVersion": 2, + "apiVersion": 3, "method": "convertShareLinksToXrayJson", "payload": { "text": "-----BEGIN AGE ENCRYPTED FILE-----\n...", @@ -261,7 +261,7 @@ libXray 使用 `sendThrough` 来存储节点名称。 ```json { - "apiVersion": 2, + "apiVersion": 3, "method": "generateAgeKeyPair", "payload": { "keyType": "x25519" @@ -292,7 +292,7 @@ libXray 使用 `sendThrough` 来存储节点名称。 ```json { - "apiVersion": 2, + "apiVersion": 3, "method": "pingBatch", "payload": { "configs": [ @@ -325,7 +325,7 @@ outbound 依赖会被自动包含。 ```json { - "apiVersion": 2, + "apiVersion": 3, "method": "testXray", "payload": { "xrayJson": "{\"outbounds\":[...]}" diff --git a/share/clash_meta_test.go b/share/clash_meta_test.go index 9563f448..15cec8d3 100644 --- a/share/clash_meta_test.go +++ b/share/clash_meta_test.go @@ -25,6 +25,8 @@ func parseClashHy2(t *testing.T, yaml string) *conf.OutboundDetourConfig { config, err := tryToParseClashYaml(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] } diff --git a/share/generate_share_test.go b/share/generate_share_test.go index f8b8e21e..377e50bf 100644 --- a/share/generate_share_test.go +++ b/share/generate_share_test.go @@ -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) { +func TestConvertXrayJsonToShareLinks_IgnoresSendThroughForName(t *testing.T) { cfg, err := ConvertShareLinksToXrayJson(`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..cba06a27 100644 --- a/share/marshal_share.go +++ b/share/marshal_share.go @@ -306,8 +306,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..7baac5e6 100644 --- a/share/marshal_share_test.go +++ b/share/marshal_share_test.go @@ -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":{} @@ -59,8 +59,8 @@ func TestMarshalShareConfigJSONProjectsSupportedFields(t *testing.T) { 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", @@ -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_test.go b/share/parse_share_test.go index 6ccdc1ee..9e96b7ef 100644 --- a/share/parse_share_test.go +++ b/share/parse_share_test.go @@ -211,8 +211,8 @@ func TestConvertShareLinksToXrayJson_FiltersBuildInvalidOutbounds(t *testing.T) 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) + assert.Equal(t, "Valid", config.OutboundConfigs[0].Tag) + assert.Nil(t, config.OutboundConfigs[0].SendThrough) } func TestConvertShareLinksToXrayJson_AllBuildInvalidOutbounds(t *testing.T) { @@ -369,6 +369,8 @@ func TestConvertShareLinksToXrayJson_VmessBase64QR(t *testing.T) { link := "vmess://" + b64 cfg, err := ConvertShareLinksToXrayJson(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) diff --git a/share/validate_outbound.go b/share/validate_outbound.go index 4f89d292..fdfb9ba5 100644 --- a/share/validate_outbound.go +++ b/share/validate_outbound.go @@ -24,9 +24,6 @@ func filterBuildableOutbounds(config *conf.Config) (*conf.Config, error) { 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 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 } From 5234653e899efe6859e8dd726f552b96061ae235 Mon Sep 17 00:00:00 2001 From: yiguo Date: Wed, 2 Sep 2026 23:17:39 +0800 Subject: [PATCH 02/16] feat: add isolated route checking and build-only validation --- AGENTS.md | 24 +++- README.md | 81 +++++++++++++ invoke.go | 28 ++++- invoke_model.go | 22 +++- invoke_test.go | 85 +++++++++++++ readme/README.zh_CN.md | 68 +++++++++++ xray/check_route.go | 249 +++++++++++++++++++++++++++++++++++++++ xray/check_route_test.go | 214 +++++++++++++++++++++++++++++++++ xray/validation.go | 13 ++ 9 files changed, 779 insertions(+), 5 deletions(-) create mode 100644 xray/check_route.go create mode 100644 xray/check_route_test.go diff --git a/AGENTS.md b/AGENTS.md index 9009a81e..c4497ed8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,6 +68,7 @@ Supported methods: - `countGeoData` - `pingBatch` - `testXray` +- `checkRoute` - `runXray` - `stopXray` - `xrayVersion` @@ -79,8 +80,8 @@ 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 +`pingBatch`, `testXray`, `checkRoute`, 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. @@ -94,13 +95,30 @@ ignores other root fields, and includes outbound dependencies referenced by `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 +`testXray` (default `buildOnly: false`) 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. +`testXray` with `buildOnly: true` only loads/builds configuration and does not +construct runtime handlers. Use it for draft structure checks that must not +create TUN devices, logs, or background connections. Local asset/certificate +reads and process-level root `env` application remain core builder behavior; +successful building does not establish that runtime construction/start succeeds. + +`checkRoute` uses a temporary draft and the real Router without calling +`Start` or dispatching the supplied target. It rejects managed-instance overlap +and holds the managed lifecycle lock until matching and close finish. Other +temporary-core APIs still require caller isolation. The draft copy removes +inbounds, log output, and webhooks; WireGuard and VLESS reverse outbounds are +rejected because construction itself has runtime side effects. DNS queries may +occur. The timeout reaches the core context, but cancellation is not a hard +wall-clock bound for every resolver. When changing route evidence or execution +boundaries, read README.md's "Draft route checking" section for field semantics +and default-loopback limitations. + 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. diff --git a/README.md b/README.md index d9a5b121..fc2db5a4 100644 --- a/README.md +++ b/README.md @@ -219,12 +219,93 @@ generateAgeKeyPair countGeoData pingBatch testXray +checkRoute runXray stopXray xrayVersion getXrayState ``` +### Configuration validation + +`testXray` accepts `{"xrayJson":"...","buildOnly":true}` to load and build +the complete configuration without creating an Xray instance. This validates +the 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 a build-only check. + +`buildOnly` is optional and defaults to `false`, preserving the existing +`testXray` create-and-close behavior. That behavior does not call `Start`, but +constructors can create TUN devices, open logs, or initiate background work. +Use build-only validation for an unstarted draft; a successful build does not +prove runtime resources are available or that an instance can start. Runtime +construction/start errors remain the caller's responsibility to handle. + +### Draft route checking + +`checkRoute` is additive to API version 3. It accepts a complete draft in +`xrayJson` and calls the pinned Xray-core Router, without starting the temporary +instance or dispatching traffic to the supplied target: + +```json +{ + "apiVersion": 3, + "method": "checkRoute", + "payload": { + "xrayJson": "{\"outbounds\":[{\"tag\":\"direct\",\"protocol\":\"freedom\"}]}", + "domain": "example.com", + "port": 443, + "network": "tcp", + "inboundTag": "tunIn", + "timeout": 5000 + } +} +``` + +Supply exactly one of `domain` (hostname, not URL) or `ip` (IPv4/IPv6 without a +zone). `port` is 1–65535, `network` is `tcp` or `udp`, and required `timeout` is +1–60000 milliseconds. `inboundTag` is optional; omitted means an empty tag, not +an assumed VPN inbound. The existing 16 MiB envelope limit applies. + +Successful `data` always includes all five fields: + +```json +{"matched":false,"ruleTag":"","outboundTag":"direct","balancerTag":"","defaulted":true} +``` + +`matched` and `ruleTag` describe the initial Router match, preserving an empty +or duplicated original rule tag. `defaulted` means the initial Router found no +matching rule. The outbound manager then supplies the actual default outbound; +a loopback's native inbound-tag/skip-DNS transition is checked again through the +Router. `outboundTag` is the terminal selected outbound, and `balancerTag` is +the last balancer encountered, or empty when none was used. Thus a draft with a +default loopback may resolve to its configured balancer, whereas an arbitrary +Raw JSON draft is never assumed to default to `proxy`. Rules reached only after +a default loopback are not reported as initial user matches. Missing handlers, loopback cycles, +traffic-dependent loopback sniffing, and routing/selection failures return an +error instead of invented evidence. Selection uses a fresh instance, not live +balancer health/history, and does not test connectivity or the exit IP. + +Only the in-memory check configuration removes inbounds, disables file/log +output, and removes rule webhooks; the caller's draft is not rewritten. +WireGuard outbounds are rejected because construction can create a TUN device +even without `Start`; VLESS reverse outbounds are rejected because construction +starts background connections. There are no inbound listeners or background probes. +DNS resolution may still send network queries through the draft configuration. +The timeout context reaches the core; a timed-out lookup never returns success. +Some core resolvers, notably `localhost`, do not honor cancellation immediately, +so this is not a strict wall-clock limit. The call waits for matching to finish +before closing the instance; it never leaves matching using an already-closed +core in the background. + +`checkRoute` rejects a managed `runXray` instance in the same process and holds +the managed lifecycle lock through construction, matching, and close. It does +not isolate other exported temporary-core APIs: callers must continue to use +an independent execution process where required. The existing `testXray` and +`pingBatch` concurrency contract is unchanged. + ## controller ### Socket protect diff --git a/invoke.go b/invoke.go index f2134fe3..e8c1789a 100644 --- a/invoke.go +++ b/invoke.go @@ -49,6 +49,8 @@ func Invoke(requestJSON string) string { return invokePingBatch(request.Payload) case LibXrayMethodTestXray: return invokeTestXray(request.Payload) + case LibXrayMethodCheckRoute: + return invokeCheckRoute(request.Payload) case LibXrayMethodRunXray: return invokeRunXray(request.Payload) case LibXrayMethodStopXray: @@ -225,7 +227,11 @@ func invokeTestXray(payload json.RawMessage) string { if err != nil { return encodeInvokeNoDataResponse(err) } - err = xray.TestXray(request.XrayJson) + if request.BuildOnly { + err = xray.ValidateXray(request.XrayJson) + } else { + err = xray.TestXray(request.XrayJson) + } return encodeInvokeNoDataResponse(err) } @@ -237,3 +243,23 @@ func invokeRunXray(payload json.RawMessage) string { err = xray.RunXray(request.XrayJson) return encodeInvokeNoDataResponse(err) } + +func invokeCheckRoute(payload json.RawMessage) string { + request, err := decodePayload[CheckRouteRequest](payload) + if err != nil { + return encodeInvokeResponse(nil, err) + } + result, err := xray.CheckRoute(xray.RouteCheckInput{ + XrayJSON: request.XrayJson, Domain: request.Domain, IP: request.IP, + Port: request.Port, Network: request.Network, + InboundTag: request.InboundTag, Timeout: request.Timeout, + }) + if err != nil { + return encodeInvokeResponse(nil, err) + } + return encodeInvokeResponse(&CheckRouteResponse{ + Matched: result.Matched, RuleTag: result.RuleTag, + OutboundTag: result.OutboundTag, BalancerTag: result.BalancerTag, + Defaulted: result.Defaulted, + }, nil) +} diff --git a/invoke_model.go b/invoke_model.go index 15286c71..1cf8bbfe 100644 --- a/invoke_model.go +++ b/invoke_model.go @@ -15,6 +15,7 @@ const ( LibXrayMethodCountGeoData LibXrayMethod = "countGeoData" LibXrayMethodPingBatch LibXrayMethod = "pingBatch" LibXrayMethodTestXray LibXrayMethod = "testXray" + LibXrayMethodCheckRoute LibXrayMethod = "checkRoute" LibXrayMethodRunXray LibXrayMethod = "runXray" LibXrayMethodStopXray LibXrayMethod = "stopXray" LibXrayMethodXrayVersion LibXrayMethod = "xrayVersion" @@ -100,7 +101,26 @@ type RunXrayRequest struct { } type TestXrayRequest struct { - XrayJson string `json:"xrayJson,omitempty"` + XrayJson string `json:"xrayJson,omitempty"` + BuildOnly bool `json:"buildOnly,omitempty"` +} + +type CheckRouteRequest struct { + XrayJson string `json:"xrayJson"` + Domain string `json:"domain,omitempty"` + IP string `json:"ip,omitempty"` + Port int `json:"port"` + Network string `json:"network"` + InboundTag string `json:"inboundTag,omitempty"` + Timeout int `json:"timeout"` +} + +type CheckRouteResponse struct { + Matched bool `json:"matched"` + RuleTag string `json:"ruleTag"` + OutboundTag string `json:"outboundTag"` + BalancerTag string `json:"balancerTag"` + Defaulted bool `json:"defaulted"` } type XrayVersionResponse struct { diff --git a/invoke_test.go b/invoke_test.go index 9da81538..6bf0f0d9 100644 --- a/invoke_test.go +++ b/invoke_test.go @@ -206,6 +206,91 @@ func TestInvokeTestXray(t *testing.T) { requireNoDataObject(t, response) } +func TestInvokeTestXrayBuildOnlyDoesNotCreateRuntimeResources(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": "BuildOnlyMustNotCreate", "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), BuildOnly: true}) + if !response.Success { + t.Fatalf("buildOnly 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("buildOnly created a runtime log directory: %v", err) + } + response = invokeForTest(t, LibXrayMethodTestXray, TestXrayRequest{XrayJson: `{"outbounds":[{"protocol":"unknown"}]}`, BuildOnly: true}) + if response.Success || string(response.Data) != "null" { + t.Fatalf("buildOnly must still reject invalid core configuration: %+v", response) + } +} + +func TestInvokeTestXrayDefaultStillConstructsRuntime(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"}, + "outbounds": []any{map[string]any{"protocol": "freedom"}}, + }) + if err != nil { + t.Fatal(err) + } + for _, payload := range []any{ + TestXrayRequest{XrayJson: string(config)}, + map[string]any{"xrayJson": string(config), "buildOnly": false}, + } { + response := invokeForTest(t, LibXrayMethodTestXray, payload) + if response.Success || !strings.Contains(response.Err, "failed to initialize error logger") { + t.Fatalf("omitted/false buildOnly must retain runtime construction: %+v", response) + } + } +} + +func TestInvokeCheckRoute(t *testing.T) { + request := CheckRouteRequest{ + XrayJson: `{"outbounds":[{"protocol":"freedom","tag":"direct"}],"routing":{"rules":[{"domain":["full:example.com"],"outboundTag":"direct"}]}}`, + Domain: "example.com", Port: 443, Network: "tcp", InboundTag: "tunIn", Timeout: 5000, + } + response := invokeForTest(t, LibXrayMethodCheckRoute, request) + if !response.Success { + t.Fatal(response.Err) + } + data := decodeDataObject[CheckRouteResponse](t, response) + if data != (CheckRouteResponse{Matched: true, OutboundTag: "direct"}) { + t.Fatalf("unexpected route evidence: %+v", data) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(response.Data, &fields); err != nil { + t.Fatal(err) + } + for _, name := range []string{"matched", "ruleTag", "outboundTag", "balancerTag", "defaulted"} { + if _, ok := fields[name]; !ok { + t.Fatalf("missing required evidence field %s", name) + } + } + request.IP = "192.0.2.1" + response = invokeForTest(t, LibXrayMethodCheckRoute, request) + if response.Success || string(response.Data) != "null" { + t.Fatalf("invalid target should fail without evidence: %+v", response) + } + response = invokeRawForTest(t, `{"apiVersion":3,"method":"checkRoute","payload":{"port":"443"}}`) + if response.Success { + t.Fatal("invalid typed field accepted") + } +} + 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 a4195ca2..6a3123fb 100644 --- a/readme/README.zh_CN.md +++ b/readme/README.zh_CN.md @@ -159,12 +159,80 @@ generateAgeKeyPair countGeoData pingBatch testXray +checkRoute runXray stopXray xrayVersion getXrayState ``` +### 配置校验 + +`testXray` 支持 `{"xrayJson":"...","buildOnly":true}`,只加载并构建完整配置, +不创建 Xray instance。它校验包括 TUN/WireGuard 定义在内的配置结构,不创建设备、 +监听、日志文件或后台连接。构建器仍可能读取本地 GeoData/证书,并将根 `env` 应用 +到当前进程。Geodata assets 声明只校验 HTTPS URL 和已存在的本地文件,下载器及 +cron 不会在只构建校验期间运行。 + +`buildOnly` 可选,默认 `false`,保留原有 `testXray` 创建并关闭 instance 的行为。 +原行为虽然不调用 `Start`,但构造函数可能创建 TUN、打开日志或启动后台任务。 +尚未启动的草稿应使用只构建校验;构建成功不代表运行资源可用,也不代表 instance +可以启动。调用方仍须处理真实构造和启动阶段的失败。 + +### 草稿路由检查 + +`checkRoute` 是 API version 3 的增量方法。通过 `xrayJson` 接收完整草稿, +调用当前锁定版本 Xray-core 的 Router;不启动临时 instance,也不向输入的目标 +派发访问流量: + +```json +{ + "apiVersion": 3, + "method": "checkRoute", + "payload": { + "xrayJson": "{\"outbounds\":[{\"tag\":\"direct\",\"protocol\":\"freedom\"}]}", + "domain": "example.com", + "port": 443, + "network": "tcp", + "inboundTag": "tunIn", + "timeout": 5000 + } +} +``` + +`domain`(主机名,不是 URL)和 `ip`(不带 zone 的 IPv4/IPv6)必须且只能提供 +一个。`port` 为 1–65535,`network` 为 `tcp` 或 `udp`,必填的 `timeout` 为 +1–60000 毫秒。`inboundTag` 可选,省略时为空,不默认假设 VPN 入站。沿用 +16 MiB 的完整请求/响应包体限制。 + +成功响应的 `data` 始终包含全部五个字段: + +```json +{"matched":false,"ruleTag":"","outboundTag":"direct","balancerTag":"","defaulted":true} +``` + +`matched` 和 `ruleTag` 表示首次 Router 匹配,保留原始的空名称或重名。 +`defaulted` 表示首次 Router 没有匹配规则,随后使用 outbound manager 的真实 +默认出站;如果是 loopback,则按其原生入站 tag / 跳过 DNS 解析的转换再次调用 +Router。`outboundTag` 是最终选中的出站,`balancerTag` 是路径中最后经过的 +balancer,没有则为空。因此带默认 loopback 的草稿可以得到其实际配置的 +balancer,但不会假设任意 Raw JSON 的默认动作都是 `proxy`。仅在默认 loopback +之后命中的规则不会被报告为首次用户规则命中。出站不存在、loopback 循环、依赖访问流量的 loopback +sniffing、路由或节点选择失败均返回错误,不生成虚假结果。节点选择使用新建 +instance,而非运行实例的健康度/历史;它不验证连通性或出口 IP。 + +只在内存中的检查配置移除 inbounds、禁用日志输出并移除规则 webhook,不回写 +调用方草稿。WireGuard 出站会在构造时创建 TUN,因此即使不调用 `Start`,也必须 +拒绝这类检查。不会启动入站监听或后台探测。DNS 解析仍可能通过草稿配置发出网络 +查询。VLESS reverse 出站也会在构造时启动后台连接,因此同样拒绝。timeout 的 +context 会传入核心,超时后不会返回成功;但部分核心解析器 +(尤其 `localhost`)不能立即响应取消,因此不承诺严格的墙钟耗时上限。调用会等 +匹配实际结束后才关闭 instance,不会留下继续使用已关闭核心的后台匹配。 + +`checkRoute` 拒绝与同进程受管理的 `runXray` instance 重叠,并在构建、匹配和 +关闭期间持有受管理生命周期锁。这不隔离其他导出的临时核心 API,调用方仍须遵守 +独立执行进程边界。现有 `testXray` 和 `pingBatch` 的并发约定不变。 + ## controller 用于解决 Android 上 socket protect 问题。 diff --git a/xray/check_route.go b/xray/check_route.go new file mode 100644 index 00000000..ed3e1bd5 --- /dev/null +++ b/xray/check_route.go @@ -0,0 +1,249 @@ +package xray + +import ( + "context" + "errors" + "fmt" + "net/netip" + "strings" + "time" + + xlog "github.com/xtls/xray-core/app/log" + "github.com/xtls/xray-core/app/router" + "github.com/xtls/xray-core/common" + xnet "github.com/xtls/xray-core/common/net" + "github.com/xtls/xray-core/common/serial" + "github.com/xtls/xray-core/common/session" + "github.com/xtls/xray-core/core" + "github.com/xtls/xray-core/features/outbound" + "github.com/xtls/xray-core/features/routing" + rsession "github.com/xtls/xray-core/features/routing/session" + "github.com/xtls/xray-core/proxy/loopback" + "github.com/xtls/xray-core/proxy/vless" + vlessoutbound "github.com/xtls/xray-core/proxy/vless/outbound" + "github.com/xtls/xray-core/proxy/wireguard" + "golang.org/x/net/idna" +) + +type RouteCheckInput struct { + XrayJSON string + Domain string + IP string + Port int + Network string + InboundTag string + Timeout int // milliseconds +} + +type RouteCheckResult struct { + Matched bool + RuleTag string + OutboundTag string + BalancerTag string + Defaulted bool +} + +type routeRuleEvidence struct { + ruleTag string + balancerTag string +} + +// CheckRoute checks a draft with the real Router without starting the instance +// or dispatching the target. DNS lookup can still use the draft's outbounds. +// Like TestXray, construction changes core process globals: callers must isolate +// this operation from other non-managed instances. Managed overlap is rejected. +func CheckRoute(input RouteCheckInput) (result RouteCheckResult, err error) { + target, err := routeCheckTarget(input) + if err != nil { + return result, err + } + coreServerMu.Lock() + defer coreServerMu.Unlock() + if coreServer != nil { + return result, errors.New("checkRoute requires an isolated process without a managed Xray instance") + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(input.Timeout)*time.Millisecond) + defer cancel() + config, err := core.LoadConfig("json", strings.NewReader(input.XrayJSON)) + if err != nil { + return result, err + } + rules, loops, err := prepareRouteCheck(config) + if err != nil { + return result, err + } + if err = ctx.Err(); err != nil { + return result, err + } + server, err := core.NewWithContext(ctx, config) + if err != nil { + return result, err + } + defer func() { + // Never close an instance while PickRoute/DNS is still using it. + cancel() + err = errors.Join(err, server.Close()) + }() + result, err = checkRoute(ctx, server, rules, loops, &rsession.Context{ + Inbound: &session.Inbound{Tag: input.InboundTag}, + Outbound: &session.Outbound{Target: target}, + }) + if deadlineErr := ctx.Err(); deadlineErr != nil { + return RouteCheckResult{}, deadlineErr + } + return result, err +} + +func routeCheckTarget(input RouteCheckInput) (xnet.Destination, error) { + invalid := xnet.Destination{} + if len(input.XrayJSON) == 0 || len(input.XrayJSON) > 16*1024*1024 { + return invalid, errors.New("checkRoute xrayJson must be nonempty and no larger than 16 MiB") + } + if (input.Domain == "") == (input.IP == "") { + return invalid, errors.New("checkRoute requires exactly one of domain or ip") + } + if input.Port < 1 || input.Port > 65535 { + return invalid, errors.New("checkRoute port must be between 1 and 65535") + } + if input.Timeout < 1 || input.Timeout > 60000 { + return invalid, errors.New("checkRoute timeout must be between 1 and 60000 milliseconds") + } + var network xnet.Network + switch input.Network { + case "tcp": + network = xnet.Network_TCP + case "udp": + network = xnet.Network_UDP + default: + return invalid, errors.New("checkRoute network must be tcp or udp") + } + var address xnet.Address + if input.IP != "" { + ip, err := netip.ParseAddr(input.IP) + if err != nil || ip.Zone() != "" { + return invalid, errors.New("checkRoute ip must be an IPv4 or IPv6 address without a zone") + } + address = xnet.IPAddress(ip.AsSlice()) + } else { + domain, err := idna.Lookup.ToASCII(input.Domain) + domain = strings.TrimSuffix(domain, ".") + if err != nil || len(domain) == 0 || len(domain) > 253 { + return invalid, errors.New("checkRoute domain must be a valid hostname") + } + if _, err := netip.ParseAddr(domain); err == nil { + return invalid, errors.New("checkRoute IP literals must use the ip field") + } + for _, label := range strings.Split(domain, ".") { + if len(label) == 0 || len(label) > 63 { + return invalid, errors.New("checkRoute domain contains an invalid label") + } + } + address = xnet.DomainAddress(domain) + } + return xnet.Destination{Network: network, Address: address, Port: xnet.Port(input.Port)}, nil +} + +func prepareRouteCheck(config *core.Config) (map[string]routeRuleEvidence, map[string]*loopback.Config, error) { + // A TUN inbound can allocate its device during construction, before Start. + config.Inbound = nil + rules := make(map[string]routeRuleEvidence) + for index, app := range config.App { + settings, err := app.GetInstance() + if err != nil { + return nil, nil, err + } + switch settings := settings.(type) { + case *xlog.Config: + // Logger construction opens files; draft checking must not write them. + config.App[index] = serial.ToTypedMessage(&xlog.Config{}) + case *router.Config: + for index, rule := range settings.Rule { + tag := fmt.Sprintf("__libxray_check_rule_%d", index) + rules[tag] = routeRuleEvidence{rule.GetRuleTag(), rule.GetBalancingTag()} + rule.RuleTag = tag + // PickRoute fires webhooks even without dispatcher publication. + rule.Webhook = nil + } + config.App[index] = serial.ToTypedMessage(settings) + } + } + loops := make(map[string]*loopback.Config) + for index, handler := range config.Outbound { + settings, err := handler.ProxySettings.GetInstance() + if err != nil { + return nil, nil, err + } + switch settings := settings.(type) { + case *wireguard.DeviceConfig: + return nil, nil, errors.New("checkRoute cannot construct WireGuard outbounds without creating a TUN device") + case *vlessoutbound.Config: + account, err := settings.GetVnext().GetUser().GetAccount().GetInstance() + if err != nil { + return nil, nil, err + } + if account.(*vless.Account).Reverse != nil { + return nil, nil, errors.New("checkRoute cannot construct VLESS reverse outbounds without starting background connections") + } + case *loopback.Config: + // Only the first untagged handler can be the default; later ones + // cannot be addressed by a routing rule. + if handler.Tag != "" || index == 0 { + loops[handler.Tag] = settings + } + } + } + return rules, loops, nil +} + +func checkRoute(ctx context.Context, server *core.Instance, rules map[string]routeRuleEvidence, loops map[string]*loopback.Config, input *rsession.Context) (RouteCheckResult, error) { + var result RouteCheckResult + router := server.GetFeature(routing.RouterType()).(routing.Router) + manager := server.GetFeature(outbound.ManagerType()).(outbound.Manager) + visited := make(map[string]bool) + for hop := 0; ; hop++ { + if err := ctx.Err(); err != nil { + return result, err + } + picked, err := router.PickRoute(input) + if err == nil { + evidence := rules[picked.GetRuleTag()] + if hop == 0 { + result.Matched = true + result.RuleTag = evidence.ruleTag + } + result.OutboundTag = picked.GetOutboundTag() + if evidence.balancerTag != "" { + result.BalancerTag = evidence.balancerTag + } + if manager.GetHandler(result.OutboundTag) == nil { + return result, errors.New("checkRoute matched an outbound that does not exist") + } + } else if errors.Is(err, common.ErrNoClue) { + if hop == 0 { + result.Defaulted = true + } + fallback := manager.GetDefaultHandler() + if fallback == nil { + return result, errors.New("checkRoute has no default outbound") + } + result.OutboundTag = fallback.Tag() + } else { + return result, err + } + loop := loops[result.OutboundTag] + if loop == nil { + return result, nil + } + if loop.Sniffing.GetEnabled() { + return result, errors.New("checkRoute cannot determine a loopback path that requires traffic sniffing") + } + if visited[result.OutboundTag] { + return result, errors.New("checkRoute encountered a loopback routing cycle") + } + visited[result.OutboundTag] = true + // Follow only the real loopback metadata transition, never DispatchLink. + input.Inbound = &session.Inbound{Tag: loop.InboundTag} + input.Content = &session.Content{SkipDNSResolve: true} + } +} diff --git a/xray/check_route_test.go b/xray/check_route_test.go new file mode 100644 index 00000000..5926e2f4 --- /dev/null +++ b/xray/check_route_test.go @@ -0,0 +1,214 @@ +package xray + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" +) + +const routeCheckConfig = `{ + "log": {"loglevel":"none"}, + "dns": {"hosts":{"ip-rule.test":"192.0.2.2", "unknown.test":"198.51.100.2"}}, + "observatory": {"subjectSelector":[]}, + "outbounds": [ + {"tag":"default-loop","protocol":"loopback","settings":{"inboundTag":"default-vpn"}}, + {"tag":"direct","protocol":"freedom"}, + {"tag":"block","protocol":"blackhole"}, + {"tag":"entry-1","protocol":"freedom"} + ], + "routing": { + "domainStrategy":"IPIfNonMatch", + "balancers":[{"tag":"proxy","selector":["entry-1"],"strategy":{"type":"roundRobin"},"fallbackTag":"block"}], + "rules":[ + {"ruleTag":"default-vpn","inboundTag":["default-vpn"],"balancerTag":"proxy"}, + {"ruleTag":"duplicate","domain":["full:domain-rule.test"],"port":"443","network":"tcp","outboundTag":"direct"}, + {"ruleTag":"duplicate","ip":["192.0.2.0/24"],"outboundTag":"block"}, + {"ruleTag":"selected-vpn","domain":["full:vpn.test"],"balancerTag":"proxy"}, + {"domain":["full:unnamed.test"],"outboundTag":"direct"} + ] + } +}` + +func routeInput(config string) RouteCheckInput { + return RouteCheckInput{XrayJSON: config, Domain: "unknown.test", Port: 443, Network: "tcp", InboundTag: "tunIn", Timeout: 5000} +} + +func TestCheckRouteCoreEvidence(t *testing.T) { + for _, sample := range []struct { + name, domain, ip, network string + port int + want RouteCheckResult + }{ + {"domain", "domain-rule.test", "", "tcp", 443, RouteCheckResult{Matched: true, RuleTag: "duplicate", OutboundTag: "direct"}}, + {"resolved IP", "ip-rule.test", "", "tcp", 443, RouteCheckResult{Matched: true, RuleTag: "duplicate", OutboundTag: "block"}}, + {"IP literal", "", "192.0.2.3", "udp", 53, RouteCheckResult{Matched: true, RuleTag: "duplicate", OutboundTag: "block"}}, + {"explicit balancer", "vpn.test", "", "tcp", 443, RouteCheckResult{Matched: true, RuleTag: "selected-vpn", OutboundTag: "entry-1", BalancerTag: "proxy"}}, + {"unnamed", "unnamed.test", "", "tcp", 443, RouteCheckResult{Matched: true, OutboundTag: "direct"}}, + {"default VPN", "unknown.test", "", "tcp", 443, RouteCheckResult{Defaulted: true, OutboundTag: "entry-1", BalancerTag: "proxy"}}, + {"AND network", "domain-rule.test", "", "udp", 443, RouteCheckResult{Defaulted: true, OutboundTag: "entry-1", BalancerTag: "proxy"}}, + {"AND port", "domain-rule.test", "", "tcp", 80, RouteCheckResult{Defaulted: true, OutboundTag: "entry-1", BalancerTag: "proxy"}}, + } { + t.Run(sample.name, func(t *testing.T) { + input := routeInput(routeCheckConfig) + input.Domain, input.IP, input.Network, input.Port = sample.domain, sample.ip, sample.network, sample.port + // Keep negative domain cases local, too: no external DNS in tests. + input.XrayJSON = strings.Replace(input.XrayJSON, `"unknown.test":"198.51.100.2"`, `"unknown.test":"198.51.100.2","domain-rule.test":"198.51.100.3"`, 1) + got, err := CheckRoute(input) + if err != nil || got != sample.want { + t.Fatalf("got %+v, %v; want %+v", got, err, sample.want) + } + }) + } + + t.Run("Raw default is not assumed to be proxy", func(t *testing.T) { + got, err := CheckRoute(routeInput(minimalConfig)) + want := RouteCheckResult{Defaulted: true, OutboundTag: "direct"} + if err != nil || got != want { + t.Fatalf("got %+v, %v; want %+v", got, err, want) + } + }) + + t.Run("ordinary VLESS is supported without connecting", func(t *testing.T) { + input := routeInput(`{"outbounds":[{"tag":"entry","protocol":"vless","settings":{"address":"127.0.0.1","port":9,"id":"00000000-0000-0000-0000-000000000000","encryption":"none"}}]}`) + got, err := CheckRoute(input) + if err != nil || got != (RouteCheckResult{Defaulted: true, OutboundTag: "entry"}) { + t.Fatalf("ordinary VLESS: %+v %v", got, err) + } + }) +} + +func TestCheckRouteDoesNotStartListenPublishOrDialTarget(t *testing.T) { + var requests atomic.Int32 + target := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + requests.Add(1) + })) + defer target.Close() + address := target.Listener.Addr().(*net.TCPAddr) + logPath := filepath.Join(t.TempDir(), "must-not-exist.log") + config := fmt.Sprintf(`{ + "log":{"access":%q,"error":%q,"loglevel":"debug"}, + "inbounds":[{"listen":"127.0.0.1","port":%d,"protocol":"socks"}], + "outbounds":[{"tag":"direct","protocol":"freedom"}], + "observatory":{"subjectSelector":["direct"],"probeUrl":%q,"probeInterval":"1ms"}, + "routing":{"rules":[{"ruleTag":"test","network":"tcp","outboundTag":"direct","webhook":{"url":%q}}]} + }`, logPath, logPath, address.Port, target.URL, target.URL) + input := routeInput(config) + input.Domain, input.IP, input.Port = "", "127.0.0.1", address.Port + got, err := CheckRoute(input) + if err != nil || !got.Matched || got.OutboundTag != "direct" { + t.Fatalf("route failed: %+v %v", got, err) + } + if requests.Load() != 0 { + t.Fatal("route checking must not dispatch target, webhook, or probes") + } + if _, err := os.Stat(logPath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("draft log was opened: %v", err) + } +} + +func TestCheckRouteRejectsManagedOverlapBeforeLoadingEnv(t *testing.T) { + if err := RunXray(minimalConfig); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = StopXray() }) + const key = "XRAY_LIBXRAY_CHECK_ROUTE_TEST" + t.Setenv(key, "original") + input := routeInput(`{"env":{"` + key + `":"changed"},"outbounds":[{"protocol":"freedom"}]}`) + if _, err := CheckRoute(input); err == nil || !strings.Contains(err.Error(), "isolated process") { + t.Fatalf("expected managed-overlap error, got %v", err) + } + if os.Getenv(key) != "original" || !GetXrayState() { + t.Fatal("route check modified managed runtime or process environment") + } +} + +func TestCheckRouteDNSDeadline(t *testing.T) { + blackhole, err := net.ListenPacket("udp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer blackhole.Close() + address := blackhole.LocalAddr().(*net.UDPAddr) + config := fmt.Sprintf(`{ + "dns":{"servers":[{"address":"127.0.0.1","port":%d}]}, + "outbounds":[{"tag":"direct","protocol":"freedom"}], + "routing":{"domainStrategy":"IPIfNonMatch","rules":[{"ip":["192.0.2.0/24"],"outboundTag":"direct"}]} + }`, address.Port) + input := routeInput(config) + input.Timeout = 100 + started := time.Now() + if _, err := CheckRoute(input); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("expected deadline, got %v", err) + } + if time.Since(started) > 3*time.Second { + t.Fatal("core DNS did not honor the operation context") + } + // The timed-out operation is fully closed before the managed instance starts. + if err := RunXray(minimalConfig); err != nil { + t.Fatal(err) + } + if err := StopXray(); err != nil { + t.Fatal(err) + } +} + +func TestCheckRouteRejectsInvalidInputsAndUnresolvedPaths(t *testing.T) { + for _, sample := range []struct { + name string + edit func(*RouteCheckInput) + }{ + {"empty config", func(i *RouteCheckInput) { i.XrayJSON = "" }}, + {"path is not JSON", func(i *RouteCheckInput) { i.XrayJSON = "/xray.json" }}, + {"malformed JSON", func(i *RouteCheckInput) { i.XrayJSON = "{" }}, + {"missing target", func(i *RouteCheckInput) { i.Domain = "" }}, + {"two targets", func(i *RouteCheckInput) { i.IP = "192.0.2.1" }}, + {"URL is not domain", func(i *RouteCheckInput) { i.Domain = "https://example.com" }}, + {"empty label", func(i *RouteCheckInput) { i.Domain = "example..com" }}, + {"IP in domain", func(i *RouteCheckInput) { i.Domain = "192.0.2.1" }}, + {"invalid IP", func(i *RouteCheckInput) { i.Domain, i.IP = "", "invalid" }}, + {"scoped IP", func(i *RouteCheckInput) { i.Domain, i.IP = "", "fe80::1%en0" }}, + {"port zero", func(i *RouteCheckInput) { i.Port = 0 }}, + {"port overflow", func(i *RouteCheckInput) { i.Port = 65536 }}, + {"unsupported network", func(i *RouteCheckInput) { i.Network = "icmp" }}, + {"missing timeout", func(i *RouteCheckInput) { i.Timeout = 0 }}, + {"timeout overflow", func(i *RouteCheckInput) { i.Timeout = 60001 }}, + {"no default", func(i *RouteCheckInput) { i.XrayJSON = "{}" }}, + {"loop cycle", func(i *RouteCheckInput) { + i.XrayJSON = `{"outbounds":[{"protocol":"loopback","settings":{"inboundTag":"repeat"}}]}` + }}, + {"loop sniffing", func(i *RouteCheckInput) { + i.XrayJSON = `{"outbounds":[{"protocol":"loopback","settings":{"inboundTag":"repeat","sniffing":{"enabled":true,"destOverride":["tls"]}}}]}` + }}, + {"missing selected handler", func(i *RouteCheckInput) { + i.XrayJSON = `{"outbounds":[{"protocol":"freedom"}],"routing":{"rules":[{"network":"tcp","outboundTag":"missing"}]}}` + }}, + } { + t.Run(sample.name, func(t *testing.T) { + input := routeInput(minimalConfig) + sample.edit(&input) + if _, err := CheckRoute(input); err == nil { + t.Fatal("invalid input/path accepted") + } + }) + } +} + +func TestCheckRouteRejectsConstructionSideEffects(t *testing.T) { + for _, sample := range []struct{ config, message string }{ + {`{"outbounds":[{"protocol":"wireguard","settings":{"secretKey":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=","address":["10.0.0.2/32"],"peers":[{"publicKey":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=","endpoint":"127.0.0.1:9"}]}}]}`, "without creating a TUN device"}, + {`{"outbounds":[{"protocol":"vless","settings":{"address":"127.0.0.1","port":9,"id":"00000000-0000-0000-0000-000000000000","encryption":"none","reverse":{"tag":"reverse"}}}]}`, "without starting background connections"}, + } { + if _, err := CheckRoute(routeInput(sample.config)); err == nil || !strings.Contains(err.Error(), sample.message) { + t.Fatalf("expected explicit construction guard %q, got %v", sample.message, err) + } + } +} diff --git a/xray/validation.go b/xray/validation.go index 3d0a442f..223be3fa 100644 --- a/xray/validation.go +++ b/xray/validation.go @@ -1,5 +1,18 @@ package xray +import ( + "strings" + + "github.com/xtls/xray-core/core" +) + +// ValidateXray only builds the configuration; it does not instantiate handlers. +// The core builder can read local assets/certificates and apply root env values. +func ValidateXray(xrayJSON string) error { + _, err := core.LoadConfig("json", strings.NewReader(xrayJSON)) + return err +} + // Test Xray Config. // xrayJSON is the serialized Xray JSON configuration. func TestXray(xrayJSON string) error { From cba611197952be6257e79a8085bef224f170fa48 Mon Sep 17 00:00:00 2001 From: yiguo Date: Thu, 3 Sep 2026 01:02:27 +0800 Subject: [PATCH 03/16] feat: persist managed Xray session traffic snapshots --- AGENTS.md | 33 +-- README.md | 112 ++++++++-- desktop_bin/main.go | 26 ++- desktop_bin/main_test.go | 4 + invoke.go | 2 +- invoke_model.go | 12 +- invoke_test.go | 37 ++++ readme/README.zh_CN.md | 82 +++++++- xray/ping_batch.go | 5 + xray/runtime.go | 279 +++++++++++++++++++++++++ xray/runtime_file.go | 26 +++ xray/runtime_file_windows.go | 23 ++ xray/runtime_test.go | 392 +++++++++++++++++++++++++++++++++++ xray/validation.go | 11 + xray/xray.go | 38 +++- xray/xray_test.go | 29 +++ 16 files changed, 1074 insertions(+), 37 deletions(-) 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_test.go diff --git a/AGENTS.md b/AGENTS.md index c4497ed8..2dca5eb4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,12 +95,18 @@ ignores other root fields, and includes outbound dependencies referenced by `runXray` manages one package-level Xray instance. A second `runXray` call fails until `stopXray` closes the current instance. -`testXray` (default `buildOnly: false`) 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. +Optional `runXray.payload.runtime` saves only the current session's inbound +counters periodically and on normal stop. Before replacing the current file, +the previous session is archived for the App to reconcile. The App owns device +totals and reset; live reads use Xray's native metrics endpoint. Read README.md's +"Managed runtime accounting" section before changing session persistence. + +`testXray` (default `buildOnly: false`) and `pingBatch` create temporary Xray +instances. Xray-core has process-wide DNS client and outbound manager state. +These operations, `ValidateXray`/`buildOnly`, and `checkRoute` hold the managed +lifecycle lock and reject an active managed instance before config loading. +Batch workers share the outer lock through close. Unmanaged external instances +remain the caller's isolation responsibility; use separate processes if needed. `testXray` with `buildOnly: true` only loads/builds configuration and does not construct runtime handlers. Use it for draft structure checks that must not @@ -110,8 +116,7 @@ successful building does not establish that runtime construction/start succeeds. `checkRoute` uses a temporary draft and the real Router without calling `Start` or dispatching the supplied target. It rejects managed-instance overlap -and holds the managed lifecycle lock until matching and close finish. Other -temporary-core APIs still require caller isolation. The draft copy removes +and holds the managed lifecycle lock until matching and close finish. The draft copy removes inbounds, log output, and webhooks; WireGuard and VLESS reverse outbounds are rejected because construction itself has runtime side effects. DNS queries may occur. The timeout reaches the core context, but cancellation is not a hard @@ -159,9 +164,10 @@ typed JSON contract. 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. +The session Core accepts `run -dns -interface -config + [-runtime ]`, installs a process-wide protected Go +resolver, and runs one Xray instance until termination. The optional runtime +file contains host metadata, separate from the raw Xray configuration. # Building @@ -197,8 +203,9 @@ manually. # Development Rules -1. Keep `Invoke` as the single cross-platform API entrypoint. Platform-only - controller APIs must remain isolated by build tags. +1. Use `Invoke` for typed commands and Xray metrics for live counters. Session + persistence does not introduce a second HTTP server. Platform-only controller + APIs 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 diff --git a/README.md b/README.md index fc2db5a4..6858adb7 100644 --- a/README.md +++ b/README.md @@ -70,11 +70,13 @@ 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 @@ -201,13 +203,13 @@ Design notes: 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` (including `buildOnly`), + `checkRoute`, 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 temporary 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: @@ -301,10 +303,10 @@ before closing the instance; it never leaves matching using an already-closed core in the background. `checkRoute` rejects a managed `runXray` instance in the same process and holds -the managed lifecycle lock through construction, matching, and close. It does -not isolate other exported temporary-core APIs: callers must continue to use -an independent execution process where required. The existing `testXray` and -`pingBatch` concurrency contract is unchanged. +the managed lifecycle lock through construction, matching, and close. The same +managed-overlap guard also applies to `testXray` (including `buildOnly`) and +`pingBatch`. It does not detect externally created unmanaged instances; callers +must still use independent execution processes when those can overlap. ## controller @@ -506,6 +508,92 @@ configuration file: 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", + "planId": "opaque-plan-id", + "inboundTag": "tunIn" +} +``` + +The host supplies an existing private directory and an absolute `statePath`. +`planId` and `inboundTag` must be nonempty and at most 256 bytes. `planId` is +opaque and must not contain credentials. 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. +Invalid metadata, corrupt saved state, an archive failure, or an initial save +failure rejects startup; any constructed core is closed. + +The saved file contains only the current session's raw inbound counter values: + +```json +{ + "version": 1, + "session": { + "id": "2a7e2e49b947a802d8b39af4fbc48f52", + "planId": "opaque-plan-id", + "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, runtime HTTP endpoints, control ports, or tokens. +`resetRuntime` is not an Invoke method. Applications may read existing Xray +metrics for live rates; their own totals/reset policy stays outside libXray. + +Before a new session can replace `runtime.json`, the previous valid saved +snapshot is atomically archived beside it as +`runtime-sessions/.json`. The archive preserves its raw counters, +timestamps, and any unset ending; it does not infer missing traffic or a crash +time. Repeated unsuccessful starts reuse the same archive filename. A failed +preparation may therefore leave the same session in both current and archive; +consumers must identify sessions by ID, not count files. Archives are never +cleaned up by libXray, and their counters are never carried into the new session. +The archive directory rejects symlinks/non-directories and is created mode 0700. + +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 the file. +This is reference data, not billing: crashes/forced termination can lose the +tail after the last successful save, with no strict 30-second loss bound. A +restart archives only that last saved tail and does not fabricate final values. + +A nonblocking OS lock on `statePath + ".lock"` is held until core close, +preventing another process from writing the same current/archive sequence. +Hosts must use one consistent canonical path and leave the lock file in place. +UI code reads snapshots but does not write them while the host owns the path. +This does not solve macOS System Extension root-owned file access or provide +graceful final settlement when Windows forcibly terminates a job. Those platform +boundaries remain the integrating application's responsibility. + ### metrics Refer to the following configuration: 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/invoke.go b/invoke.go index e8c1789a..72b4b325 100644 --- a/invoke.go +++ b/invoke.go @@ -240,7 +240,7 @@ 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 1cf8bbfe..652755ca 100644 --- a/invoke_model.go +++ b/invoke_model.go @@ -1,7 +1,11 @@ // 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/xray" +) type LibXrayMethod string @@ -97,9 +101,13 @@ type PingBatchItemResponse struct { } type RunXrayRequest struct { - XrayJson string `json:"xrayJson,omitempty"` + XrayJson string `json:"xrayJson,omitempty"` + Runtime *RuntimeConfig `json:"runtime,omitempty"` } +type RuntimeConfig = xray.RuntimeConfig +type RuntimeSnapshot = xray.RuntimeSnapshot + type TestXrayRequest struct { XrayJson string `json:"xrayJson,omitempty"` BuildOnly bool `json:"buildOnly,omitempty"` diff --git a/invoke_test.go b/invoke_test.go index 6bf0f0d9..d8a20846 100644 --- a/invoke_test.go +++ b/invoke_test.go @@ -329,6 +329,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", PlanID: "plan", 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, "") diff --git a/readme/README.zh_CN.md b/readme/README.zh_CN.md index 6a3123fb..bd5a9d75 100644 --- a/readme/README.zh_CN.md +++ b/readme/README.zh_CN.md @@ -38,11 +38,11 @@ Linux 和 Windows 构建还会生成 `bin/xray` 或 `bin/xray.exe`。该会话 C 会保护 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 不支持在同一进程中加载多个独立构建的 @@ -147,7 +147,7 @@ void CGoFree(char* value); 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,必须将它们放在不同进程中。 +7. Xray-core 的系统拨号 DNS client 和 outbound manager 属于进程级状态。`pingBatch`、`testXray`(含 `buildOnly`)、`checkRoute` 及对应导出的 Go 入口均取得受管理生命周期锁,在加载/构建配置前拒绝同进程已运行的 `runXray` instance。批量测速在全部 worker 和临时核心关闭后才释放锁,这些临时操作也彼此串行。由管理 API 之外创建的 instance 不在检测或恢复范围内;可能与它们重叠的调用仍须使用独立进程。 支持的 method: @@ -230,8 +230,8 @@ context 会传入核心,超时后不会返回成功;但部分核心解析器 匹配实际结束后才关闭 instance,不会留下继续使用已关闭核心的后台匹配。 `checkRoute` 拒绝与同进程受管理的 `runXray` instance 重叠,并在构建、匹配和 -关闭期间持有受管理生命周期锁。这不隔离其他导出的临时核心 API,调用方仍须遵守 -独立执行进程边界。现有 `testXray` 和 `pingBatch` 的并发约定不变。 +关闭期间持有受管理生命周期锁。`testXray`(含 `buildOnly`)和 `pingBatch` 具有相同 +保护;未托管 instance 不在检测范围内,可能与其重叠时调用方仍须使用独立进程。 ## controller @@ -406,6 +406,76 @@ outbound 依赖会被自动包含。 使用传入的 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", + "planId": "opaque-plan-id", + "inboundTag": "tunIn" +} +``` + +宿主提供已存在的私有目录和绝对 `statePath`。`planId` / `inboundTag` 非空且 +各不超过 256 字节;`planId` 是不包含凭据的不透明标识。元数据独立于 Xray JSON, +用户配置不能覆盖。指定入站必须存在,并启用上下行系统统计和 stats manager。 +元数据无效、已有快照损坏、归档失败或首次保存失败均拒绝启动,并关闭已构建的核心。 + +落盘文件仅包含本次会话的原始入站计数: + +```json +{ + "version": 1, + "session": { + "id": "2a7e2e49b947a802d8b39af4fbc48f52", + "planId": "opaque-plan-id", + "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 总量、重置代次、runtime HTTP 接口、 +控制端口或 token。`resetRuntime` 不是 Invoke method。App 可通过已有 Xray metrics +读取实时速率;App 累计与重置策略由 App 自行管理,不属于 libXray。 + +新会话覆盖 `runtime.json` 前,先将已有合法快照原子归档到同级目录 +`runtime-sessions/.json`。归档保留原始计数、时间及可能未设置的结束 +时间,不推测丢失流量或崩溃时间。重复启动失败使用同一个归档文件名;准备失败时, +当前文件和归档可能同时存在相同会话,消费者必须按 session ID 识别,不能按文件数 +重复计入。libXray 不清理归档,也不把旧计数继承到新会话。归档目录以 0700 创建, +拒绝符号链接和非目录对象。 + +快照文件使用同目录 0600 临时文件,sync 后原子替换;Windows 使用 +`MoveFileEx` 的替换和 write-through 标志。私有父目录/Windows ACL 由宿主管理。 +保存失败保留上次完整磁盘快照供后续重试;最终保存失败向调用方报告,但仍关闭核心。 +rename 后发生 I/O 错误时结果可能不确定,消费者应重新读取文件。这是参考数据, +不是计费账本:崩溃/强杀允许丢失最后成功保存后的尾部,不承诺严格 30 秒丢失上限。 +下次启动只归档已有快照,不伪造最终计数。 + +`statePath + ".lock"` 的非阻塞操作系统文件锁保持至核心关闭,防止跨进程同时 +改写当前快照和归档。宿主须使用一致的规范路径并保留锁文件。UI 只读快照,不能在 +宿主持有路径期间直接写入。此能力不解决 macOS System Extension 的 root 文件访问 +权限,也不能让 Windows Job 强制终止获得正常最终结算;这些平台边界仍由接入方处理。 + ### metrics 统计。 diff --git a/xray/ping_batch.go b/xray/ping_batch.go index f40f24d9..ba89acd3 100644 --- a/xray/ping_batch.go +++ b/xray/ping_batch.go @@ -53,6 +53,11 @@ func PingBatch( if err := validatePingBatchRequest(items, timeout, targetURL); err != nil { return nil, err } + 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)) diff --git a/xray/runtime.go b/xray/runtime.go new file mode 100644 index 00000000..6d6ad768 --- /dev/null +++ b/xray/runtime.go @@ -0,0 +1,279 @@ +package xray + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "io" + "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"` + PlanID string `json:"planId"` + InboundTag string `json:"inboundTag"` +} + +type RuntimeSession struct { + ID string `json:"id"` + PlanID string `json:"planId"` + 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{} +} + +func prepareRuntime(config *RuntimeConfig) (*managedRuntime, error) { + if config == nil { + return nil, nil + } + if !filepath.IsAbs(config.StatePath) || strings.TrimSpace(config.PlanID) == "" || len(config.PlanID) > 256 || + strings.TrimSpace(config.InboundTag) == "" || len(config.InboundTag) > 256 { + return nil, errors.New("runtime requires an absolute statePath, planId, and inboundTag") + } + stateLock, err := lockRuntimeState(config.StatePath) + if err != nil { + return nil, err + } + prepared := false + defer func() { + if !prepared { + _ = stateLock.Close() + } + }() + previous, err := readRuntimeState(config.StatePath) + if err != nil { + return nil, err + } + // Archive before any new session can replace the previous saved snapshot. + // Repeated failed starts write the same session filename, not duplicate records. + if err := archiveRuntimeState(config.StatePath, previous); err != nil { + return nil, err + } + var id [16]byte + if _, err = rand.Read(id[:]); err != nil { + return nil, err + } + prepared = true + return &managedRuntime{ + config: *config, stateLock: stateLock, + snapshot: RuntimeSnapshot{ + Version: 1, + Session: RuntimeSession{ID: hex.EncodeToString(id[:]), PlanID: config.PlanID, 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 { + r.sample() + if err := r.save(); err != nil { + return err + } + 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 + } + } + r.snapshot.Error = "" + if !r.snapshot.Available { + r.snapshot.Error = "counters_unavailable" + } +} + +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 + } + 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 r.save() +} + +func readRuntimeState(path string) (RuntimeSnapshot, error) { + var state RuntimeSnapshot + info, err := os.Lstat(path) + if errors.Is(err, os.ErrNotExist) { + return state, nil + } + 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)) + 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) || + strings.TrimSpace(state.Session.PlanID) == "" || len(state.Session.PlanID) > 256 || + 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 archiveRuntimeState(path string, previous RuntimeSnapshot) error { + if previous.Version == 0 { + return nil + } + directory := filepath.Join(filepath.Dir(path), "runtime-sessions") + if err := os.Mkdir(directory, 0700); err != nil && !errors.Is(err, os.ErrExist) { + return errors.New("runtime archive directory is unavailable") + } + if info, err := os.Lstat(directory); err != nil || !info.IsDir() { + return errors.New("runtime archive directory is unavailable") + } + if err := writeRuntimeState(filepath.Join(directory, previous.Session.ID+".json"), previous); err != nil { + return errors.New("runtime archive write failed") + } + return 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_test.go b/xray/runtime_test.go new file mode 100644 index 00000000..251357e9 --- /dev/null +++ b/xray/runtime_test.go @@ -0,0 +1,392 @@ +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"), + PlanID: "opaque-plan", 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) != 3 || metadataFields["controlAddress"] != nil || metadataFields["controlToken"] != nil { + t.Fatalf("runtime metadata exposed a control surface: %s", metadata) + } +} + +func TestRuntimeArchivesEachPreviousSessionBeforeReplacement(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. Repeating it must + // not create multiple records for the same session or change its timestamps. + 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") + } + } + archive := filepath.Join(filepath.Dir(config.StatePath), "runtime-sessions") + entries, err := os.ReadDir(archive) + if err != nil || len(entries) != 1 || savedRuntime(t, filepath.Join(archive, stopped.Session.ID+".json")) != stopped { + t.Fatalf("archive duplicated or changed previous session: %v %v", entries, err) + } + 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.PlanID != config.PlanID || current.Session.Uplink != 0 || current.Session.Downlink != 0 { + t.Fatalf("restart reused a session or inherited totals: %+v", current) + } + if savedRuntime(t, filepath.Join(archive, stopped.Session.ID+".json")) != stopped { + t.Fatal("new current snapshot overwrote the archived session") + } +} + +func TestRuntimeWriteAndArchiveFailuresPreserveSavedFile(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) + } + _ = runtime.stateLock.Close() + archive := filepath.Join(filepath.Dir(config.StatePath), "runtime-sessions") + if err := os.WriteFile(archive, []byte("blocks archive directory"), 0600); err != nil { + t.Fatal(err) + } + if next, err := prepareRuntime(&config); err == nil { + _ = next.stateLock.Close() + t.Fatal("archive failure permitted a new owner") + } + if savedRuntime(t, config.StatePath) != recovered { + t.Fatal("archive failure overwrote the previous saved session") + } + if err := os.Remove(archive); err != nil { + t.Fatal(err) + } + if err := os.Symlink(t.TempDir(), archive); err != nil { + t.Skipf("symbolic links unavailable on this test host: %v", err) + } + if next, err := prepareRuntime(&config); err == nil { + _ = next.stateLock.Close() + t.Fatal("archive directory symlink was followed") + } + if savedRuntime(t, config.StatePath) != recovered { + t.Fatal("archive symlink rejection changed the saved session") + } +} + +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.PlanID = " " }, + func(c *RuntimeConfig) { c.PlanID = strings.Repeat("x", 257) }, + } { + 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","planId":"plan","startedAtMs":1},"sampledAtMs":1,"savedAtMs":1}`, + `{"version":1,"session":{"id":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","planId":"plan","startedAtMs":1,"uplink":-1},"sampledAtMs":1,"savedAtMs":1}`, + } { + if err := os.WriteFile(config.StatePath, []byte(text), 0600); err != nil { + t.Fatal(err) + } + if r, err := prepareRuntime(&config); err == nil { + _ = r.stateLock.Close() + 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") + } + } +} + +func TestManagedRuntimeStartFailureAndStop(t *testing.T) { + t.Cleanup(func() { _ = StopXray() }) + config := runtimeConfig(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() + 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) + } + 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 TestRuntimeKilledOwnerKeepsOnlySavedTail(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) + } + archive := filepath.Join(filepath.Dir(config.StatePath), "runtime-sessions", saved.Session.ID+".json") + if savedRuntime(t, archive) != saved { + t.Fatal("restart did not preserve the killed owner's last saved snapshot") + } + 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) + } +} + +func TestRuntimeChild(t *testing.T) { + path := os.Getenv("LIBXRAY_TEST_RUNTIME_PATH") + if path == "" { + return + } + config := RuntimeConfig{StatePath: path, PlanID: "child-plan", 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 223be3fa..53be1615 100644 --- a/xray/validation.go +++ b/xray/validation.go @@ -1,6 +1,7 @@ package xray import ( + "errors" "strings" "github.com/xtls/xray-core/core" @@ -9,6 +10,11 @@ import ( // ValidateXray only builds the configuration; it does not instantiate handlers. // The core builder can read local assets/certificates and apply root env values. func ValidateXray(xrayJSON string) error { + coreServerMu.Lock() + defer coreServerMu.Unlock() + if coreServer != nil { + return errors.New("validateXray requires an isolated process without a managed Xray instance") + } _, err := core.LoadConfig("json", strings.NewReader(xrayJSON)) return err } @@ -16,6 +22,11 @@ func ValidateXray(xrayJSON string) error { // Test Xray Config. // xrayJSON is the serialized Xray JSON configuration. 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") + } server, err := newXrayInstance(xrayJSON) if err != nil { 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..47947b20 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,33 @@ 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{ + "buildOnly": func() error { return ValidateXray(config) }, + "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 1af77337d02bb9f42c7b64ce3a4f4ea09268e9dd Mon Sep 17 00:00:00 2001 From: yiguo Date: Thu, 3 Sep 2026 02:46:47 +0800 Subject: [PATCH 04/16] feat: report share parsing counts and probe server locations --- README.md | 45 +++++++++++- invoke.go | 15 +++- invoke_model.go | 23 ++++-- invoke_probes_test.go | 78 ++++++++++++++++++++ readme/README.zh_CN.md | 36 ++++++++- share/age.go | 32 +++++--- share/marshal_share.go | 17 +++-- share/parse_stats.go | 121 ++++++++++++++++++++++++++++++ share/parse_stats_test.go | 147 +++++++++++++++++++++++++++++++++++++ xray/ping_batch.go | 98 ++++++++++++++++++++----- xray/ping_location_test.go | 120 ++++++++++++++++++++++++++++++ 11 files changed, 682 insertions(+), 50 deletions(-) create mode 100644 invoke_probes_test.go create mode 100644 share/parse_stats.go create mode 100644 share/parse_stats_test.go create mode 100644 xray/ping_location_test.go diff --git a/README.md b/README.md index 6858adb7..f6c1f139 100644 --- a/README.md +++ b/README.md @@ -399,6 +399,29 @@ convert VMessAEAD/VLESS sharing protocol to Xray Json. convert VMessQRCode to Xray Json. +#### Optional parsing counts + +Set `payload.includeStats: true` on `convertShareLinksToXrayJson` to return +`data: {"config":{"outbounds":[...]},"usableCount":2,"failedCount":1}`. +Omitting it (or setting it to `false`) preserves the original `data.outbounds` +response and conversion behavior. + +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. Stats mode skips malformed individual elements 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 @@ -472,7 +495,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/" } } ``` @@ -483,11 +507,30 @@ 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 `location: {"ip":"203.0.113.1","countryCode":"JP"}`. +The provider must return HTTP 200 and at most 64 KiB of JSON containing +Cloudflare's `ip_address`/`country` pair or normalized `ip`/`countryCode`. +The IP must be valid and the country code must be two ASCII letters (returned +uppercase). An invalid/missing location instead adds `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 diff --git a/invoke.go b/invoke.go index 72b4b325..ee123337 100644 --- a/invoke.go +++ b/invoke.go @@ -141,6 +141,10 @@ func invokeConvertShareLinksToXrayJson(payload json.RawMessage) string { if request.Age != nil { secretKey = request.Age.SecretKey } + if request.IncludeStats { + result, err := share.ConvertShareLinksToXrayJsonWithStats(request.Text, secretKey) + return encodeInvokeResponse(result, err) + } config, err := share.ConvertShareLinksToXrayJsonWithAge(request.Text, secretKey) if err != nil { return encodeInvokeResponse(nil, err) @@ -202,10 +206,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) @@ -214,9 +219,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, + Location: result.Location, + LocationError: result.LocationError, } } return encodeInvokeResponse(&PingBatchResponse{Results: responseResults}, nil) diff --git a/invoke_model.go b/invoke_model.go index 652755ca..8e45c988 100644 --- a/invoke_model.go +++ b/invoke_model.go @@ -4,6 +4,7 @@ package libXray import ( "encoding/json" + "github.com/xtls/libxray/share" "github.com/xtls/libxray/xray" ) @@ -45,10 +46,13 @@ type AgeDecryptConfig struct { } type ConvertShareLinksToXrayJsonRequest struct { - Text string `json:"text,omitempty"` - Age *AgeDecryptConfig `json:"age,omitempty"` + Text string `json:"text,omitempty"` + Age *AgeDecryptConfig `json:"age,omitempty"` + IncludeStats bool `json:"includeStats,omitempty"` } +type ConvertShareLinksToXrayJsonResponse = share.ParseStats + type AgeKeyType string const ( @@ -80,9 +84,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 { @@ -95,9 +100,11 @@ 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"` + Location *xray.PingLocation `json:"location,omitempty"` + LocationError string `json:"locationError,omitempty"` } type RunXrayRequest struct { diff --git a/invoke_probes_test.go b/invoke_probes_test.go new file mode 100644 index 00000000..ac6f9a45 --- /dev/null +++ b/invoke_probes_test.go @@ -0,0 +1,78 @@ +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, IncludeStats: true}) + 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, IncludeStats: true}) + 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) + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"ip_address":"203.0.113.1","country":"SG"}`) + })) + 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.Location == nil || result.Location.CountryCode != "SG" || result.Location.IP != "203.0.113.1" { + t.Fatalf("result = %+v", result) + } + request.LocationURL = "" + response = invokeForTest(t, LibXrayMethodPingBatch, request) + if !response.Success || strings.Contains(string(response.Data), "location") { + t.Fatalf("legacy response = %+v", response) + } +} diff --git a/readme/README.zh_CN.md b/readme/README.zh_CN.md index bd5a9d75..7f402e21 100644 --- a/readme/README.zh_CN.md +++ b/readme/README.zh_CN.md @@ -302,6 +302,24 @@ libXray 使用 `tag` 存储节点名称。`sendThrough` 保留 Xray 原生语义 转换 VMessQRCode 为 Xray Json。 +#### 可选解析数量 + +给 `convertShareLinksToXrayJson` 传入 `payload.includeStats: true` 时,返回 +`data: {"config":{"outbounds":[...]},"usableCount":2,"failedCount":1}`。 +省略或设为 `false` 时,保留原来的 `data.outbounds` 响应和转换行为。 + +数量只描述本次输入,不区分新增和更新。JSON 根 `outbounds` 的每个元素、YAML +`proxies` 的每个元素各算一个候选。已识别的分享链接列表中,每条 URI 形式的行 +算一个候选,空行、注释和文本标题忽略。Base64 / age 包装使用内部格式的候选 +数量。统计模式逐项跳过类型错误,不丢弃其余有效元素。`usableCount` 与最终投影且 +可构建的 outbound 数量相同;解析失败、构建失败和投影不支持的候选均计入 +`failedCount`。不做节点 hash 比较或去重。 + +已识别容器中没有可用节点时,返回 `success: false`,保留结构化数量和 +`config: {"outbounds":[]}`。无法识别格式、整份文档语法错误、容器错误或解密 +失败时返回 `data: null`,不猜测数量。错误文案不含被拒绝的候选或解密明文。 +调用方不得在可用节点为零时导入或覆盖订阅。 + ### age 加密订阅 `convertShareLinksToXrayJson` 接受可选的 age 原生私钥。仅支持 X25519 @@ -373,7 +391,8 @@ libXray 使用 `tag` 存储节点名称。`sendThrough` 保留 Xray 原生语义 } ], "timeout": 5, - "url": "https://cp.cloudflare.com/" + "url": "https://cp.cloudflare.com/", + "locationUrl": "https://ip-check-perf.radar.cloudflare.com/" } } ``` @@ -384,9 +403,24 @@ libXray 使用 `tag` 存储节点名称。`sendThrough` 保留 Xray 原生语义 批次请求本身被接受时,顶层 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 成功后增加 `location: {"ip":"203.0.113.1","countryCode":"JP"}`。 +数据源必须返回 HTTP 200、最大 64 KiB 的 JSON,字段为 Cloudflare 的 +`ip_address` / `country`,或规范化的 `ip` / `countryCode`。IP 必须有效, +地区代码为两个 ASCII 字母并统一返回大写。缺失或无效的位置改为返回 +`locationError`,错误不回显 URL、凭据或响应正文。无效 outbound 保留原有 +逐项失败结果,不发出这两个请求。 + ### testXray 直接校验传入的 Xray JSON 文本,不读取配置文件: diff --git a/share/age.go b/share/age.go index 1057911f..03644fa0 100644 --- a/share/age.go +++ b/share/age.go @@ -63,39 +63,47 @@ func GenerateAgeKeyPair(keyType AgeKeyType) (*AgeKeyPair, error) { } func ConvertShareLinksToXrayJsonWithAge(links, secretKey string) (*conf.Config, error) { + text, encrypted, err := decryptShareText(links, secretKey) + if err != nil { + return nil, err + } + config, err := ConvertShareLinksToXrayJson(text) + if err != nil && encrypted { + return nil, ErrAgePlaintextUnsupported + } + return config, err +} + +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 - } - config, err := ConvertShareLinksToXrayJson(string(plaintext)) - if err != nil { - return nil, ErrAgePlaintextUnsupported + return "", true, ErrAgePlaintextTooLarge } - return config, nil + return string(plaintext), true, nil } func parseNativeAgeIdentity(secretKey string) (age.Identity, age.Recipient, error) { diff --git a/share/marshal_share.go b/share/marshal_share.go index cba06a27..b0b4c4ef 100644 --- a/share/marshal_share.go +++ b/share/marshal_share.go @@ -12,8 +12,13 @@ import ( // MarshalShareConfigJSON returns the Xray JSON subset supported by share links. func MarshalShareConfigJSON(config *conf.Config) (json.RawMessage, error) { + raw, _, err := marshalShareConfigJSON(config) + return raw, err +} + +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)) @@ -21,7 +26,7 @@ func MarshalShareConfigJSON(config *conf.Config) (json.RawMessage, 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 { @@ -38,16 +43,16 @@ func MarshalShareConfigJSON(config *conf.Config) (json.RawMessage, error) { if len(outbounds) == 0 { if firstBuildError != nil { - return nil, fmt.Errorf("no valid outbound found: %w", firstBuildError) + return nil, 0, 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) { diff --git a/share/parse_stats.go b/share/parse_stats.go new file mode 100644 index 00000000..83e8bfbc --- /dev/null +++ b/share/parse_stats.go @@ -0,0 +1,121 @@ +package share + +import ( + "encoding/json" + "errors" + "net/url" + "strings" + + "github.com/xtls/xray-core/infra/conf" + "gopkg.in/yaml.v3" +) + +// ParseStats counts source candidates, not lines or changes to a subscription. +// Config contains exactly the projected, buildable outbounds counted as usable. +type ParseStats struct { + Config json.RawMessage `json:"config"` + UsableCount int `json:"usableCount"` + FailedCount int `json:"failedCount"` +} + +// ConvertShareLinksToXrayJsonWithStats leaves the legacy conversion API intact. +// A recognized candidate container with no usable nodes returns both its counts +// and an error. Whole-document/decryption failures return no invented counts. +func ConvertShareLinksToXrayJsonWithStats(links, secretKey string) (*ParseStats, 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 := &ParseStats{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 + forEachLine(text, func(raw string) bool { + 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#") { + return true + } + candidates++ + parsed, err := url.Parse(line) + if err != nil { + return true + } + outbound, err := (xrayShareLink{link: parsed, rawText: line}).outbound() + if err == nil { + config.OutboundConfigs = append(config.OutboundConfigs, *outbound) + } + return true + }) + 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/parse_stats_test.go b/share/parse_stats_test.go new file mode 100644 index 00000000..d8eb204b --- /dev/null +++ b/share/parse_stats_test.go @@ -0,0 +1,147 @@ +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 TestShareStatsCountActualCandidates(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 := ConvertShareLinksToXrayJsonWithStats(test.text, "") + if err != nil { + t.Fatal(err) + } + assertShareStats(t, result, test.usable, test.failed) + }) + } +} + +func TestShareStatsAllInvalidRetainsCountsWithoutSource(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 := ConvertShareLinksToXrayJsonWithStats(input, "") + if err == nil || err.Error() != "no valid outbound found" { + t.Fatalf("error = %v", err) + } + assertShareStats(t, result, 0, 1) + } + result, err := ConvertShareLinksToXrayJsonWithStats(`{"outbounds":[]}`, "") + if err == nil { + t.Fatal("empty array succeeded") + } + assertShareStats(t, result, 0, 0) +} + +func TestShareStatsMalformedDocumentHasNoCounts(t *testing.T) { + for _, input := range []string{ + `{"outbounds":[`, `{"outbounds":"private-source"}`, `{"outbounds":null}`, + "proxies: [", "proxies: private-source", "not a subscription", + } { + result, err := ConvertShareLinksToXrayJsonWithStats(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 TestShareStatsAgeCountsInnerCandidatesAndRedactsErrors(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 := ConvertShareLinksToXrayJsonWithStats(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 := ConvertShareLinksToXrayJsonWithStats(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 := ConvertShareLinksToXrayJsonWithStats(encryptAgeForTest(t, pair, ageTestShareLink), "private-invalid-key") + if err != ErrAgeSecretKeyInvalid || result != nil { + t.Fatalf("result = %+v, error = %v", result, err) + } +} + +func TestShareStatsPreservesLegacyProjectedConfig(t *testing.T) { + config, err := ConvertShareLinksToXrayJson(ageTestShareLink) + if err != nil { + t.Fatal(err) + } + legacy, err := MarshalShareConfigJSON(config) + if err != nil { + t.Fatal(err) + } + result, err := ConvertShareLinksToXrayJsonWithStats(ageTestShareLink, "") + if err != nil { + t.Fatal(err) + } + if string(result.Config) != string(legacy) { + t.Fatal("stats changed projected config") + } +} + +func assertShareStats(t *testing.T, result *ParseStats, 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/xray/ping_batch.go b/xray/ping_batch.go index ba89acd3..356622de 100644 --- a/xray/ping_batch.go +++ b/xray/ping_batch.go @@ -31,9 +31,16 @@ type PingBatchItem struct { } type PingBatchResult struct { - Success bool - Delay int64 - Error string + Success bool + Delay int64 + Error string + Location *PingLocation + LocationError string +} + +type PingLocation struct { + IP string `json:"ip"` + CountryCode string `json:"countryCode"` } type pingOutboundConfig struct { @@ -50,9 +57,20 @@ 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 { @@ -104,23 +122,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, - } } }() } @@ -366,12 +374,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, @@ -397,7 +406,60 @@ 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 != "" { + location, err := probeLocation(client, locationURL) + if err != nil { + result.LocationError = err.Error() + } else { + result.Location = location + } + } + return result +} + +func probeLocation(client *http.Client, locationURL string) (*PingLocation, error) { + response, err := client.Get(locationURL) + if err != nil { + // Do not include a provider URL, credentials or response body in errors. + return nil, errors.New("location request failed") + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return nil, 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 nil, errors.New("unable to read location response") + } + // Cloudflare's location response and the equivalent normalized pair are + // supported; an unavailable or invalid country is not guessed from the IP. + var location struct { + IP string `json:"ip"` + CountryCode string `json:"countryCode"` + IPAddress string `json:"ip_address"` + Country string `json:"country"` + } + if err := json.Unmarshal(body, &location); err != nil { + return nil, errors.New("invalid location response") + } + if location.IP == "" { + location.IP = location.IPAddress + } + if location.CountryCode == "" { + location.CountryCode = location.Country + } + code := strings.ToUpper(strings.TrimSpace(location.CountryCode)) + ip := net.ParseIP(strings.TrimSpace(location.IP)) + if ip == nil || len(code) != 2 || code[0] < 'A' || code[0] > 'Z' || code[1] < 'A' || code[1] > 'Z' { + return nil, errors.New("location response requires an IP and two-letter country code") + } + return &PingLocation{IP: ip.String(), CountryCode: code}, 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..306b5d57 --- /dev/null +++ b/xray/ping_location_test.go @@ -0,0 +1,120 @@ +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.Location == nil || first.Location.IP != "203.0.113.9" || first.Location.CountryCode != "JP" || first.LocationError != "" { + t.Fatalf("first result = %+v", first) + } + if second.Success || second.Location != 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.Location != 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].Location != nil || results[0].LocationError != "" { + t.Fatalf("legacy 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":"2001:db8::1","countryCode":"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].Location == nil || results[0].LocationError != "" { + t.Fatalf("result = %+v", results[0]) + } +} + +func TestProbeLocationRejectsInvalidResponsesWithoutLeakingInput(t *testing.T) { + for _, body := range []string{ + `{"ip":"203.0.113.9","countryCode":"Japan"}`, + `{"ip":"private-source","countryCode":"JP"}`, + `{"ip":"203.0.113.9"}`, `private-source`, strings.Repeat("x", 64*1024+1), + } { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, body) })) + location, err := probeLocation(server.Client(), server.URL) + server.Close() + if err == nil || location != nil { + t.Fatalf("location = %+v, error = %v", location, err) + } + if strings.Contains(err.Error(), "private-source") { + t.Fatal("response body leaked") + } + } + 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 0fe8f5b72ead1052ee3667ecfe41479bba515aaf Mon Sep 17 00:00:00 2001 From: yiguo Date: Thu, 3 Sep 2026 04:06:49 +0800 Subject: [PATCH 05/16] feat: probe full routing configurations without binding listeners --- README.md | 14 +++++++++ invoke.go | 10 +++++++ invoke_model.go | 11 +++++-- invoke_probes_test.go | 21 +++++++++++++ readme/README.zh_CN.md | 11 +++++++ xray/probe.go | 68 ++++++++++++++++++++++++++++++++++++++++++ xray/probe_test.go | 48 +++++++++++++++++++++++++++++ 7 files changed, 181 insertions(+), 2 deletions(-) create mode 100644 xray/probe.go create mode 100644 xray/probe_test.go diff --git a/README.md b/README.md index f6c1f139..67463993 100644 --- a/README.md +++ b/README.md @@ -245,6 +245,20 @@ Use build-only validation for an unstarted draft; a successful build does not prove runtime resources are available or that an instance can start. Runtime construction/start errors remain the caller's responsibility to handle. +### Configuration URL probe + +`testXray` also accepts `url`, `timeout` (1–60 seconds), and optional +`inboundTag` with `xrayJson`. It returns `data: {"delay": 12}` in integer +milliseconds. `url` and `buildOnly: true` are mutually exclusive. Omit `url` +to retain the existing validation response. + +The probe sends an HTTP HEAD using the draft's DNS, routing and outbounds, +without forcing one outbound. It uses the route check's safe construction: +inbounds/log output/webhooks are disabled, WireGuard and VLESS reverse are +rejected, and the temporary instance is never started or published. It does +not test extra listeners, startup-only integrations, or every destination. +The lifecycle lock and managed-instance overlap rejection still apply. + ### Draft route checking `checkRoute` is additive to API version 3. It accepts a complete draft in diff --git a/invoke.go b/invoke.go index ee123337..43a6c14d 100644 --- a/invoke.go +++ b/invoke.go @@ -234,6 +234,16 @@ func invokeTestXray(payload json.RawMessage) string { if err != nil { return encodeInvokeNoDataResponse(err) } + if request.URL != "" { + if request.BuildOnly { + return encodeInvokeNoDataResponse(errors.New("testXray URL and buildOnly are mutually exclusive")) + } + delay, err := xray.ProbeXray(request.XrayJson, request.URL, request.Timeout, request.InboundTag) + if err != nil { + return encodeInvokeNoDataResponse(err) + } + return encodeInvokeResponse(&TestXrayResponse{Delay: delay}, nil) + } if request.BuildOnly { err = xray.ValidateXray(request.XrayJson) } else { diff --git a/invoke_model.go b/invoke_model.go index 8e45c988..ad4cb54f 100644 --- a/invoke_model.go +++ b/invoke_model.go @@ -116,8 +116,15 @@ type RuntimeConfig = xray.RuntimeConfig type RuntimeSnapshot = xray.RuntimeSnapshot type TestXrayRequest struct { - XrayJson string `json:"xrayJson,omitempty"` - BuildOnly bool `json:"buildOnly,omitempty"` + XrayJson string `json:"xrayJson,omitempty"` + BuildOnly bool `json:"buildOnly,omitempty"` + URL string `json:"url,omitempty"` + Timeout int `json:"timeout,omitempty"` + InboundTag string `json:"inboundTag,omitempty"` +} + +type TestXrayResponse struct { + Delay int64 `json:"delay"` } type CheckRouteRequest struct { diff --git a/invoke_probes_test.go b/invoke_probes_test.go index ac6f9a45..b4de2715 100644 --- a/invoke_probes_test.go +++ b/invoke_probes_test.go @@ -46,6 +46,27 @@ func TestInvokeShareStatsResponseShape(t *testing.T) { } } +func TestInvokeConfigurationURLProbe(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + request := TestXrayRequest{XrayJson: `{"outbounds":[{"protocol":"freedom"}]}`, URL: server.URL, Timeout: 2} + response := invokeForTest(t, LibXrayMethodTestXray, request) + if !response.Success { + t.Fatal(response.Err) + } + result := decodeDataObject[TestXrayResponse](t, response) + if result.Delay < 0 || !strings.Contains(string(response.Data), `"delay"`) { + t.Fatalf("bad result: %s", response.Data) + } + request.BuildOnly = true + response = invokeForTest(t, LibXrayMethodTestXray, request) + if response.Success || string(response.Data) != "null" { + t.Fatalf("ambiguous request accepted: %+v", response) + } +} + func TestInvokePingLocationAndZeroDelayWireFields(t *testing.T) { raw, err := json.Marshal(PingBatchItemResponse{Success: true, Delay: 0}) if err != nil { diff --git a/readme/README.zh_CN.md b/readme/README.zh_CN.md index 7f402e21..b5f1f1df 100644 --- a/readme/README.zh_CN.md +++ b/readme/README.zh_CN.md @@ -179,6 +179,17 @@ cron 不会在只构建校验期间运行。 尚未启动的草稿应使用只构建校验;构建成功不代表运行资源可用,也不代表 instance 可以启动。调用方仍须处理真实构造和启动阶段的失败。 +### 配置 URL 测试 + +`testXray` 还可随 `xrayJson` 提供 `url`、`timeout`(1–60 秒)和可选 +`inboundTag`,返回整数毫秒 `data: {"delay": 12}`。`url` 与 +`buildOnly: true` 互斥;省略 `url` 时保留原校验响应。 + +测试使用草稿完整的 DNS、routing 和 outbounds 发送 HTTP HEAD,不强制单个出站。 +沿用路由检查的安全构造:禁用入站、日志输出和 webhook,拒绝 WireGuard/VLESS +reverse,不调用临时 instance 的 Start,也不发布为活动核心。结果不证明额外监听、 +仅在启动时工作的集成或所有目标均可用;生命周期锁和受管理核心重叠拒绝仍然生效。 + ### 草稿路由检查 `checkRoute` 是 API version 3 的增量方法。通过 `xrayJson` 接收完整草稿, diff --git a/xray/probe.go b/xray/probe.go new file mode 100644 index 00000000..ff397086 --- /dev/null +++ b/xray/probe.go @@ -0,0 +1,68 @@ +package xray + +import ( + "context" + "errors" + "net" + "net/http" + "net/url" + "strings" + "time" + + "github.com/xtls/libxray/nodep" + xnet "github.com/xtls/xray-core/common/net" + "github.com/xtls/xray-core/common/session" + "github.com/xtls/xray-core/core" +) + +// ProbeXray dispatches one HTTP request through the draft's real DNS, routing +// and outbounds. It does not start listeners or startup-only integrations, and +// is not proof that extra inbounds or all destinations work. Like CheckRoute, +// the caller must isolate it from unmanaged instances in the same process. +func ProbeXray(xrayJSON, targetURL string, timeout int, inboundTag string) (int64, error) { + uri, err := url.ParseRequestURI(targetURL) + if err != nil || uri.Host == "" || uri.User != nil || + (uri.Scheme != "http" && uri.Scheme != "https") || timeout < 1 || timeout > 60 { + return 0, errors.New("testXray requires an HTTP(S) URL and a timeout of 1–60 seconds") + } + coreServerMu.Lock() + defer coreServerMu.Unlock() + if coreServer != nil { + return 0, errors.New("testXray requires an isolated process without a managed Xray instance") + } + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) + defer cancel() + config, err := core.LoadConfig("json", strings.NewReader(xrayJSON)) + if err != nil { + return 0, errors.New("testXray configuration could not be built") + } + if _, _, err = prepareRouteCheck(config); err != nil { + return 0, err + } + server, err := core.NewWithContext(ctx, config) + if err != nil { + return 0, errors.New("testXray configuration could not be constructed") + } + defer server.Close() + transport := &http.Transport{ + DisableKeepAlives: true, + DialContext: func(call context.Context, network, address string) (net.Conn, error) { + destination, err := xnet.ParseDestination("tcp:" + address) + if err != nil { + return nil, err + } + call = session.ContextWithInbound(call, &session.Inbound{Tag: inboundTag}) + return core.Dial(call, server, destination) + }, + } + defer transport.CloseIdleConnections() + delay, err := nodep.PingHTTPRequest(&http.Client{ + Transport: transport, + Timeout: time.Duration(timeout) * time.Second, + }, targetURL, timeout) + if err != nil { + // HTTP errors may include a credential-bearing URL. Keep them local. + return 0, errors.New("testXray URL request failed") + } + return delay, nil +} diff --git a/xray/probe_test.go b/xray/probe_test.go new file mode 100644 index 00000000..9cf52063 --- /dev/null +++ b/xray/probe_test.go @@ -0,0 +1,48 @@ +package xray + +import ( + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +func TestProbeXrayUsesDraftDNSAndRoutingWithoutListening(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodHead { + t.Error("probe must use HEAD") + } + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + u, _ := url.Parse(server.URL) + config := fmt.Sprintf(`{ + "inbounds":[{"listen":"127.0.0.1","port":%s,"protocol":"socks"}], + "dns":{"hosts":{"probe.test":"127.0.0.1"}}, + "outbounds":[{"tag":"blocked","protocol":"blackhole"},{"tag":"ok","protocol":"freedom","settings":{"domainStrategy":"UseIP"}}], + "routing":{"rules":[{"inboundTag":["tunIn"],"domain":["full:probe.test"],"outboundTag":"ok"}]} + }`, u.Port()) + // The configured listener port is already occupied. Only the routed request + // should run; accidentally starting the raw listeners would fail this test. + target := "http://probe.test:" + u.Port() + "/" + if delay, err := ProbeXray(config, target, 2, "tunIn"); err != nil || delay < 0 { + t.Fatalf("routed probe: delay=%d err=%v", delay, err) + } + if _, err := ProbeXray(config, target, 1, "other"); err == nil { + t.Fatal("ignoring the draft routing incorrectly reached the target") + } + if GetXrayState() { + t.Fatal("probe published a managed instance") + } +} + +func TestProbeXrayRejectsUnsafeRequestWithoutLeakingURL(t *testing.T) { + for _, target := range []string{"file:///secret", "https://user:secret@example.com/"} { + _, err := ProbeXray(`{}`, target, 1, "") + if err == nil || strings.Contains(err.Error(), "secret") { + t.Fatalf("unsafe error: %v", err) + } + } +} From f49c42633fda23a9c5cf55bf0cad85da1d929f35 Mon Sep 17 00:00:00 2001 From: yiguo Date: Thu, 3 Sep 2026 05:18:47 +0800 Subject: [PATCH 06/16] build: record effective native build inputs and gomobile version --- .gitignore | 1 + README.md | 16 ++++ build/app/build.py | 105 ++++++++++++++++++++++++- build/test_build_metadata.py | 147 +++++++++++++++++++++++++++++++++++ readme/README.zh_CN.md | 13 ++++ 5 files changed, 279 insertions(+), 3 deletions(-) create mode 100644 build/test_build_metadata.py diff --git a/.gitignore b/.gitignore index 872c7051..53799c9f 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,4 @@ test/ config/ .DS_Store bin/ +/build/build-metadata-*.json diff --git a/README.md b/README.md index 67463993..243c2931 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,22 @@ python3 build/main.py windows local ``` +Before restoring `go.mod` and `go.sum`, each build attempt writes the ignored +`build/build-metadata-.json`, where builder is `android`, `apple-go`, +`apple-gomobile`, `linux`, or `windows`. It records the libXray commit and tracked +dirty state (including temporary module edits), Go version, effective +`go list -mod=readonly -m all` output, and SHA-256 hashes of the effective module +files. Gomobile builds resolve `latest` by default; the record includes that resolved +version and the actual PATH binary's module version and `go version -m` output. +Set `LIBXRAY_GOMOBILE_VERSION` to a Go module version to pin the resolution; `resolvedVersion` still records the resolved value. +Non-gomobile builds record `gomobile: null`. + +This is **build input evidence, not proof of a successful or matching artifact**: +failed builds also run this hook, and collection failures appear in `errors` or +as a warning without replacing the original build error. Consumers must check +the build command's success and the record's freshness; artifact verification +is separate. A missing or incomplete record must not be treated as verified input. + 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: diff --git a/build/app/build.py b/build/app/build.py index 8d467f8e..6f63bd96 100644 --- a/build/app/build.py +++ b/build/app/build.py @@ -1,5 +1,10 @@ +from datetime import datetime, timezone +import hashlib +import json import os.path +import shutil import subprocess +import sys from app.cmd import ( create_dir_if_not_exists, @@ -24,6 +29,7 @@ def __init__(self, build_dir: str, use_local_xray_core: bool = False): os.path.join(self.lib_dir, self.xray_core_replace_path) ) self._go_env_snapshot = None + self._gomobile_version = None def snapshot_go_env(self): paths = [ @@ -115,6 +121,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 +129,15 @@ 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") + self._gomobile_version = version ret = subprocess.run( [ @@ -193,4 +201,95 @@ def build(self): pass def after_build(self): - pass + # Called in finally, before restoring the effective module files. This + # records inputs even on failure; it never certifies an artifact. + try: + builder = { + "AndroidBuilder": "android", + "AppleGoBuilder": "apple-go", + "AppleGoMobileBuilder": "apple-gomobile", + "LinuxBuilder": "linux", + "WindowsBuilder": "windows", + }.get(type(self).__name__, type(self).__name__) + errors = [] + + def capture(label, operation): + try: + return operation() + except Exception as error: + errors.append(f"{label}: {error}") + return None + + def output(*command): + return subprocess.run( + command, + cwd=self.lib_dir, + check=True, + capture_output=True, + text=True, + timeout=60, + ).stdout.strip() + + def file_hash(name): + with open(os.path.join(self.lib_dir, name), "rb") as file: + return hashlib.sha256(file.read()).hexdigest() + + metadata = { + "schemaVersion": 1, + "evidence": "build-inputs-only", + "builder": builder, + "recordedAt": datetime.now(timezone.utc).isoformat(), + "goModSha256": capture("go.mod", lambda: file_hash("go.mod")), + "goSumSha256": capture("go.sum", lambda: file_hash("go.sum")), + "libXrayCommit": capture( + "git commit", lambda: output("git", "rev-parse", "HEAD") + ), + "goVersion": capture("Go version", lambda: output("go", "version")), + "modules": capture( + "Go modules", + lambda: output("go", "list", "-mod=readonly", "-m", "all"), + ), + "gomobile": None, + "errors": errors, + } + status = capture( + "git status", + lambda: output("git", "status", "--porcelain", "--untracked-files=no"), + ) + metadata["libXrayDirty"] = None if status is None else bool(status) + if self._gomobile_version is not None: + binary = shutil.which("gomobile") + build_info = None + used_version = None + if binary is None: + errors.append("gomobile binary: not found in PATH") + else: + build_info = capture( + "gomobile build info", + lambda: output("go", "version", "-m", binary), + ) + for line in (build_info or "").splitlines(): + fields = line.split() + if ( + fields[:2] == ["mod", "golang.org/x/mobile"] + and len(fields) >= 3 + ): + used_version = fields[2] + break + if build_info is not None and used_version is None: + errors.append("gomobile build info: module version missing") + metadata["gomobile"] = { + "resolvedVersion": self._gomobile_version, + "usedVersion": used_version, + "binary": binary, + "buildInfo": build_info, + } + path = os.path.join(self.build_dir, f"build-metadata-{builder}.json") + with open(path, "w", encoding="utf-8") as file: + json.dump(metadata, file, indent=2) + file.write("\n") + if errors: + print(f"Build input metadata is incomplete: {path}", file=sys.stderr) + except Exception as error: + # A metadata failure must not replace the original build exception. + print(f"Unable to record build input metadata: {error}", file=sys.stderr) diff --git a/build/test_build_metadata.py b/build/test_build_metadata.py new file mode 100644 index 00000000..347e056e --- /dev/null +++ b/build/test_build_metadata.py @@ -0,0 +1,147 @@ +"""Run: python3 build/test_build_metadata.py. No Go or platform build is run.""" +import hashlib +import io +import json +from pathlib import Path +import shutil +import subprocess +import unittest +from unittest.mock import patch +from uuid import uuid4 + +from app.android import AndroidBuilder +from app.build import Builder + + +class BuildMetadataTest(unittest.TestCase): + def setUp(self): + # Keep every test fixture in the permitted references tree, never /tmp. + self.root = ( + Path(__file__).resolve().parents[2] + / "references" + / "onexray-refactor-validation" + / "build-metadata" + / uuid4().hex + ) + (self.root / "build").mkdir(parents=True) + self.addCleanup(shutil.rmtree, self.root) + (self.root / "go.mod").write_text("original module\n") + (self.root / "go.sum").write_text("original sums\n") + self.builder = AndroidBuilder(str(self.root / "build")) + run_patch = patch("app.build.subprocess.run", side_effect=self.command) + self.run = run_patch.start() + self.addCleanup(run_patch.stop) + + def command(self, command, **kwargs): + command = list(command) + outputs = { + ("git", "rev-parse", "HEAD"): "abc123\n", + ("git", "status", "--porcelain", "--untracked-files=no"): " M go.mod\n", + ("go", "version"): "go version go1.26.6 darwin/arm64\n", + ("go", "list", "-mod=readonly", "-m", "all"): ( + "github.com/xtls/libxray\ngolang.org/x/mobile v0.0.0-resolved\n" + ), + ( + "go", "list", "-m", "-f", "{{.Version}}", + "golang.org/x/mobile@latest", + ): "v0.0.0-resolved\n", + ("go", "version", "-m", "/fixture/gomobile"): ( + "/fixture/gomobile: go1.26.6\n" + "\tmod\tgolang.org/x/mobile\tv0.0.0-actual\th1:fixture\n" + ), + } + return subprocess.CompletedProcess(command, 0, outputs.get(tuple(command), ""), "") + + def read(self, builder="android"): + path = self.root / "build" / f"build-metadata-{builder}.json" + return json.loads(path.read_text()) + + def test_effective_inputs_are_captured_before_restoration(self): + self.builder.snapshot_go_env() + (self.root / "go.mod").write_text("effective module\n") + (self.root / "go.sum").write_text("effective sums\n") + with patch.dict("app.build.os.environ", {"LIBXRAY_GOMOBILE_VERSION": ""}): + self.builder.prepare_gomobile() + with patch("app.build.shutil.which", return_value="/fixture/gomobile"): + self.builder.after_build() + self.builder.restore_go_env() + metadata = self.read() + self.assertEqual(metadata["evidence"], "build-inputs-only") + self.assertEqual(metadata["builder"], "android") + self.assertEqual(metadata["libXrayCommit"], "abc123") + self.assertTrue(metadata["libXrayDirty"]) + self.assertIn("go1.26.6", metadata["goVersion"]) + self.assertIn("golang.org/x/mobile v0.0.0-resolved", metadata["modules"]) + self.assertEqual( + metadata["goModSha256"], hashlib.sha256(b"effective module\n").hexdigest() + ) + self.assertEqual( + metadata["goSumSha256"], hashlib.sha256(b"effective sums\n").hexdigest() + ) + self.assertEqual(metadata["gomobile"]["resolvedVersion"], "v0.0.0-resolved") + self.assertEqual(metadata["gomobile"]["usedVersion"], "v0.0.0-actual") + self.assertEqual(metadata["errors"], []) + self.assertEqual((self.root / "go.mod").read_text(), "original module\n") + self.assertEqual((self.root / "go.sum").read_text(), "original sums\n") + for call in self.run.call_args_list: + if "check" in call.kwargs: + self.assertEqual(call.kwargs["cwd"], str(self.root)) + + def test_non_gomobile_build_has_its_own_name(self): + builder = type("AppleGoBuilder", (Builder,), {})(str(self.root / "build")) + builder.after_build() + metadata = self.read("apple-go") + self.assertIsNone(metadata["gomobile"]) + self.assertEqual(metadata["errors"], []) + + def test_gomobile_version_environment_selects_resolution_query(self): + version = "v0.0.0-20260821190718-4776eadac327" + with ( + patch.dict("app.build.os.environ", {"LIBXRAY_GOMOBILE_VERSION": version}), + patch( + "app.build.subprocess.run", + return_value=subprocess.CompletedProcess([], 0, version + "\n", ""), + ) as run, + ): + self.builder.prepare_gomobile() + self.assertEqual(run.call_args_list[0].args[0], [ + "go", "list", "-m", "-f", "{{.Version}}", + f"golang.org/x/mobile@{version}", + ]) + self.assertEqual(self.builder._gomobile_version, version) + + def test_collection_failure_keeps_original_build_error(self): + with ( + patch.object( + self.builder, "before_build", + side_effect=RuntimeError("original build failed"), + ), + patch("app.build.subprocess.run", side_effect=OSError("tool unavailable")), + patch("sys.stderr", new_callable=io.StringIO), + ): + with self.assertRaisesRegex(RuntimeError, "original build failed"): + self.builder.build() + metadata = self.read() + self.assertIsNone(metadata["libXrayCommit"]) + self.assertIsNone(metadata["modules"]) + self.assertTrue(metadata["errors"]) + self.assertIsNone(self.builder._go_env_snapshot) + + def test_write_failure_keeps_original_build_error(self): + blocked = self.root / "not-a-directory" + blocked.write_text("fixture") + self.builder.build_dir = str(blocked) + with ( + patch.object( + self.builder, "before_build", + side_effect=RuntimeError("original build failed"), + ), + patch("sys.stderr", new_callable=io.StringIO), + ): + with self.assertRaisesRegex(RuntimeError, "original build failed"): + self.builder.build() + self.assertIsNone(self.builder._go_env_snapshot) + + +if __name__ == "__main__": + unittest.main() diff --git a/readme/README.zh_CN.md b/readme/README.zh_CN.md index b5f1f1df..9f4b83e3 100644 --- a/readme/README.zh_CN.md +++ b/readme/README.zh_CN.md @@ -34,6 +34,19 @@ python3 build/main.py windows python3 build/main.py windows local ``` +每次构建尝试都会在恢复 `go.mod` 和 `go.sum` 前写入已忽略的 +`build/build-metadata-.json`;builder 为 `android`、`apple-go`、 +`apple-gomobile`、`linux` 或 `windows`。记录包含 libXray commit、受跟踪文件的 +dirty 状态(包括临时模块修改)、Go 版本、实际生效的 +`go list -mod=readonly -m all` 输出,以及生效模块文件的 SHA-256。 +gomobile 仍默认解析 `latest`,同时记录解析版本、实际 PATH 中二进制的模块版本和 +`go version -m` 输出;不使用 gomobile 的构建记录 `gomobile: null`。 +设置环境变量 `LIBXRAY_GOMOBILE_VERSION` 可指定 Go 模块版本,`resolvedVersion` 仍记录实际解析结果。 + +这些记录是**构建输入证据,不是构建成功或产物匹配的证明**。失败构建也会执行记录; +采集失败写入 `errors` 或输出警告,不会覆盖原始构建错误。使用方必须独立确认构建 +命令成功、记录属于本次构建,并另行验证产物;记录缺失或不完整不能视为输入已验证。 + Linux 和 Windows 构建还会生成 `bin/xray` 或 `bin/xray.exe`。该会话 Core 会保护 Go DNS 查询不被 VPN 路由重新捕获,并且只接受以下命令: From ac320ab020fb2be5f444f96e43cc10e0aa01f6d3 Mon Sep 17 00:00:00 2001 From: yiguo Date: Thu, 3 Sep 2026 11:55:47 +0800 Subject: [PATCH 07/16] feat: expose saved runtime statistics over loopback HTTP --- AGENTS.md | 12 +- README.md | 60 +++++++-- readme/README.zh_CN.md | 45 +++++-- xray/runtime.go | 37 +++++- xray/runtime_http.go | 186 ++++++++++++++++++++++++++ xray/runtime_http_test.go | 268 ++++++++++++++++++++++++++++++++++++++ xray/runtime_test.go | 24 +++- 7 files changed, 605 insertions(+), 27 deletions(-) create mode 100644 xray/runtime_http.go create mode 100644 xray/runtime_http_test.go diff --git a/AGENTS.md b/AGENTS.md index 2dca5eb4..fad84737 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,8 +98,10 @@ until `stopXray` closes the current instance. Optional `runXray.payload.runtime` saves only the current session's inbound counters periodically and on normal stop. Before replacing the current file, the previous session is archived for the App to reconcile. The App owns device -totals and reset; live reads use Xray's native metrics endpoint. Read README.md's -"Managed runtime accounting" section before changing session persistence. +totals and reset; live reads use Xray's native metrics endpoint. Optional runtime +`listen`/`token` expose saved snapshots and archive acknowledgments over an +authenticated loopback HTTP listener. Read README.md's "Managed runtime +accounting" section before changing persistence, acknowledgment, or HTTP access. `testXray` (default `buildOnly: false`) and `pingBatch` create temporary Xray instances. Xray-core has process-wide DNS client and outbound manager state. @@ -203,9 +205,9 @@ manually. # Development Rules -1. Use `Invoke` for typed commands and Xray metrics for live counters. Session - persistence does not introduce a second HTTP server. Platform-only controller - APIs remain isolated by build tags. +1. Use `Invoke` for typed commands, Xray metrics for live counters, and runtime + HTTP only for saved snapshots/archive acknowledgments. Keep App totals/reset + outside libXray and platform-only controller APIs 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 diff --git a/README.md b/README.md index 243c2931..899a619b 100644 --- a/README.md +++ b/README.md @@ -591,7 +591,9 @@ with this object (also the complete content of the desktop `-runtime` file): { "statePath": "/private/app/run/runtime.json", "planId": "opaque-plan-id", - "inboundTag": "tunIn" + "inboundTag": "tunIn", + "listen": "127.0.0.1:49228", + "token": "538fc3253a3e433491bc2d653fc74214" } ``` @@ -600,8 +602,12 @@ The host supplies an existing private directory and an absolute `statePath`. opaque and must not contain credentials. 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. -Invalid metadata, corrupt saved state, an archive failure, or an initial save -failure rejects startup; any constructed core is closed. +`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, corrupt +saved state, an archive failure, 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: @@ -635,7 +641,7 @@ 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, runtime HTTP endpoints, control ports, or tokens. +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. @@ -645,8 +651,9 @@ snapshot is atomically archived beside it as timestamps, and any unset ending; it does not infer missing traffic or a crash time. Repeated unsuccessful starts reuse the same archive filename. A failed preparation may therefore leave the same session in both current and archive; -consumers must identify sessions by ID, not count files. Archives are never -cleaned up by libXray, and their counters are never carried into the new session. +consumers must identify sessions by ID, not count files. Archives are retained +until the App explicitly acknowledges them; their counters are never carried +into the new session. The archive directory rejects symlinks/non-directories and is created mode 0700. Snapshot files use a mode-0600 same-directory temporary file, sync, and atomic @@ -654,7 +661,8 @@ 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 the file. +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 can lose the tail after the last successful save, with no strict 30-second loss bound. A restart archives only that last saved tail and does not fabricate final values. @@ -662,10 +670,40 @@ restart archives only that last saved tail and does not fabricate final values. A nonblocking OS lock on `statePath + ".lock"` is held until core close, preventing another process from writing the same current/archive sequence. Hosts must use one consistent canonical path and leave the lock file in place. -UI code reads snapshots but does not write them while the host owns the path. -This does not solve macOS System Extension root-owned file access or provide -graceful final settlement when Windows forcibly terminates a job. Those platform -boundaries remain the integrating application's responsibility. +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 `{"current": , "archived": [, ...]}`. +- `POST /runtime/ack` accepts `{"removeSessionIds": ["", ...]}` and + returns the same shape with the remaining archives. IDs must be 32-character + lowercase hex. Unknown IDs are harmless, repeated acknowledgment is safe, + and the current session is never deleted, including its duplicate archive. + +Requests read the host's saved atomic snapshots, without sampling, resetting +counters, or updating the save time. Use native metrics for live rates. The App +must durably save its totals/session watermarks **before** acknowledging archives; +if the HTTP request fails, retain the watermarks and retry. Failed deletions +remain in the returned archive list. Only valid snapshot files immediately in +the host's own `runtime-sessions` directory are eligible; request paths and +symlinks are rejected. A corrupt snapshot makes the request fail rather than +silently lose accounting data. + +Acknowledgment bodies are limited to 64 KiB and responses to 16 MiB; requests +have bounded read/write timeouts. There is no pagination. If unacknowledged +archives exceed the response limit, the request fails and the App retains its +last known data. While stopped, HTTP is unavailable: the App can show its own +persisted last-known snapshot/totals, retain reset watermarks, and reconcile +saved tails and archives on the next connection. libXray never owns App totals +or clear/reset policy. ### metrics diff --git a/readme/README.zh_CN.md b/readme/README.zh_CN.md index 9f4b83e3..933cb74a 100644 --- a/readme/README.zh_CN.md +++ b/readme/README.zh_CN.md @@ -474,14 +474,19 @@ API v3 的 `runXray.payload.runtime` 为可选宿主元数据。省略时保留 { "statePath": "/private/app/run/runtime.json", "planId": "opaque-plan-id", - "inboundTag": "tunIn" + "inboundTag": "tunIn", + "listen": "127.0.0.1:49228", + "token": "538fc3253a3e433491bc2d653fc74214" } ``` 宿主提供已存在的私有目录和绝对 `statePath`。`planId` / `inboundTag` 非空且 各不超过 256 字节;`planId` 是不包含凭据的不透明标识。元数据独立于 Xray JSON, 用户配置不能覆盖。指定入站必须存在,并启用上下行系统统计和 stats manager。 -元数据无效、已有快照损坏、归档失败或首次保存失败均拒绝启动,并关闭已构建的核心。 +`listen` / `token` 可同时省略,保留仅落盘、不启用 HTTP 的行为。启用时 `listen` +只能是 `127.0.0.1:`,端口范围 1–65535;宿主须生成新的 32 位小写十六进制 +随机 `token` 并保密,不能复用示例值。元数据无效、HTTP 端口被占用、已有快照损坏、 +归档失败或首次保存失败均拒绝启动,并关闭已构建的核心和统计监听器。 落盘文件仅包含本次会话的原始入站计数: @@ -511,28 +516,50 @@ API v3 的 `runXray.payload.runtime` 为可选宿主元数据。省略时保留 采样直接读取指定入站的 `Value()`,不重置计数,不叠加节点或 outbound 计数。 重复采样不累加字节;非负计数回退时保存实际较小值,不合成差额。计数缺失或为负时, `available: false`、`error: "counters_unavailable"`,保留上次合法的非负值。 -有效入站尚无流量时为可用的 0。不维护 App 总量、重置代次、runtime HTTP 接口、 -控制端口或 token。`resetRuntime` 不是 Invoke method。App 可通过已有 Xray metrics +有效入站尚无流量时为可用的 0。不维护 App 总量、重置代次,也不提供 VPN 控制 HTTP +方法。`resetRuntime` 不是 Invoke method。App 可通过已有 Xray metrics 读取实时速率;App 累计与重置策略由 App 自行管理,不属于 libXray。 新会话覆盖 `runtime.json` 前,先将已有合法快照原子归档到同级目录 `runtime-sessions/.json`。归档保留原始计数、时间及可能未设置的结束 时间,不推测丢失流量或崩溃时间。重复启动失败使用同一个归档文件名;准备失败时, 当前文件和归档可能同时存在相同会话,消费者必须按 session ID 识别,不能按文件数 -重复计入。libXray 不清理归档,也不把旧计数继承到新会话。归档目录以 0700 创建, +重复计入。归档保留至 App 显式确认结算,不把旧计数继承到新会话。归档目录以 0700 创建, 拒绝符号链接和非目录对象。 快照文件使用同目录 0600 临时文件,sync 后原子替换;Windows 使用 `MoveFileEx` 的替换和 write-through 标志。私有父目录/Windows ACL 由宿主管理。 保存失败保留上次完整磁盘快照供后续重试;最终保存失败向调用方报告,但仍关闭核心。 -rename 后发生 I/O 错误时结果可能不确定,消费者应重新读取文件。这是参考数据, +rename 后发生 I/O 错误时结果可能不确定,消费者应在 HTTP 可用时重新读取已保存的快照。这是参考数据, 不是计费账本:崩溃/强杀允许丢失最后成功保存后的尾部,不承诺严格 30 秒丢失上限。 下次启动只归档已有快照,不伪造最终计数。 `statePath + ".lock"` 的非阻塞操作系统文件锁保持至核心关闭,防止跨进程同时 -改写当前快照和归档。宿主须使用一致的规范路径并保留锁文件。UI 只读快照,不能在 -宿主持有路径期间直接写入。此能力不解决 macOS System Extension 的 root 文件访问 -权限,也不能让 Windows Job 强制终止获得正常最终结算;这些平台边界仍由接入方处理。 +改写当前快照和归档。宿主须使用一致的规范路径并保留锁文件。App 经 HTTP 读取快照, +无需打开宿主文件,因此 macOS System Extension 文件可继续归 root 所有。此能力 +不能让 Windows Job 强制终止获得正常最终结算。 + +#### 快照 HTTP + +可选统计监听器随托管会话启动,在停止时关闭,最终保存失败也会关闭。它使用独立于 +Xray 原生 metrics 的回环端口,不提供 VPN 启停或配置方法。所有请求必须携带 +`Authorization: Bearer `;响应使用 `Cache-Control: no-store`,不启用 CORS。 + +- `GET /runtime` 返回 `{"current": , "archived": [, ...]}`。 +- `POST /runtime/ack` 接收 `{"removeSessionIds": ["", ...]}`,返回相同结构, + 其中只保留剩余归档。ID 必须为 32 位小写十六进制;不存在的 ID 无副作用,支持重复确认; + 当前会话及其重复归档永不删除。 + +请求只读取宿主已保存的原子快照,不触发采样、计数重置或保存时间更新;实时速率仍使用 +原生 metrics。App 必须**先持久保存累计值及会话水位,再确认归档**;HTTP 请求失败时 +保留水位并重试。删除失败的归档仍出现在响应列表。仅宿主自身 `runtime-sessions` +目录直接包含的合法快照文件可被清理,不接受请求路径,拒绝符号链接。快照损坏会使 +请求失败,不静默丢弃统计数据。 + +确认请求体限制 64 KiB,响应限制 16 MiB,读写有超时限制,不提供分页。未确认归档超出 +响应上限时请求失败,App 保留最后已知数据。停止期间 HTTP 不可用:App 可展示自身 +持久化的最后已知快照及累计值,保留清零水位,在下次连接时补结算保存的尾部与归档。 +libXray 不维护 App 累计值或清零策略。 ### metrics diff --git a/xray/runtime.go b/xray/runtime.go index 6d6ad768..93559433 100644 --- a/xray/runtime.go +++ b/xray/runtime.go @@ -7,9 +7,12 @@ import ( "encoding/json" "errors" "io" + "net" + "net/http" "os" "path/filepath" "strings" + "sync" "time" "github.com/xtls/xray-core/core" @@ -23,6 +26,8 @@ type RuntimeConfig struct { StatePath string `json:"statePath"` PlanID string `json:"planId"` InboundTag string `json:"inboundTag"` + Listen string `json:"listen,omitempty"` + Token string `json:"token,omitempty"` } type RuntimeSession struct { @@ -51,6 +56,10 @@ type managedRuntime struct { manager stats.Manager stateLock *os.File stopTicker, tickDone chan struct{} + httpServer *http.Server + httpListener net.Listener + httpMu sync.Mutex + httpClosed bool } func prepareRuntime(config *RuntimeConfig) (*managedRuntime, error) { @@ -61,6 +70,9 @@ func prepareRuntime(config *RuntimeConfig) (*managedRuntime, error) { strings.TrimSpace(config.InboundTag) == "" || len(config.InboundTag) > 256 { return nil, errors.New("runtime requires an absolute statePath, planId, and inboundTag") } + if err := validateRuntimeHTTP(config); err != nil { + return nil, err + } stateLock, err := lockRuntimeState(config.StatePath) if err != nil { return nil, err @@ -139,10 +151,20 @@ func (r *managedRuntime) counterName(direction string) string { } 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) @@ -199,13 +221,26 @@ 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 connections, then finish any filesystem request before releasing + // the session owner lock. Queued handlers cannot acknowledge after stop. + r.httpMu.Lock() + r.httpClosed = true + r.httpMu.Unlock() + } 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 r.save() + return errors.Join(httpErr, r.save()) } func readRuntimeState(path string) (RuntimeSnapshot, error) { diff --git a/xray/runtime_http.go b/xray/runtime_http.go new file mode 100644 index 00000000..20fdd17f --- /dev/null +++ b/xray/runtime_http.go @@ -0,0 +1,186 @@ +package xray + +import ( + "crypto/subtle" + "encoding/hex" + "encoding/json" + "errors" + "io" + "net" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +const runtimeResponseLimit = 16 * 1024 * 1024 + +type runtimeFiles struct { + Current *RuntimeSnapshot `json:"current"` + Archived []RuntimeSnapshot `json:"archived"` +} + +func validRuntimeID(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 || !validRuntimeID(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 + } + var removeSessionIDs []string + switch request.URL.Path { + case "/runtime": + if request.Method != http.MethodGet { + w.Header().Set("Allow", http.MethodGet) + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + case "/runtime/ack": + if request.Method != http.MethodPost { + w.Header().Set("Allow", http.MethodPost) + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var payload struct { + RemoveSessionIDs []string `json:"removeSessionIds"` + } + request.Body = http.MaxBytesReader(w, request.Body, 64*1024) + decoder := json.NewDecoder(request.Body) + decoder.DisallowUnknownFields() + var extra any + if decoder.Decode(&payload) != nil || decoder.Decode(&extra) != io.EOF || payload.RemoveSessionIDs == nil { + http.Error(w, "invalid runtime acknowledgment", http.StatusBadRequest) + return + } + for _, id := range payload.RemoveSessionIDs { + if !validRuntimeID(id) { + http.Error(w, "invalid runtime session ID", http.StatusBadRequest) + return + } + } + removeSessionIDs = payload.RemoveSessionIDs + default: + http.NotFound(w, request) + return + } + // Files are atomically replaced by the ticker; HTTP never reads or mutates + // its in-memory sample. Serialize only archive readers/acknowledgments. + r.httpMu.Lock() + defer r.httpMu.Unlock() + if r.httpClosed || request.Context().Err() != nil { + http.Error(w, "runtime stopped", http.StatusServiceUnavailable) + return + } + files, err := r.readHTTPFiles(removeSessionIDs) + if err != nil { + http.Error(w, "runtime snapshots unavailable", http.StatusServiceUnavailable) + return + } + data, err := json.Marshal(files) + if err != nil || len(data) > runtimeResponseLimit { + http.Error(w, "runtime snapshots exceed response limit", http.StatusServiceUnavailable) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(data) +} + +func (r *managedRuntime) readHTTPFiles(removeSessionIDs []string) (runtimeFiles, error) { + files := runtimeFiles{Archived: []RuntimeSnapshot{}} + current, err := readRuntimeState(r.config.StatePath) + if err != nil { + return files, err + } + if current.Version != 0 { + files.Current = ¤t + } else if len(removeSessionIDs) != 0 { + return files, errors.New("runtime current session is unavailable") + } + directory := filepath.Join(filepath.Dir(r.config.StatePath), "runtime-sessions") + info, err := os.Lstat(directory) + if errors.Is(err, os.ErrNotExist) { + return files, nil + } + if err != nil || !info.IsDir() { + return files, errors.New("runtime archive directory is unavailable") + } + entries, err := os.ReadDir(directory) + if err != nil { + return files, err + } + remove := make(map[string]bool, len(removeSessionIDs)) + for _, id := range removeSessionIDs { + if id != current.Session.ID { + remove[id] = true + } + } + // Bound the accumulated response before marshaling the complete envelope. + encoded, _ := json.Marshal(files) + size := len(encoded) + for _, entry := range entries { + id, isJSON := strings.CutSuffix(entry.Name(), ".json") + if !isJSON || !validRuntimeID(id) { + continue + } + path := filepath.Join(directory, entry.Name()) + state, err := readRuntimeState(path) + if err != nil || state.Version == 0 || state.Session.ID != id { + return files, errors.New("runtime archive is invalid") + } + if remove[id] && os.Remove(path) == nil { + continue + } + encoded, _ := json.Marshal(state) + size += len(encoded) + 1 + if size > runtimeResponseLimit { + return files, errors.New("runtime snapshots exceed response limit") + } + files.Archived = append(files.Archived, state) + } + return files, nil +} diff --git a/xray/runtime_http_test.go b/xray/runtime_http_test.go new file mode 100644 index 00000000..574961cc --- /dev/null +++ b/xray/runtime_http_test.go @@ -0,0 +1,268 @@ +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, body string, status int) runtimeFiles { + t.Helper() + request, err := http.NewRequest(method, "http://"+config.Listen+path, strings.NewReader(body)) + if err != nil { + t.Fatal(err) + } + if token != "" { + request.Header.Set("Authorization", "Bearer "+token) + } + request.Header.Set("Content-Type", "application/json") + 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 files runtimeFiles + if status == http.StatusOK { + if response.Header.Get("Content-Type") != "application/json" || json.Unmarshal(data, &files) != nil || files.Archived == nil { + t.Fatalf("invalid runtime response: %s", data) + } + } + return files +} + +func TestRuntimeHTTPAuthenticationArchivesAcknowledgmentAndStop(t *testing.T) { + config := runtimeHTTPConfig(t) + previous, up, down := runtimeFixture(t, config) + up.Add(5) + down.Add(7) + archived := saveRuntimeSample(t, previous) + _ = previous.stateLock.Close() + 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) + requestRuntime(t, config, http.MethodPost, "/runtime/ack", "", `{"removeSessionIds":["`+archived.Session.ID+`"]}`, http.StatusUnauthorized) + files := requestRuntime(t, config, http.MethodGet, "/runtime", config.Token, "", http.StatusOK) + if files.Current == nil || files.Current.Session.Uplink != 17 || files.Current.Session.Downlink != 23 || len(files.Archived) != 1 || files.Archived[0] != archived { + t.Fatalf("wrong current/archived snapshots: %+v", files) + } + current := *files.Current + up.Add(100) + files = requestRuntime(t, config, http.MethodGet, "/runtime", config.Token, "", http.StatusOK) + if *files.Current != current || up.Value() != 117 { + t.Fatal("HTTP must read saved snapshots without sampling or resetting metrics") + } + // A duplicate current archive is never acknowledged while it remains current. + if err := archiveRuntimeState(config.StatePath, current); err != nil { + t.Fatal(err) + } + body := `{"removeSessionIds":["` + archived.Session.ID + `","` + current.Session.ID + `","` + strings.Repeat("c", 32) + `"]}` + for range 2 { + files = requestRuntime(t, config, http.MethodPost, "/runtime/ack", config.Token, body, http.StatusOK) + if *files.Current != current || len(files.Archived) != 1 || files.Archived[0] != current { + t.Fatalf("ack was not idempotent or removed current: %+v", files) + } + } + archivePath := filepath.Join(filepath.Dir(config.StatePath), "runtime-sessions", archived.Session.ID+".json") + if _, err := os.Lstat(archivePath); !os.IsNotExist(err) { + t.Fatal("acknowledged archive still exists") + } + 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 TestRuntimeHTTPRejectsInvalidAcknowledgments(t *testing.T) { + config := runtimeHTTPConfig(t) + runtime, _, _ := runtimeFixture(t, config) + if err := runtime.start(); err != nil { + t.Fatal(err) + } + archive := savedRuntime(t, config.StatePath) + archive.Session.ID = strings.Repeat("b", 32) + if err := archiveRuntimeState(config.StatePath, archive); err != nil { + t.Fatal(err) + } + for _, body := range []string{ + `{`, `{}`, `{"removeSessionIds":null}`, + `{"removeSessionIds":["` + archive.Session.ID + `","../../outside"]}`, + `{"removeSessionIds":["` + strings.Repeat("A", 32) + `"]}`, + `{"removeSessionIds":[],"path":"outside"}`, + `{"removeSessionIds":[]} {}`, + `{"removeSessionIds":[]}` + strings.Repeat(" ", 64*1024), + } { + requestRuntime(t, config, http.MethodPost, "/runtime/ack", config.Token, body, http.StatusBadRequest) + } + files := requestRuntime(t, config, http.MethodGet, "/runtime", config.Token, "", http.StatusOK) + if len(files.Archived) != 1 || files.Archived[0] != archive { + t.Fatal("invalid request partially acknowledged an archive") + } + requestRuntime(t, config, http.MethodPost, "/runtime", config.Token, "", http.StatusMethodNotAllowed) + requestRuntime(t, config, http.MethodGet, "/runtime/ack", config.Token, "", http.StatusMethodNotAllowed) + requestRuntime(t, config, http.MethodGet, "/control", config.Token, "", http.StatusNotFound) + if err := os.Remove(config.StatePath); err != nil { + t.Fatal(err) + } + files = requestRuntime(t, config, http.MethodGet, "/runtime", config.Token, "", http.StatusOK) + if files.Current != nil || len(files.Archived) != 1 { + t.Fatal("missing current snapshot was not reported as null") + } + requestRuntime(t, config, http.MethodPost, "/runtime/ack", config.Token, `{"removeSessionIds":["`+archive.Session.ID+`"]}`, http.StatusServiceUnavailable) +} + +func TestRuntimeHTTPRejectsSnapshotSymlinksAndInvalidArchives(t *testing.T) { + for _, target := range []string{"current", "archive-directory", "archive-file", "archive-id"} { + t.Run(target, func(t *testing.T) { + config := runtimeHTTPConfig(t) + runtime, _, _ := runtimeFixture(t, config) + if err := runtime.start(); err != nil { + t.Fatal(err) + } + state := savedRuntime(t, config.StatePath) + state.Session.ID = strings.Repeat("b", 32) + outside := filepath.Join(t.TempDir(), state.Session.ID+".json") + if err := writeRuntimeState(outside, state); err != nil { + t.Fatal(err) + } + archive := filepath.Join(filepath.Dir(config.StatePath), "runtime-sessions") + link, destination := config.StatePath, outside + switch target { + case "current": + if err := os.Remove(config.StatePath); err != nil { + t.Fatal(err) + } + case "archive-directory": + link, destination = archive, filepath.Dir(outside) + case "archive-file", "archive-id": + if err := os.Mkdir(archive, 0700); err != nil { + t.Fatal(err) + } + link = filepath.Join(archive, state.Session.ID+".json") + } + if target == "archive-id" { + invalid := state + invalid.Session.ID = strings.Repeat("c", 32) + if err := writeRuntimeState(link, invalid); err != nil { + t.Fatal(err) + } + } else if err := os.Symlink(destination, link); err != nil { + t.Skipf("symbolic links unavailable: %v", err) + } + requestRuntime(t, config, http.MethodGet, "/runtime", config.Token, "", http.StatusServiceUnavailable) + requestRuntime(t, config, http.MethodPost, "/runtime/ack", config.Token, `{"removeSessionIds":["`+state.Session.ID+`"]}`, http.StatusServiceUnavailable) + if savedRuntime(t, outside) != state { + t.Fatal("HTTP changed a snapshot outside its archive") + } + }) + } +} + +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 TestRuntimeHTTPConcurrentReadsAndAcknowledgments(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.Go(func() { + for range 30 { + requestRuntime(t, config, http.MethodPost, "/runtime/ack", config.Token, `{"removeSessionIds":[]}`, http.StatusOK) + } + }) + workers.Wait() +} diff --git a/xray/runtime_test.go b/xray/runtime_test.go index 251357e9..e4a071a3 100644 --- a/xray/runtime_test.go +++ b/xray/runtime_test.go @@ -212,6 +212,15 @@ func TestRuntimeConfigAndStateBoundary(t *testing.T) { func(c *RuntimeConfig) { c.InboundTag = "" }, func(c *RuntimeConfig) { c.PlanID = " " }, func(c *RuntimeConfig) { c.PlanID = strings.Repeat("x", 257) }, + 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) @@ -240,7 +249,7 @@ func TestRuntimeConfigAndStateBoundary(t *testing.T) { func TestManagedRuntimeStartFailureAndStop(t *testing.T) { t.Cleanup(func() { _ = StopXray() }) - config := runtimeConfig(t) + config := runtimeHTTPConfig(t) if err := RunXrayWithRuntime(minimalConfig, &config); err == nil || GetXrayState() { t.Fatal("missing statistics were accepted") } @@ -255,6 +264,14 @@ func TestManagedRuntimeStartFailureAndStop(t *testing.T) { 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() { @@ -273,6 +290,11 @@ func TestManagedRuntimeStartFailureAndStop(t *testing.T) { 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) From 613b636b342e420f572b21fc760d647769bb5940 Mon Sep 17 00:00:00 2001 From: yiguo Date: Fri, 4 Sep 2026 14:01:32 +0800 Subject: [PATCH 08/16] refactor: return raw ping location responses --- AGENTS.md | 4 +++- README.md | 13 +++++----- invoke.go | 2 +- invoke_model.go | 10 ++++---- invoke_probes_test.go | 21 +++++++++++++--- readme/README.zh_CN.md | 10 ++++---- xray/ping_batch.go | 43 +++++++-------------------------- xray/ping_location_test.go | 49 +++++++++++++++++++++++--------------- 8 files changed, 75 insertions(+), 77 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fad84737..fa3a0869 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,7 +88,9 @@ 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`. +`streamSettings.sockopt.dialerProxy` or `proxySettings.tag`. Its optional +location request returns the response body unchanged in `locationJson`; the App +owns JSON parsing and provider-specific semantics. # Runtime Semantics diff --git a/README.md b/README.md index 899a619b..714aee83 100644 --- a/README.md +++ b/README.md @@ -553,13 +553,12 @@ Location time is not included in `delay`, and the two results are independent: invalidate a successful latency result, and GET is still attempted after a latency failure. -A successful GET adds `location: {"ip":"203.0.113.1","countryCode":"JP"}`. -The provider must return HTTP 200 and at most 64 KiB of JSON containing -Cloudflare's `ip_address`/`country` pair or normalized `ip`/`countryCode`. -The IP must be valid and the country code must be two ASCII letters (returned -uppercase). An invalid/missing location instead adds `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. +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 diff --git a/invoke.go b/invoke.go index 43a6c14d..4fad8cda 100644 --- a/invoke.go +++ b/invoke.go @@ -222,7 +222,7 @@ func invokePingBatch(payload json.RawMessage) string { Success: result.Success, Delay: result.Delay, Error: result.Error, - Location: result.Location, + LocationJSON: result.LocationJSON, LocationError: result.LocationError, } } diff --git a/invoke_model.go b/invoke_model.go index ad4cb54f..5365c669 100644 --- a/invoke_model.go +++ b/invoke_model.go @@ -100,11 +100,11 @@ type PingBatchResponse struct { } type PingBatchItemResponse struct { - Success bool `json:"success"` - Delay int64 `json:"delay"` - Error string `json:"error,omitempty"` - Location *xray.PingLocation `json:"location,omitempty"` - LocationError string `json:"locationError,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 { diff --git a/invoke_probes_test.go b/invoke_probes_test.go index b4de2715..d19b9c15 100644 --- a/invoke_probes_test.go +++ b/invoke_probes_test.go @@ -75,8 +75,17 @@ 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"}` server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - fmt.Fprint(w, `{"ip_address":"203.0.113.1","country":"SG"}`) + fmt.Fprint(w, locationBody) })) defer server.Close() request := PingBatchRequest{ @@ -88,12 +97,18 @@ func TestInvokePingLocationAndZeroDelayWireFields(t *testing.T) { t.Fatalf("error = %s", response.Err) } result := decodeDataObject[PingBatchResponse](t, response).Results[0] - if !result.Success || result.Location == nil || result.Location.CountryCode != "SG" || result.Location.IP != "203.0.113.1" { + 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("legacy response = %+v", response) + t.Fatalf("latency-only response = %+v", response) } } diff --git a/readme/README.zh_CN.md b/readme/README.zh_CN.md index 933cb74a..86a006b0 100644 --- a/readme/README.zh_CN.md +++ b/readme/README.zh_CN.md @@ -438,12 +438,10 @@ outbound 依赖会被自动包含。 `success`、`delay`、`error` 只表示延迟结果,位置失败不影响成功的延迟,延迟 失败后仍尝试位置 GET。 -GET 成功后增加 `location: {"ip":"203.0.113.1","countryCode":"JP"}`。 -数据源必须返回 HTTP 200、最大 64 KiB 的 JSON,字段为 Cloudflare 的 -`ip_address` / `country`,或规范化的 `ip` / `countryCode`。IP 必须有效, -地区代码为两个 ASCII 字母并统一返回大写。缺失或无效的位置改为返回 -`locationError`,错误不回显 URL、凭据或响应正文。无效 outbound 保留原有 -逐项失败结果,不发出这两个请求。 +GET 成功后把响应正文原样放入 `locationJson` 字符串。JSON 解析和数据源专属字段 +处理由 App 负责。数据源必须返回 HTTP 200,正文最大 64 KiB;传输或正文读取失败 +改为返回 `locationError`。错误不回显 URL、凭据或响应正文。无效 outbound 保留 +原有逐项失败结果,不发出这两个请求。 ### testXray diff --git a/xray/ping_batch.go b/xray/ping_batch.go index 356622de..2014dc2d 100644 --- a/xray/ping_batch.go +++ b/xray/ping_batch.go @@ -34,15 +34,10 @@ type PingBatchResult struct { Success bool Delay int64 Error string - Location *PingLocation + LocationJSON *string LocationError string } -type PingLocation struct { - IP string `json:"ip"` - CountryCode string `json:"countryCode"` -} - type pingOutboundConfig struct { Outbounds []conf.OutboundDetourConfig `json:"outbounds"` } @@ -412,54 +407,32 @@ func probeOutbound( result = failedPingBatchResult(delay, err) } if locationURL != "" { - location, err := probeLocation(client, locationURL) + locationJSON, err := probeLocation(client, locationURL) if err != nil { result.LocationError = err.Error() } else { - result.Location = location + result.LocationJSON = &locationJSON } } return result } -func probeLocation(client *http.Client, locationURL string) (*PingLocation, error) { +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 nil, errors.New("location request failed") + return "", errors.New("location request failed") } defer response.Body.Close() if response.StatusCode != http.StatusOK { - return nil, fmt.Errorf("location request returned HTTP %d", response.StatusCode) + 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 nil, errors.New("unable to read location response") - } - // Cloudflare's location response and the equivalent normalized pair are - // supported; an unavailable or invalid country is not guessed from the IP. - var location struct { - IP string `json:"ip"` - CountryCode string `json:"countryCode"` - IPAddress string `json:"ip_address"` - Country string `json:"country"` - } - if err := json.Unmarshal(body, &location); err != nil { - return nil, errors.New("invalid location response") - } - if location.IP == "" { - location.IP = location.IPAddress - } - if location.CountryCode == "" { - location.CountryCode = location.Country - } - code := strings.ToUpper(strings.TrimSpace(location.CountryCode)) - ip := net.ParseIP(strings.TrimSpace(location.IP)) - if ip == nil || len(code) != 2 || code[0] < 'A' || code[0] > 'Z' || code[1] < 'A' || code[1] > 'Z' { - return nil, errors.New("location response requires an IP and two-letter country code") + return "", errors.New("unable to read location response") } - return &PingLocation{IP: ip.String(), CountryCode: code}, nil + 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 index 306b5d57..8c6f3d45 100644 --- a/xray/ping_location_test.go +++ b/xray/ping_location_test.go @@ -29,10 +29,10 @@ func TestPingBatchLocationUsesEachForcedOutbound(t *testing.T) { t.Fatal(err) } first, second := results[0], results[1] - if !first.Success || first.Location == nil || first.Location.IP != "203.0.113.9" || first.Location.CountryCode != "JP" || first.LocationError != "" { + 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.Location != nil || second.LocationError == "" { + 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 { @@ -53,15 +53,15 @@ func TestPingBatchLocationFailureDoesNotFailLatency(t *testing.T) { t.Fatal(err) } result := results[0] - if !result.Success || result.Error != "" || result.Location != nil || result.LocationError != "location request returned HTTP 503" { + 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].Location != nil || results[0].LocationError != "" { - t.Fatalf("legacy result = %+v", results[0]) + if !results[0].Success || results[0].LocationJSON != nil || results[0].LocationError != "" { + t.Fatalf("latency-only result = %+v", results[0]) } } @@ -74,34 +74,45 @@ func TestPingBatchLocationCanSucceedWhenLatencyFails(t *testing.T) { } return } - fmt.Fprint(w, `{"ip":"2001:db8::1","countryCode":"US"}`) + 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].Location == nil || results[0].LocationError != "" { + 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 TestProbeLocationRejectsInvalidResponsesWithoutLeakingInput(t *testing.T) { - for _, body := range []string{ - `{"ip":"203.0.113.9","countryCode":"Japan"}`, - `{"ip":"private-source","countryCode":"JP"}`, - `{"ip":"203.0.113.9"}`, `private-source`, strings.Repeat("x", 64*1024+1), - } { +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) })) - location, err := probeLocation(server.Client(), server.URL) + locationJSON, err := probeLocation(server.Client(), server.URL) server.Close() - if err == nil || location != nil { - t.Fatalf("location = %+v, error = %v", location, err) - } - if strings.Contains(err.Error(), "private-source") { - t.Fatal("response body leaked") + 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() From 8d1f2ca76e20a31f80ca304eb2b769361dccd7ab Mon Sep 17 00:00:00 2001 From: yiguo Date: Fri, 4 Sep 2026 18:02:07 +0800 Subject: [PATCH 09/16] refactor: simplify managed runtime accounting --- AGENTS.md | 18 ++--- README.md | 83 ++++++++-------------- invoke_model.go | 2 +- invoke_test.go | 20 +++--- readme/README.zh_CN.md | 60 ++++++---------- xray/runtime.go | 45 ++---------- xray/runtime_http.go | 125 +++----------------------------- xray/runtime_http_test.go | 145 +++++++++----------------------------- xray/runtime_test.go | 75 ++++++-------------- 9 files changed, 141 insertions(+), 432 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fa3a0869..80fd0575 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,12 +32,12 @@ the generic API. # Invoke API Contract -The current API version is `3`. Requests using an omitted or different +The current API version is `4`. Requests using an omitted or different `apiVersion` are rejected. ```json { - "apiVersion": 3, + "apiVersion": 4, "method": "runXray", "payload": { "xrayJson": "{\"outbounds\":[...]}" @@ -98,12 +98,12 @@ owns JSON parsing and provider-specific semantics. until `stopXray` closes the current instance. Optional `runXray.payload.runtime` saves only the current session's inbound -counters periodically and on normal stop. Before replacing the current file, -the previous session is archived for the App to reconcile. The App owns device -totals and reset; live reads use Xray's native metrics endpoint. Optional runtime -`listen`/`token` expose saved snapshots and archive acknowledgments over an -authenticated loopback HTTP listener. Read README.md's "Managed runtime -accounting" section before changing persistence, acknowledgment, or HTTP access. +counters periodically and on normal stop. A new session replaces the previous +saved session without archiving it. The App owns device totals and reset; live +reads use Xray's native metrics endpoint. Optional runtime `listen`/`token` +expose the current saved snapshot over an authenticated loopback HTTP listener. +Read README.md's "Managed runtime accounting" section before changing +persistence or HTTP access. `testXray` (default `buildOnly: false`) and `pingBatch` create temporary Xray instances. Xray-core has process-wide DNS client and outbound manager state. @@ -208,7 +208,7 @@ manually. # Development Rules 1. Use `Invoke` for typed commands, Xray metrics for live counters, and runtime - HTTP only for saved snapshots/archive acknowledgments. Keep App totals/reset + HTTP only for the current saved snapshot. Keep App totals/reset outside libXray and platform-only controller APIs 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. diff --git a/README.md b/README.md index 714aee83..7230ca46 100644 --- a/README.md +++ b/README.md @@ -175,7 +175,7 @@ The request is a JSON object: ```json { - "apiVersion": 3, + "apiVersion": 4, "method": "runXray", "payload": { "xrayJson": "{\"outbounds\":[...]}" @@ -195,7 +195,7 @@ The response is a JSON object: Design notes: -1. Invoke currently accepts only `apiVersion: 3`. Xray configurations are +1. Invoke currently accepts only `apiVersion: 4`. 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 @@ -277,13 +277,13 @@ The lifecycle lock and managed-instance overlap rejection still apply. ### Draft route checking -`checkRoute` is additive to API version 3. It accepts a complete draft in +In API version 4, `checkRoute` accepts a complete draft in `xrayJson` and calls the pinned Xray-core Router, without starting the temporary instance or dispatching traffic to the supplied target: ```json { - "apiVersion": 3, + "apiVersion": 4, "method": "checkRoute", "payload": { "xrayJson": "{\"outbounds\":[{\"tag\":\"direct\",\"protocol\":\"freedom\"}]}", @@ -461,7 +461,7 @@ decrypted in memory and limited to 16 MiB of plaintext. ```json { - "apiVersion": 3, + "apiVersion": 4, "method": "convertShareLinksToXrayJson", "payload": { "text": "-----BEGIN AGE ENCRYPTED FILE-----\n...", @@ -479,7 +479,7 @@ Generate a new keypair with `keyType` set to `x25519` or `hybrid`. An omitted ```json { - "apiVersion": 3, + "apiVersion": 4, "method": "generateAgeKeyPair", "payload": { "keyType": "x25519" @@ -512,7 +512,7 @@ by the `proxy` tag, and finally by the first outbound. ```json { - "apiVersion": 3, + "apiVersion": 4, "method": "pingBatch", "payload": { "configs": [ @@ -567,7 +567,7 @@ configuration file: ```json { - "apiVersion": 3, + "apiVersion": 4, "method": "testXray", "payload": { "xrayJson": "{\"outbounds\":[...]}" @@ -582,14 +582,13 @@ 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 +`runXray.payload.runtime` is optional API v4 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", - "planId": "opaque-plan-id", "inboundTag": "tunIn", "listen": "127.0.0.1:49228", "token": "538fc3253a3e433491bc2d653fc74214" @@ -597,16 +596,15 @@ with this object (also the complete content of the desktop `-runtime` file): ``` The host supplies an existing private directory and an absolute `statePath`. -`planId` and `inboundTag` must be nonempty and at most 256 bytes. `planId` is -opaque and must not contain credentials. 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. +`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, corrupt -saved state, an archive failure, or an initial save failure rejects startup; -any constructed core and statistics listener are closed. +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: @@ -615,7 +613,6 @@ The saved file contains only the current session's raw inbound counter values: "version": 1, "session": { "id": "2a7e2e49b947a802d8b39af4fbc48f52", - "planId": "opaque-plan-id", "startedAtMs": 1788300000000, "endedAtMs": 0, "uplink": 120, @@ -644,16 +641,10 @@ 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. -Before a new session can replace `runtime.json`, the previous valid saved -snapshot is atomically archived beside it as -`runtime-sessions/.json`. The archive preserves its raw counters, -timestamps, and any unset ending; it does not infer missing traffic or a crash -time. Repeated unsuccessful starts reuse the same archive filename. A failed -preparation may therefore leave the same session in both current and archive; -consumers must identify sessions by ID, not count files. Archives are retained -until the App explicitly acknowledges them; their counters are never carried -into the new session. -The archive directory rejects symlinks/non-directories and is created mode 0700. +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). @@ -662,12 +653,11 @@ 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 can lose the -tail after the last successful save, with no strict 30-second loss bound. A -restart archives only that last saved tail and does not fabricate final values. +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 same current/archive sequence. +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 @@ -681,28 +671,13 @@ 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 `{"current": , "archived": [, ...]}`. -- `POST /runtime/ack` accepts `{"removeSessionIds": ["", ...]}` and - returns the same shape with the remaining archives. IDs must be 32-character - lowercase hex. Unknown IDs are harmless, repeated acknowledgment is safe, - and the current session is never deleted, including its duplicate archive. - -Requests read the host's saved atomic snapshots, without sampling, resetting -counters, or updating the save time. Use native metrics for live rates. The App -must durably save its totals/session watermarks **before** acknowledging archives; -if the HTTP request fails, retain the watermarks and retry. Failed deletions -remain in the returned archive list. Only valid snapshot files immediately in -the host's own `runtime-sessions` directory are eligible; request paths and -symlinks are rejected. A corrupt snapshot makes the request fail rather than -silently lose accounting data. - -Acknowledgment bodies are limited to 64 KiB and responses to 16 MiB; requests -have bounded read/write timeouts. There is no pagination. If unacknowledged -archives exceed the response limit, the request fails and the App retains its -last known data. While stopped, HTTP is unavailable: the App can show its own -persisted last-known snapshot/totals, retain reset watermarks, and reconcile -saved tails and archives on the next connection. libXray never owns App totals -or clear/reset policy. +- `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 diff --git a/invoke_model.go b/invoke_model.go index 5365c669..5d76c843 100644 --- a/invoke_model.go +++ b/invoke_model.go @@ -10,7 +10,7 @@ import ( type LibXrayMethod string -const LibXrayAPIVersion = 3 +const LibXrayAPIVersion = 4 const ( LibXrayMethodGetFreePorts LibXrayMethod = "getFreePorts" diff --git a/invoke_test.go b/invoke_test.go index d8a20846..9fd5d443 100644 --- a/invoke_test.go +++ b/invoke_test.go @@ -285,7 +285,7 @@ func TestInvokeCheckRoute(t *testing.T) { if response.Success || string(response.Data) != "null" { t.Fatalf("invalid target should fail without evidence: %+v", response) } - response = invokeRawForTest(t, `{"apiVersion":3,"method":"checkRoute","payload":{"port":"443"}}`) + response = invokeRawForTest(t, `{"apiVersion":4,"method":"checkRoute","payload":{"port":"443"}}`) if response.Success { t.Fatal("invalid typed field accepted") } @@ -334,7 +334,7 @@ func TestInvokeRunXrayRuntimeIsOptionalTypedMetadata(t *testing.T) { request := RunXrayRequest{ XrayJson: `{"log":{"loglevel":"none"},"outbounds":[{"protocol":"freedom"}]}`, Runtime: &RuntimeConfig{ - StatePath: "relative.json", PlanID: "plan", InboundTag: "tunIn", + StatePath: "relative.json", InboundTag: "tunIn", }, } encoded, err := json.Marshal(request) @@ -345,7 +345,7 @@ func TestInvokeRunXrayRuntimeIsOptionalTypedMetadata(t *testing.T) { 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"}}`) + response = invokeRawForTest(t, `{"apiVersion":4,"method":"runXray","payload":{"xrayJson":"{}","runtime":"invalid"}}`) if response.Success { t.Fatal("untyped runtime metadata was accepted") } @@ -734,7 +734,7 @@ func TestInvokeRemovedMethods(t *testing.T) { for _, method := range []string{"ping", "runXrayFromJson", "deriveAgePublicKey"} { response := invokeRawForTest( t, - `{"apiVersion":3,"method":"`+method+`","payload":{}}`, + `{"apiVersion":4,"method":"`+method+`","payload":{}}`, ) if response.Success { t.Fatalf("removed method %q should fail", method) @@ -786,17 +786,17 @@ func TestInvokeAPIVersion(t *testing.T) { t.Fatal("omitted apiVersion should fail") } - response = invokeRawForTest(t, `{"apiVersion":2,"method":"xrayVersion"}`) + response = invokeRawForTest(t, `{"apiVersion":3,"method":"xrayVersion"}`) if response.Success { - t.Fatal("v2 apiVersion should fail") + t.Fatal("v3 apiVersion should fail") } if got := string(response.Data); got != "null" { t.Fatalf("data = %s, want null", got) } - response = invokeRawForTest(t, `{"apiVersion":3,"method":"xrayVersion"}`) + response = invokeRawForTest(t, `{"apiVersion":4,"method":"xrayVersion"}`) if !response.Success { - t.Fatalf("v3 apiVersion should succeed: %s", response.Err) + t.Fatalf("v4 apiVersion should succeed: %s", response.Err) } } @@ -807,7 +807,7 @@ func TestInvokeNoDataResponseShape(t *testing.T) { } requireNoDataObject(t, response) - response = invokeRawForTest(t, `{"apiVersion":3,"method":"runXray","payload":"invalid"}`) + response = invokeRawForTest(t, `{"apiVersion":4,"method":"runXray","payload":"invalid"}`) if response.Success { t.Fatal("invalid runXray payload should fail") } @@ -820,7 +820,7 @@ func TestInvokeIgnoresTopLevelEnv(t *testing.T) { const key = "XRAY_LIBXRAY_UNKNOWN_ENV_TEST" _ = os.Unsetenv(key) t.Cleanup(func() { _ = os.Unsetenv(key) }) - requestJSON := `{"apiVersion":3,"method":"xrayVersion","env":{"` + key + `":"/tmp"}}` + requestJSON := `{"apiVersion":4,"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 86a006b0..edae12a9 100644 --- a/readme/README.zh_CN.md +++ b/readme/README.zh_CN.md @@ -134,7 +134,7 @@ void CGoFree(char* value); ```json { - "apiVersion": 3, + "apiVersion": 4, "method": "runXray", "payload": { "xrayJson": "{\"outbounds\":[...]}" @@ -154,7 +154,7 @@ void CGoFree(char* value); 设计决定: -1. Invoke 当前只接受 `apiVersion: 3`。Xray 配置通过 `xrayJson` 传递 UTF-8 JSON 文本;libXray 不读取配置文件路径。 +1. Invoke 当前只接受 `apiVersion: 4`。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` 传入数据目录。 @@ -205,13 +205,13 @@ reverse,不调用临时 instance 的 Start,也不发布为活动核心。结 ### 草稿路由检查 -`checkRoute` 是 API version 3 的增量方法。通过 `xrayJson` 接收完整草稿, +API version 4 的 `checkRoute` 通过 `xrayJson` 接收完整草稿, 调用当前锁定版本 Xray-core 的 Router;不启动临时 instance,也不向输入的目标 派发访问流量: ```json { - "apiVersion": 3, + "apiVersion": 4, "method": "checkRoute", "payload": { "xrayJson": "{\"outbounds\":[{\"tag\":\"direct\",\"protocol\":\"freedom\"}]}", @@ -353,7 +353,7 @@ libXray 使用 `tag` 存储节点名称。`sendThrough` 保留 Xray 原生语义 ```json { - "apiVersion": 3, + "apiVersion": 4, "method": "convertShareLinksToXrayJson", "payload": { "text": "-----BEGIN AGE ENCRYPTED FILE-----\n...", @@ -371,7 +371,7 @@ libXray 使用 `tag` 存储节点名称。`sendThrough` 保留 Xray 原生语义 ```json { - "apiVersion": 3, + "apiVersion": 4, "method": "generateAgeKeyPair", "payload": { "keyType": "x25519" @@ -402,7 +402,7 @@ libXray 使用 `tag` 存储节点名称。`sendThrough` 保留 Xray 原生语义 ```json { - "apiVersion": 3, + "apiVersion": 4, "method": "pingBatch", "payload": { "configs": [ @@ -449,7 +449,7 @@ GET 成功后把响应正文原样放入 `locationJson` 字符串。JSON 解析 ```json { - "apiVersion": 3, + "apiVersion": 4, "method": "testXray", "payload": { "xrayJson": "{\"outbounds\":[...]}" @@ -464,27 +464,26 @@ GET 成功后把响应正文原样放入 `locationJson` 字符串。JSON 解析 ### 托管运行统计 -API v3 的 `runXray.payload.runtime` 为可选宿主元数据。省略时保留原生命周期, +API v4 的 `runXray.payload.runtime` 为可选宿主元数据。省略时保留原生命周期, 不写运行快照。宿主传入以下对象;Desktop 的 `-runtime` 文件也直接使用此对象, 不含外层 `runtime`,原始 Xray 配置仍通过独立的 `-config` 传入。 ```json { "statePath": "/private/app/run/runtime.json", - "planId": "opaque-plan-id", "inboundTag": "tunIn", "listen": "127.0.0.1:49228", "token": "538fc3253a3e433491bc2d653fc74214" } ``` -宿主提供已存在的私有目录和绝对 `statePath`。`planId` / `inboundTag` 非空且 -各不超过 256 字节;`planId` 是不包含凭据的不透明标识。元数据独立于 Xray JSON, -用户配置不能覆盖。指定入站必须存在,并启用上下行系统统计和 stats manager。 +宿主提供已存在的私有目录和绝对 `statePath`。`inboundTag` 非空且不超过 256 字节。 +元数据独立于 Xray JSON,用户配置不能覆盖。指定入站必须存在,并启用上下行系统统计 +和 stats manager。 `listen` / `token` 可同时省略,保留仅落盘、不启用 HTTP 的行为。启用时 `listen` 只能是 `127.0.0.1:`,端口范围 1–65535;宿主须生成新的 32 位小写十六进制 -随机 `token` 并保密,不能复用示例值。元数据无效、HTTP 端口被占用、已有快照损坏、 -归档失败或首次保存失败均拒绝启动,并关闭已构建的核心和统计监听器。 +随机 `token` 并保密,不能复用示例值。元数据无效、HTTP 端口被占用或首次保存失败 +均拒绝启动,并关闭已构建的核心和统计监听器。 落盘文件仅包含本次会话的原始入站计数: @@ -493,7 +492,6 @@ API v3 的 `runXray.payload.runtime` 为可选宿主元数据。省略时保留 "version": 1, "session": { "id": "2a7e2e49b947a802d8b39af4fbc48f52", - "planId": "opaque-plan-id", "startedAtMs": 1788300000000, "endedAtMs": 0, "uplink": 120, @@ -518,22 +516,18 @@ API v3 的 `runXray.payload.runtime` 为可选宿主元数据。省略时保留 方法。`resetRuntime` 不是 Invoke method。App 可通过已有 Xray metrics 读取实时速率;App 累计与重置策略由 App 自行管理,不属于 libXray。 -新会话覆盖 `runtime.json` 前,先将已有合法快照原子归档到同级目录 -`runtime-sessions/.json`。归档保留原始计数、时间及可能未设置的结束 -时间,不推测丢失流量或崩溃时间。重复启动失败使用同一个归档文件名;准备失败时, -当前文件和归档可能同时存在相同会话,消费者必须按 session ID 识别,不能按文件数 -重复计入。归档保留至 App 显式确认结算,不把旧计数继承到新会话。归档目录以 0700 创建, -拒绝符号链接和非目录对象。 +启动新会话时会原子覆盖之前的 `runtime.json`;libXray 不归档或合并旧会话。 +若 App 未在覆盖前读取流量,该数据将直接丢失。每个会话都从零开始,并生成新的 ID。 快照文件使用同目录 0600 临时文件,sync 后原子替换;Windows 使用 `MoveFileEx` 的替换和 write-through 标志。私有父目录/Windows ACL 由宿主管理。 保存失败保留上次完整磁盘快照供后续重试;最终保存失败向调用方报告,但仍关闭核心。 rename 后发生 I/O 错误时结果可能不确定,消费者应在 HTTP 可用时重新读取已保存的快照。这是参考数据, -不是计费账本:崩溃/强杀允许丢失最后成功保存后的尾部,不承诺严格 30 秒丢失上限。 -下次启动只归档已有快照,不伪造最终计数。 +不是计费账本:崩溃、强杀或 App 读取前被新会话覆盖都可能丢失流量,不承诺严格的 +丢失上限。 `statePath + ".lock"` 的非阻塞操作系统文件锁保持至核心关闭,防止跨进程同时 -改写当前快照和归档。宿主须使用一致的规范路径并保留锁文件。App 经 HTTP 读取快照, +改写当前会话。宿主须使用一致的规范路径并保留锁文件。App 经 HTTP 读取快照, 无需打开宿主文件,因此 macOS System Extension 文件可继续归 root 所有。此能力 不能让 Windows Job 强制终止获得正常最终结算。 @@ -543,21 +537,11 @@ rename 后发生 I/O 错误时结果可能不确定,消费者应在 HTTP 可 Xray 原生 metrics 的回环端口,不提供 VPN 启停或配置方法。所有请求必须携带 `Authorization: Bearer `;响应使用 `Cache-Control: no-store`,不启用 CORS。 -- `GET /runtime` 返回 `{"current": , "archived": [, ...]}`。 -- `POST /runtime/ack` 接收 `{"removeSessionIds": ["", ...]}`,返回相同结构, - 其中只保留剩余归档。ID 必须为 32 位小写十六进制;不存在的 ID 无副作用,支持重复确认; - 当前会话及其重复归档永不删除。 +- `GET /runtime` 直接返回当前已保存的快照。 请求只读取宿主已保存的原子快照,不触发采样、计数重置或保存时间更新;实时速率仍使用 -原生 metrics。App 必须**先持久保存累计值及会话水位,再确认归档**;HTTP 请求失败时 -保留水位并重试。删除失败的归档仍出现在响应列表。仅宿主自身 `runtime-sessions` -目录直接包含的合法快照文件可被清理,不接受请求路径,拒绝符号链接。快照损坏会使 -请求失败,不静默丢弃统计数据。 - -确认请求体限制 64 KiB,响应限制 16 MiB,读写有超时限制,不提供分页。未确认归档超出 -响应上限时请求失败,App 保留最后已知数据。停止期间 HTTP 不可用:App 可展示自身 -持久化的最后已知快照及累计值,保留清零水位,在下次连接时补结算保存的尾部与归档。 -libXray 不维护 App 累计值或清零策略。 +原生 metrics。快照缺失、损坏或不是常规文件时返回服务不可用。请求有读写超时限制。 +停止期间 HTTP 不可用;libXray 不维护 App 累计值或清零策略。 ### metrics diff --git a/xray/runtime.go b/xray/runtime.go index 93559433..1b1064bf 100644 --- a/xray/runtime.go +++ b/xray/runtime.go @@ -12,7 +12,6 @@ import ( "os" "path/filepath" "strings" - "sync" "time" "github.com/xtls/xray-core/core" @@ -24,7 +23,6 @@ import ( // RuntimeConfig is host metadata, never part of the Xray configuration. type RuntimeConfig struct { StatePath string `json:"statePath"` - PlanID string `json:"planId"` InboundTag string `json:"inboundTag"` Listen string `json:"listen,omitempty"` Token string `json:"token,omitempty"` @@ -32,7 +30,6 @@ type RuntimeConfig struct { type RuntimeSession struct { ID string `json:"id"` - PlanID string `json:"planId"` StartedAtMs int64 `json:"startedAtMs"` EndedAtMs int64 `json:"endedAtMs"` Uplink int64 `json:"uplink"` @@ -58,17 +55,14 @@ type managedRuntime struct { stopTicker, tickDone chan struct{} httpServer *http.Server httpListener net.Listener - httpMu sync.Mutex - httpClosed bool } func prepareRuntime(config *RuntimeConfig) (*managedRuntime, error) { if config == nil { return nil, nil } - if !filepath.IsAbs(config.StatePath) || strings.TrimSpace(config.PlanID) == "" || len(config.PlanID) > 256 || - strings.TrimSpace(config.InboundTag) == "" || len(config.InboundTag) > 256 { - return nil, errors.New("runtime requires an absolute statePath, planId, and inboundTag") + 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 @@ -83,15 +77,6 @@ func prepareRuntime(config *RuntimeConfig) (*managedRuntime, error) { _ = stateLock.Close() } }() - previous, err := readRuntimeState(config.StatePath) - if err != nil { - return nil, err - } - // Archive before any new session can replace the previous saved snapshot. - // Repeated failed starts write the same session filename, not duplicate records. - if err := archiveRuntimeState(config.StatePath, previous); err != nil { - return nil, err - } var id [16]byte if _, err = rand.Read(id[:]); err != nil { return nil, err @@ -101,7 +86,7 @@ func prepareRuntime(config *RuntimeConfig) (*managedRuntime, error) { config: *config, stateLock: stateLock, snapshot: RuntimeSnapshot{ Version: 1, - Session: RuntimeSession{ID: hex.EncodeToString(id[:]), PlanID: config.PlanID, StartedAtMs: time.Now().UnixMilli()}, + Session: RuntimeSession{ID: hex.EncodeToString(id[:]), StartedAtMs: time.Now().UnixMilli()}, }, }, nil } @@ -228,11 +213,6 @@ func (r *managedRuntime) stop() error { _ = r.httpListener.Close() r.httpServer = nil r.httpListener = nil - // Close connections, then finish any filesystem request before releasing - // the session owner lock. Queued handlers cannot acknowledge after stop. - r.httpMu.Lock() - r.httpClosed = true - r.httpMu.Unlock() } close(r.stopTicker) <-r.tickDone @@ -258,6 +238,7 @@ func readRuntimeState(path string) (RuntimeSnapshot, error) { } 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") } @@ -265,7 +246,6 @@ func readRuntimeState(path string) (RuntimeSnapshot, error) { 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) || - strings.TrimSpace(state.Session.PlanID) == "" || len(state.Session.PlanID) > 256 || 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") @@ -273,23 +253,6 @@ func readRuntimeState(path string) (RuntimeSnapshot, error) { return state, nil } -func archiveRuntimeState(path string, previous RuntimeSnapshot) error { - if previous.Version == 0 { - return nil - } - directory := filepath.Join(filepath.Dir(path), "runtime-sessions") - if err := os.Mkdir(directory, 0700); err != nil && !errors.Is(err, os.ErrExist) { - return errors.New("runtime archive directory is unavailable") - } - if info, err := os.Lstat(directory); err != nil || !info.IsDir() { - return errors.New("runtime archive directory is unavailable") - } - if err := writeRuntimeState(filepath.Join(directory, previous.Session.ID+".json"), previous); err != nil { - return errors.New("runtime archive write failed") - } - return 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") diff --git a/xray/runtime_http.go b/xray/runtime_http.go index 20fdd17f..d5d106d8 100644 --- a/xray/runtime_http.go +++ b/xray/runtime_http.go @@ -5,24 +5,14 @@ import ( "encoding/hex" "encoding/json" "errors" - "io" "net" "net/http" - "os" - "path/filepath" "strconv" "strings" "time" ) -const runtimeResponseLimit = 16 * 1024 * 1024 - -type runtimeFiles struct { - Current *RuntimeSnapshot `json:"current"` - Archived []RuntimeSnapshot `json:"archived"` -} - -func validRuntimeID(value string) bool { +func validRuntimeToken(value string) bool { decoded, err := hex.DecodeString(value) return err == nil && len(decoded) == 16 && value == strings.ToLower(value) } @@ -34,7 +24,7 @@ func validateRuntimeHTTP(config *RuntimeConfig) error { 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 || !validRuntimeID(config.Token) { + 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 @@ -72,115 +62,20 @@ func (r *managedRuntime) handleHTTP(w http.ResponseWriter, request *http.Request http.Error(w, "unauthorized", http.StatusUnauthorized) return } - var removeSessionIDs []string - switch request.URL.Path { - case "/runtime": - if request.Method != http.MethodGet { - w.Header().Set("Allow", http.MethodGet) - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - case "/runtime/ack": - if request.Method != http.MethodPost { - w.Header().Set("Allow", http.MethodPost) - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - var payload struct { - RemoveSessionIDs []string `json:"removeSessionIds"` - } - request.Body = http.MaxBytesReader(w, request.Body, 64*1024) - decoder := json.NewDecoder(request.Body) - decoder.DisallowUnknownFields() - var extra any - if decoder.Decode(&payload) != nil || decoder.Decode(&extra) != io.EOF || payload.RemoveSessionIDs == nil { - http.Error(w, "invalid runtime acknowledgment", http.StatusBadRequest) - return - } - for _, id := range payload.RemoveSessionIDs { - if !validRuntimeID(id) { - http.Error(w, "invalid runtime session ID", http.StatusBadRequest) - return - } - } - removeSessionIDs = payload.RemoveSessionIDs - default: + if request.URL.Path != "/runtime" { http.NotFound(w, request) return } - // Files are atomically replaced by the ticker; HTTP never reads or mutates - // its in-memory sample. Serialize only archive readers/acknowledgments. - r.httpMu.Lock() - defer r.httpMu.Unlock() - if r.httpClosed || request.Context().Err() != nil { - http.Error(w, "runtime stopped", http.StatusServiceUnavailable) + if request.Method != http.MethodGet { + w.Header().Set("Allow", http.MethodGet) + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } - files, err := r.readHTTPFiles(removeSessionIDs) - if err != nil { - http.Error(w, "runtime snapshots unavailable", http.StatusServiceUnavailable) - return - } - data, err := json.Marshal(files) - if err != nil || len(data) > runtimeResponseLimit { - http.Error(w, "runtime snapshots exceed response limit", http.StatusServiceUnavailable) + snapshot, err := readRuntimeState(r.config.StatePath) + if err != nil || snapshot.Version == 0 { + http.Error(w, "runtime snapshot unavailable", http.StatusServiceUnavailable) return } w.Header().Set("Content-Type", "application/json") - _, _ = w.Write(data) -} - -func (r *managedRuntime) readHTTPFiles(removeSessionIDs []string) (runtimeFiles, error) { - files := runtimeFiles{Archived: []RuntimeSnapshot{}} - current, err := readRuntimeState(r.config.StatePath) - if err != nil { - return files, err - } - if current.Version != 0 { - files.Current = ¤t - } else if len(removeSessionIDs) != 0 { - return files, errors.New("runtime current session is unavailable") - } - directory := filepath.Join(filepath.Dir(r.config.StatePath), "runtime-sessions") - info, err := os.Lstat(directory) - if errors.Is(err, os.ErrNotExist) { - return files, nil - } - if err != nil || !info.IsDir() { - return files, errors.New("runtime archive directory is unavailable") - } - entries, err := os.ReadDir(directory) - if err != nil { - return files, err - } - remove := make(map[string]bool, len(removeSessionIDs)) - for _, id := range removeSessionIDs { - if id != current.Session.ID { - remove[id] = true - } - } - // Bound the accumulated response before marshaling the complete envelope. - encoded, _ := json.Marshal(files) - size := len(encoded) - for _, entry := range entries { - id, isJSON := strings.CutSuffix(entry.Name(), ".json") - if !isJSON || !validRuntimeID(id) { - continue - } - path := filepath.Join(directory, entry.Name()) - state, err := readRuntimeState(path) - if err != nil || state.Version == 0 || state.Session.ID != id { - return files, errors.New("runtime archive is invalid") - } - if remove[id] && os.Remove(path) == nil { - continue - } - encoded, _ := json.Marshal(state) - size += len(encoded) + 1 - if size > runtimeResponseLimit { - return files, errors.New("runtime snapshots exceed response limit") - } - files.Archived = append(files.Archived, state) - } - return files, nil + _ = json.NewEncoder(w).Encode(snapshot) } diff --git a/xray/runtime_http_test.go b/xray/runtime_http_test.go index 574961cc..ed18916a 100644 --- a/xray/runtime_http_test.go +++ b/xray/runtime_http_test.go @@ -25,16 +25,15 @@ func runtimeHTTPConfig(t *testing.T) RuntimeConfig { return config } -func requestRuntime(t *testing.T, config RuntimeConfig, method, path, token, body string, status int) runtimeFiles { +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, strings.NewReader(body)) + request, err := http.NewRequest(method, "http://"+config.Listen+path, nil) if err != nil { t.Fatal(err) } if token != "" { request.Header.Set("Authorization", "Bearer "+token) } - request.Header.Set("Content-Type", "application/json") client := &http.Client{Timeout: 3 * time.Second} response, err := client.Do(request) if err != nil { @@ -51,55 +50,44 @@ func requestRuntime(t *testing.T, config RuntimeConfig, method, path, token, bod if strings.Contains(string(data), config.StatePath) || strings.Contains(string(data), config.Token) { t.Fatal("runtime HTTP exposed host metadata") } - var files runtimeFiles + var snapshot RuntimeSnapshot if status == http.StatusOK { - if response.Header.Get("Content-Type") != "application/json" || json.Unmarshal(data, &files) != nil || files.Archived == nil { + if response.Header.Get("Content-Type") != "application/json" || json.Unmarshal(data, &snapshot) != nil || snapshot.Version != 1 { t.Fatalf("invalid runtime response: %s", data) } } - return files + return snapshot } -func TestRuntimeHTTPAuthenticationArchivesAcknowledgmentAndStop(t *testing.T) { +func TestRuntimeHTTPAuthenticationCurrentSessionAndStop(t *testing.T) { config := runtimeHTTPConfig(t) - previous, up, down := runtimeFixture(t, config) - up.Add(5) - down.Add(7) - archived := saveRuntimeSample(t, previous) - _ = previous.stateLock.Close() 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) - requestRuntime(t, config, http.MethodPost, "/runtime/ack", "", `{"removeSessionIds":["`+archived.Session.ID+`"]}`, http.StatusUnauthorized) - files := requestRuntime(t, config, http.MethodGet, "/runtime", config.Token, "", http.StatusOK) - if files.Current == nil || files.Current.Session.Uplink != 17 || files.Current.Session.Downlink != 23 || len(files.Archived) != 1 || files.Archived[0] != archived { - t.Fatalf("wrong current/archived snapshots: %+v", files) + 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) } - current := *files.Current up.Add(100) - files = requestRuntime(t, config, http.MethodGet, "/runtime", config.Token, "", http.StatusOK) - if *files.Current != current || up.Value() != 117 { - t.Fatal("HTTP must read saved snapshots without sampling or resetting metrics") + 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") } - // A duplicate current archive is never acknowledged while it remains current. - if err := archiveRuntimeState(config.StatePath, current); err != nil { + 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) } - body := `{"removeSessionIds":["` + archived.Session.ID + `","` + current.Session.ID + `","` + strings.Repeat("c", 32) + `"]}` - for range 2 { - files = requestRuntime(t, config, http.MethodPost, "/runtime/ack", config.Token, body, http.StatusOK) - if *files.Current != current || len(files.Archived) != 1 || files.Archived[0] != current { - t.Fatalf("ack was not idempotent or removed current: %+v", files) - } - } - archivePath := filepath.Join(filepath.Dir(config.StatePath), "runtime-sessions", archived.Session.ID+".json") - if _, err := os.Lstat(archivePath); !os.IsNotExist(err) { - t.Fatal("acknowledged archive still exists") + 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) @@ -115,88 +103,26 @@ func TestRuntimeHTTPAuthenticationArchivesAcknowledgmentAndStop(t *testing.T) { } } -func TestRuntimeHTTPRejectsInvalidAcknowledgments(t *testing.T) { +func TestRuntimeHTTPRejectsCurrentSnapshotSymlink(t *testing.T) { config := runtimeHTTPConfig(t) runtime, _, _ := runtimeFixture(t, config) if err := runtime.start(); err != nil { t.Fatal(err) } - archive := savedRuntime(t, config.StatePath) - archive.Session.ID = strings.Repeat("b", 32) - if err := archiveRuntimeState(config.StatePath, archive); err != nil { + state := savedRuntime(t, config.StatePath) + outside := filepath.Join(t.TempDir(), "runtime.json") + if err := writeRuntimeState(outside, state); err != nil { t.Fatal(err) } - for _, body := range []string{ - `{`, `{}`, `{"removeSessionIds":null}`, - `{"removeSessionIds":["` + archive.Session.ID + `","../../outside"]}`, - `{"removeSessionIds":["` + strings.Repeat("A", 32) + `"]}`, - `{"removeSessionIds":[],"path":"outside"}`, - `{"removeSessionIds":[]} {}`, - `{"removeSessionIds":[]}` + strings.Repeat(" ", 64*1024), - } { - requestRuntime(t, config, http.MethodPost, "/runtime/ack", config.Token, body, http.StatusBadRequest) - } - files := requestRuntime(t, config, http.MethodGet, "/runtime", config.Token, "", http.StatusOK) - if len(files.Archived) != 1 || files.Archived[0] != archive { - t.Fatal("invalid request partially acknowledged an archive") - } - requestRuntime(t, config, http.MethodPost, "/runtime", config.Token, "", http.StatusMethodNotAllowed) - requestRuntime(t, config, http.MethodGet, "/runtime/ack", config.Token, "", http.StatusMethodNotAllowed) - requestRuntime(t, config, http.MethodGet, "/control", config.Token, "", http.StatusNotFound) if err := os.Remove(config.StatePath); err != nil { t.Fatal(err) } - files = requestRuntime(t, config, http.MethodGet, "/runtime", config.Token, "", http.StatusOK) - if files.Current != nil || len(files.Archived) != 1 { - t.Fatal("missing current snapshot was not reported as null") + if err := os.Symlink(outside, config.StatePath); err != nil { + t.Skipf("symbolic links unavailable: %v", err) } - requestRuntime(t, config, http.MethodPost, "/runtime/ack", config.Token, `{"removeSessionIds":["`+archive.Session.ID+`"]}`, http.StatusServiceUnavailable) -} - -func TestRuntimeHTTPRejectsSnapshotSymlinksAndInvalidArchives(t *testing.T) { - for _, target := range []string{"current", "archive-directory", "archive-file", "archive-id"} { - t.Run(target, func(t *testing.T) { - config := runtimeHTTPConfig(t) - runtime, _, _ := runtimeFixture(t, config) - if err := runtime.start(); err != nil { - t.Fatal(err) - } - state := savedRuntime(t, config.StatePath) - state.Session.ID = strings.Repeat("b", 32) - outside := filepath.Join(t.TempDir(), state.Session.ID+".json") - if err := writeRuntimeState(outside, state); err != nil { - t.Fatal(err) - } - archive := filepath.Join(filepath.Dir(config.StatePath), "runtime-sessions") - link, destination := config.StatePath, outside - switch target { - case "current": - if err := os.Remove(config.StatePath); err != nil { - t.Fatal(err) - } - case "archive-directory": - link, destination = archive, filepath.Dir(outside) - case "archive-file", "archive-id": - if err := os.Mkdir(archive, 0700); err != nil { - t.Fatal(err) - } - link = filepath.Join(archive, state.Session.ID+".json") - } - if target == "archive-id" { - invalid := state - invalid.Session.ID = strings.Repeat("c", 32) - if err := writeRuntimeState(link, invalid); err != nil { - t.Fatal(err) - } - } else if err := os.Symlink(destination, link); err != nil { - t.Skipf("symbolic links unavailable: %v", err) - } - requestRuntime(t, config, http.MethodGet, "/runtime", config.Token, "", http.StatusServiceUnavailable) - requestRuntime(t, config, http.MethodPost, "/runtime/ack", config.Token, `{"removeSessionIds":["`+state.Session.ID+`"]}`, http.StatusServiceUnavailable) - if savedRuntime(t, outside) != state { - t.Fatal("HTTP changed a snapshot outside its archive") - } - }) + 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") } } @@ -238,7 +164,7 @@ func TestRuntimeHTTPStartFailureClosesListener(t *testing.T) { _ = listener.Close() } -func TestRuntimeHTTPConcurrentReadsAndAcknowledgments(t *testing.T) { +func TestRuntimeHTTPConcurrentReads(t *testing.T) { config := runtimeHTTPConfig(t) runtime, up, _ := runtimeFixture(t, config) if err := runtime.start(); err != nil { @@ -256,12 +182,7 @@ func TestRuntimeHTTPConcurrentReadsAndAcknowledgments(t *testing.T) { }) workers.Go(func() { for range 30 { - requestRuntime(t, config, http.MethodGet, "/runtime", config.Token, "", http.StatusOK) - } - }) - workers.Go(func() { - for range 30 { - requestRuntime(t, config, http.MethodPost, "/runtime/ack", config.Token, `{"removeSessionIds":[]}`, http.StatusOK) + requestRuntime(t, config, http.MethodGet, "/runtime", config.Token, http.StatusOK) } }) workers.Wait() diff --git a/xray/runtime_test.go b/xray/runtime_test.go index e4a071a3..41851e33 100644 --- a/xray/runtime_test.go +++ b/xray/runtime_test.go @@ -20,8 +20,8 @@ import ( func runtimeConfig(t *testing.T) RuntimeConfig { t.Helper() return RuntimeConfig{ - StatePath: filepath.Join(t.TempDir(), "runtime.json"), - PlanID: "opaque-plan", InboundTag: "tunIn", + StatePath: filepath.Join(t.TempDir(), "runtime.json"), + InboundTag: "tunIn", } } @@ -103,12 +103,12 @@ func TestRuntimeStoresRawCountersWithoutResetOrTotals(t *testing.T) { metadata, _ := json.Marshal(config) var metadataFields map[string]json.RawMessage _ = json.Unmarshal(metadata, &metadataFields) - if len(metadataFields) != 3 || metadataFields["controlAddress"] != nil || metadataFields["controlToken"] != nil { + if len(metadataFields) != 2 || metadataFields["planId"] != nil || metadataFields["controlAddress"] != nil || metadataFields["controlToken"] != nil { t.Fatalf("runtime metadata exposed a control surface: %s", metadata) } } -func TestRuntimeArchivesEachPreviousSessionBeforeReplacement(t *testing.T) { +func TestRuntimeReplacesPreviousSessionOnStart(t *testing.T) { config := runtimeConfig(t) runtime, up, down := runtimeFixture(t, config) if err := runtime.start(); err != nil { @@ -128,8 +128,7 @@ func TestRuntimeArchivesEachPreviousSessionBeforeReplacement(t *testing.T) { t.Fatalf("stop did not save final raw counters: %+v", stopped) } _ = runtime.stateLock.Close() - // Preparation alone cannot overwrite the current snapshot. Repeating it must - // not create multiple records for the same session or change its timestamps. + // Preparation alone cannot overwrite the current snapshot. for range 2 { next, err := prepareRuntime(&config) if err != nil { @@ -140,25 +139,20 @@ func TestRuntimeArchivesEachPreviousSessionBeforeReplacement(t *testing.T) { t.Fatal("preparation replaced the previous current snapshot") } } - archive := filepath.Join(filepath.Dir(config.StatePath), "runtime-sessions") - entries, err := os.ReadDir(archive) - if err != nil || len(entries) != 1 || savedRuntime(t, filepath.Join(archive, stopped.Session.ID+".json")) != stopped { - t.Fatalf("archive duplicated or changed previous session: %v %v", entries, err) - } 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.PlanID != config.PlanID || current.Session.Uplink != 0 || current.Session.Downlink != 0 { + 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 savedRuntime(t, filepath.Join(archive, stopped.Session.ID+".json")) != stopped { - t.Fatal("new current snapshot overwrote the archived session") + if _, err := os.Lstat(filepath.Join(filepath.Dir(config.StatePath), "runtime-sessions")); !os.IsNotExist(err) { + t.Fatal("restart created a runtime session archive") } } -func TestRuntimeWriteAndArchiveFailuresPreserveSavedFile(t *testing.T) { +func TestRuntimeWriteFailuresPreserveSavedFile(t *testing.T) { config := runtimeConfig(t) runtime, up, _ := runtimeFixture(t, config) up.Add(20) @@ -178,31 +172,6 @@ func TestRuntimeWriteAndArchiveFailuresPreserveSavedFile(t *testing.T) { if recovered.Session.Uplink != 33 || recovered.Error != "" { t.Fatalf("retry synthesized or lost raw bytes: %+v", recovered) } - _ = runtime.stateLock.Close() - archive := filepath.Join(filepath.Dir(config.StatePath), "runtime-sessions") - if err := os.WriteFile(archive, []byte("blocks archive directory"), 0600); err != nil { - t.Fatal(err) - } - if next, err := prepareRuntime(&config); err == nil { - _ = next.stateLock.Close() - t.Fatal("archive failure permitted a new owner") - } - if savedRuntime(t, config.StatePath) != recovered { - t.Fatal("archive failure overwrote the previous saved session") - } - if err := os.Remove(archive); err != nil { - t.Fatal(err) - } - if err := os.Symlink(t.TempDir(), archive); err != nil { - t.Skipf("symbolic links unavailable on this test host: %v", err) - } - if next, err := prepareRuntime(&config); err == nil { - _ = next.stateLock.Close() - t.Fatal("archive directory symlink was followed") - } - if savedRuntime(t, config.StatePath) != recovered { - t.Fatal("archive symlink rejection changed the saved session") - } } func TestRuntimeConfigAndStateBoundary(t *testing.T) { @@ -210,8 +179,6 @@ func TestRuntimeConfigAndStateBoundary(t *testing.T) { for _, mutate := range []func(*RuntimeConfig){ func(c *RuntimeConfig) { c.StatePath = "relative.json" }, func(c *RuntimeConfig) { c.InboundTag = "" }, - func(c *RuntimeConfig) { c.PlanID = " " }, - func(c *RuntimeConfig) { c.PlanID = strings.Repeat("x", 257) }, 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) }, @@ -231,20 +198,25 @@ func TestRuntimeConfigAndStateBoundary(t *testing.T) { } for _, text := range []string{ `{`, `{"version":9}`, - `{"version":1,"session":{"id":"../../outside","planId":"plan","startedAtMs":1},"sampledAtMs":1,"savedAtMs":1}`, - `{"version":1,"session":{"id":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","planId":"plan","startedAtMs":1,"uplink":-1},"sampledAtMs":1,"savedAtMs":1}`, + `{"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 r, err := prepareRuntime(&config); err == nil { - _ = r.stateLock.Close() + 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) { @@ -336,7 +308,7 @@ func TestRuntimePeriodicSaveAndSingleOwner(t *testing.T) { t.Fatal("host timer did not save counters without any UI/control request") } -func TestRuntimeKilledOwnerKeepsOnlySavedTail(t *testing.T) { +func TestRuntimeKilledOwnerStateIsReplacedOnRestart(t *testing.T) { config := runtimeConfig(t) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() @@ -376,13 +348,12 @@ func TestRuntimeKilledOwnerKeepsOnlySavedTail(t *testing.T) { if err := runtime.start(); err != nil { t.Fatal(err) } - archive := filepath.Join(filepath.Dir(config.StatePath), "runtime-sessions", saved.Session.ID+".json") - if savedRuntime(t, archive) != saved { - t.Fatal("restart did not preserve the killed owner's last saved snapshot") - } 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) { @@ -390,7 +361,7 @@ func TestRuntimeChild(t *testing.T) { if path == "" { return } - config := RuntimeConfig{StatePath: path, PlanID: "child-plan", InboundTag: "tunIn"} + 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 { From fabf4b767f4971ef679cee41055a35668a307573 Mon Sep 17 00:00:00 2001 From: yiguo Date: Fri, 4 Sep 2026 23:51:59 +0800 Subject: [PATCH 10/16] refactor: make runtime snapshots internal --- invoke_model.go | 1 - xray/runtime.go | 20 ++++++++++---------- xray/runtime_http_test.go | 4 ++-- xray/runtime_test.go | 4 ++-- 4 files changed, 14 insertions(+), 15 deletions(-) diff --git a/invoke_model.go b/invoke_model.go index 5d76c843..489489fa 100644 --- a/invoke_model.go +++ b/invoke_model.go @@ -113,7 +113,6 @@ type RunXrayRequest struct { } type RuntimeConfig = xray.RuntimeConfig -type RuntimeSnapshot = xray.RuntimeSnapshot type TestXrayRequest struct { XrayJson string `json:"xrayJson,omitempty"` diff --git a/xray/runtime.go b/xray/runtime.go index 1b1064bf..c3f24935 100644 --- a/xray/runtime.go +++ b/xray/runtime.go @@ -28,7 +28,7 @@ type RuntimeConfig struct { Token string `json:"token,omitempty"` } -type RuntimeSession struct { +type runtimeSession struct { ID string `json:"id"` StartedAtMs int64 `json:"startedAtMs"` EndedAtMs int64 `json:"endedAtMs"` @@ -36,11 +36,11 @@ type RuntimeSession struct { Downlink int64 `json:"downlink"` } -// RuntimeSnapshot contains only this session's raw inbound counter values. +// runtimeSnapshot contains only this session's raw inbound counter values. // It contains no application totals, configuration, credentials, or control API. -type RuntimeSnapshot struct { +type runtimeSnapshot struct { Version int `json:"version"` - Session RuntimeSession `json:"session"` + Session runtimeSession `json:"session"` Available bool `json:"available"` SampledAtMs int64 `json:"sampledAtMs"` SavedAtMs int64 `json:"savedAtMs"` @@ -49,7 +49,7 @@ type RuntimeSnapshot struct { type managedRuntime struct { config RuntimeConfig - snapshot RuntimeSnapshot + snapshot runtimeSnapshot manager stats.Manager stateLock *os.File stopTicker, tickDone chan struct{} @@ -84,9 +84,9 @@ func prepareRuntime(config *RuntimeConfig) (*managedRuntime, error) { prepared = true return &managedRuntime{ config: *config, stateLock: stateLock, - snapshot: RuntimeSnapshot{ + snapshot: runtimeSnapshot{ Version: 1, - Session: RuntimeSession{ID: hex.EncodeToString(id[:]), StartedAtMs: time.Now().UnixMilli()}, + Session: runtimeSession{ID: hex.EncodeToString(id[:]), StartedAtMs: time.Now().UnixMilli()}, }, }, nil } @@ -223,8 +223,8 @@ func (r *managedRuntime) stop() error { return errors.Join(httpErr, r.save()) } -func readRuntimeState(path string) (RuntimeSnapshot, error) { - var state RuntimeSnapshot +func readRuntimeState(path string) (runtimeSnapshot, error) { + var state runtimeSnapshot info, err := os.Lstat(path) if errors.Is(err, os.ErrNotExist) { return state, nil @@ -253,7 +253,7 @@ func readRuntimeState(path string) (RuntimeSnapshot, error) { return state, nil } -func writeRuntimeState(path string, state RuntimeSnapshot) error { +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") } diff --git a/xray/runtime_http_test.go b/xray/runtime_http_test.go index ed18916a..43df1378 100644 --- a/xray/runtime_http_test.go +++ b/xray/runtime_http_test.go @@ -25,7 +25,7 @@ func runtimeHTTPConfig(t *testing.T) RuntimeConfig { return config } -func requestRuntime(t *testing.T, config RuntimeConfig, method, path, token string, status int) RuntimeSnapshot { +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 { @@ -50,7 +50,7 @@ func requestRuntime(t *testing.T, config RuntimeConfig, method, path, token stri if strings.Contains(string(data), config.StatePath) || strings.Contains(string(data), config.Token) { t.Fatal("runtime HTTP exposed host metadata") } - var snapshot RuntimeSnapshot + 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) diff --git a/xray/runtime_test.go b/xray/runtime_test.go index 41851e33..a8206d2c 100644 --- a/xray/runtime_test.go +++ b/xray/runtime_test.go @@ -45,7 +45,7 @@ func runtimeFixture(t *testing.T, config RuntimeConfig) (*managedRuntime, stats. return runtime, up, down } -func saveRuntimeSample(t *testing.T, runtime *managedRuntime) RuntimeSnapshot { +func saveRuntimeSample(t *testing.T, runtime *managedRuntime) runtimeSnapshot { t.Helper() runtime.sample() if err := runtime.save(); err != nil { @@ -54,7 +54,7 @@ func saveRuntimeSample(t *testing.T, runtime *managedRuntime) RuntimeSnapshot { return savedRuntime(t, runtime.config.StatePath) } -func savedRuntime(t *testing.T, path string) RuntimeSnapshot { +func savedRuntime(t *testing.T, path string) runtimeSnapshot { t.Helper() snapshot, err := readRuntimeState(path) if err != nil || snapshot.Version != 1 { From 91610c79b2ea75ef1d28565fc547b568a9b48775 Mon Sep 17 00:00:00 2001 From: yiguo Date: Sat, 5 Sep 2026 00:12:16 +0800 Subject: [PATCH 11/16] fix: address probe review feedback --- xray/ping_location_test.go | 4 ++-- xray/probe.go | 10 +++++----- xray/probe_test.go | 6 +++--- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/xray/ping_location_test.go b/xray/ping_location_test.go index 8c6f3d45..ad4418dc 100644 --- a/xray/ping_location_test.go +++ b/xray/ping_location_test.go @@ -60,8 +60,8 @@ func TestPingBatchLocationFailureDoesNotFailLatency(t *testing.T) { 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]) + if !results[0].Success || results[0].LocationJSON != nil || results[0].LocationError != "" { + t.Fatalf("latency-only result = %+v", results[0]) } } diff --git a/xray/probe.go b/xray/probe.go index ff397086..6a5a1d66 100644 --- a/xray/probe.go +++ b/xray/probe.go @@ -23,25 +23,25 @@ func ProbeXray(xrayJSON, targetURL string, timeout int, inboundTag string) (int6 uri, err := url.ParseRequestURI(targetURL) if err != nil || uri.Host == "" || uri.User != nil || (uri.Scheme != "http" && uri.Scheme != "https") || timeout < 1 || timeout > 60 { - return 0, errors.New("testXray requires an HTTP(S) URL and a timeout of 1–60 seconds") + return 0, errors.New("configuration probe requires an HTTP(S) URL and a timeout of 1–60 seconds") } coreServerMu.Lock() defer coreServerMu.Unlock() if coreServer != nil { - return 0, errors.New("testXray requires an isolated process without a managed Xray instance") + return 0, errors.New("configuration probe requires an isolated process without a managed Xray instance") } ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) defer cancel() config, err := core.LoadConfig("json", strings.NewReader(xrayJSON)) if err != nil { - return 0, errors.New("testXray configuration could not be built") + return 0, errors.New("configuration probe could not build the configuration") } if _, _, err = prepareRouteCheck(config); err != nil { return 0, err } server, err := core.NewWithContext(ctx, config) if err != nil { - return 0, errors.New("testXray configuration could not be constructed") + return 0, errors.New("configuration probe could not construct the Xray instance") } defer server.Close() transport := &http.Transport{ @@ -62,7 +62,7 @@ func ProbeXray(xrayJSON, targetURL string, timeout int, inboundTag string) (int6 }, targetURL, timeout) if err != nil { // HTTP errors may include a credential-bearing URL. Keep them local. - return 0, errors.New("testXray URL request failed") + return 0, errors.New("configuration probe URL request failed") } return delay, nil } diff --git a/xray/probe_test.go b/xray/probe_test.go index 9cf52063..03d75233 100644 --- a/xray/probe_test.go +++ b/xray/probe_test.go @@ -30,8 +30,8 @@ func TestProbeXrayUsesDraftDNSAndRoutingWithoutListening(t *testing.T) { if delay, err := ProbeXray(config, target, 2, "tunIn"); err != nil || delay < 0 { t.Fatalf("routed probe: delay=%d err=%v", delay, err) } - if _, err := ProbeXray(config, target, 1, "other"); err == nil { - t.Fatal("ignoring the draft routing incorrectly reached the target") + if _, err := ProbeXray(config, target, 1, "other"); err == nil || !strings.HasPrefix(err.Error(), "configuration probe ") { + t.Fatalf("ignoring the draft routing: %v", err) } if GetXrayState() { t.Fatal("probe published a managed instance") @@ -41,7 +41,7 @@ func TestProbeXrayUsesDraftDNSAndRoutingWithoutListening(t *testing.T) { func TestProbeXrayRejectsUnsafeRequestWithoutLeakingURL(t *testing.T) { for _, target := range []string{"file:///secret", "https://user:secret@example.com/"} { _, err := ProbeXray(`{}`, target, 1, "") - if err == nil || strings.Contains(err.Error(), "secret") { + if err == nil || !strings.HasPrefix(err.Error(), "configuration probe ") || strings.Contains(err.Error(), "secret") { t.Fatalf("unsafe error: %v", err) } } From d724851d1038f08943a64e8ed7180dc85215d1d2 Mon Sep 17 00:00:00 2001 From: yiguo Date: Sat, 5 Sep 2026 00:40:38 +0800 Subject: [PATCH 12/16] refactor: unify share conversion API --- AGENTS.md | 10 ++- README.md | 31 ++++---- invoke.go | 12 +-- invoke_model.go | 9 +-- invoke_probes_test.go | 4 +- invoke_test.go | 59 +++++++++------ readme/README.zh_CN.md | 28 +++---- share/age.go | 13 ---- share/age_test.go | 24 +++--- share/clash_meta.go | 30 -------- share/clash_meta_test.go | 4 +- share/{parse_stats.go => convert_share.go} | 10 +-- ...se_stats_test.go => convert_share_test.go} | 42 +++-------- share/generate_share_test.go | 14 ++-- share/marshal_share.go | 6 -- share/marshal_share_test.go | 22 +++--- share/parse_share.go | 73 ------------------- share/parse_share_test.go | 72 +++++++++--------- share/test_helpers_test.go | 32 ++++++++ 19 files changed, 201 insertions(+), 294 deletions(-) rename share/{parse_stats.go => convert_share.go} (89%) rename share/{parse_stats_test.go => convert_share_test.go} (72%) create mode 100644 share/test_helpers_test.go diff --git a/AGENTS.md b/AGENTS.md index 80fd0575..08a0ab0f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,12 +32,12 @@ the generic API. # Invoke API Contract -The current API version is `4`. Requests using an omitted or different +The current API version is `5`. Requests using an omitted or different `apiVersion` are rejected. ```json { - "apiVersion": 4, + "apiVersion": 5, "method": "runXray", "payload": { "xrayJson": "{\"outbounds\":[...]}" @@ -80,6 +80,12 @@ 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. +`convertShareLinksToXrayJson` accepts `text` and optional `age` fields and has +one result shape. Every successful call returns a data object containing +`config`, `usableCount`, and `failedCount`. A recognized container with no +usable nodes returns the same object with `success: false`; whole-document and +decryption failures return `data: null` because counts are unknown. + `pingBatch`, `testXray`, `checkRoute`, 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 diff --git a/README.md b/README.md index 7230ca46..84de87bd 100644 --- a/README.md +++ b/README.md @@ -175,7 +175,7 @@ The request is a JSON object: ```json { - "apiVersion": 4, + "apiVersion": 5, "method": "runXray", "payload": { "xrayJson": "{\"outbounds\":[...]}" @@ -195,7 +195,7 @@ The response is a JSON object: Design notes: -1. Invoke currently accepts only `apiVersion: 4`. Xray configurations are +1. Invoke currently accepts only `apiVersion: 5`. 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 @@ -216,6 +216,8 @@ 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 @@ -277,13 +279,13 @@ The lifecycle lock and managed-instance overlap rejection still apply. ### Draft route checking -In API version 4, `checkRoute` accepts a complete draft in +In API version 5, `checkRoute` accepts a complete draft in `xrayJson` and calls the pinned Xray-core Router, without starting the temporary instance or dispatching traffic to the supplied target: ```json { - "apiVersion": 4, + "apiVersion": 5, "method": "checkRoute", "payload": { "xrayJson": "{\"outbounds\":[{\"tag\":\"direct\",\"protocol\":\"freedom\"}]}", @@ -429,19 +431,18 @@ convert VMessAEAD/VLESS sharing protocol to Xray Json. convert VMessQRCode to Xray Json. -#### Optional parsing counts +#### Parsing result -Set `payload.includeStats: true` on `convertShareLinksToXrayJson` to return +`convertShareLinksToXrayJson` has one response shape. Its payload contains +`text` and optional `age`. Every successful conversion returns `data: {"config":{"outbounds":[...]},"usableCount":2,"failedCount":1}`. -Omitting it (or setting it to `false`) preserves the original `data.outbounds` -response and conversion behavior. 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. Stats mode skips malformed individual elements without discarding -other valid elements. `usableCount` equals the final projected, buildable +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. @@ -461,7 +462,7 @@ decrypted in memory and limited to 16 MiB of plaintext. ```json { - "apiVersion": 4, + "apiVersion": 5, "method": "convertShareLinksToXrayJson", "payload": { "text": "-----BEGIN AGE ENCRYPTED FILE-----\n...", @@ -479,7 +480,7 @@ Generate a new keypair with `keyType` set to `x25519` or `hybrid`. An omitted ```json { - "apiVersion": 4, + "apiVersion": 5, "method": "generateAgeKeyPair", "payload": { "keyType": "x25519" @@ -512,7 +513,7 @@ by the `proxy` tag, and finally by the first outbound. ```json { - "apiVersion": 4, + "apiVersion": 5, "method": "pingBatch", "payload": { "configs": [ @@ -567,7 +568,7 @@ configuration file: ```json { - "apiVersion": 4, + "apiVersion": 5, "method": "testXray", "payload": { "xrayJson": "{\"outbounds\":[...]}" @@ -582,7 +583,7 @@ to stop that instance. `runXrayFromJson` is no longer a separate method. ### Managed runtime accounting -`runXray.payload.runtime` is optional API v4 host metadata. Omitting it +`runXray.payload.runtime` is optional API v5 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): diff --git a/invoke.go b/invoke.go index 4fad8cda..1ca76e1b 100644 --- a/invoke.go +++ b/invoke.go @@ -141,16 +141,8 @@ func invokeConvertShareLinksToXrayJson(payload json.RawMessage) string { if request.Age != nil { secretKey = request.Age.SecretKey } - if request.IncludeStats { - result, err := share.ConvertShareLinksToXrayJsonWithStats(request.Text, secretKey) - return encodeInvokeResponse(result, err) - } - 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 { diff --git a/invoke_model.go b/invoke_model.go index 489489fa..5d30e194 100644 --- a/invoke_model.go +++ b/invoke_model.go @@ -10,7 +10,7 @@ import ( type LibXrayMethod string -const LibXrayAPIVersion = 4 +const LibXrayAPIVersion = 5 const ( LibXrayMethodGetFreePorts LibXrayMethod = "getFreePorts" @@ -46,12 +46,11 @@ type AgeDecryptConfig struct { } type ConvertShareLinksToXrayJsonRequest struct { - Text string `json:"text,omitempty"` - Age *AgeDecryptConfig `json:"age,omitempty"` - IncludeStats bool `json:"includeStats,omitempty"` + Text string `json:"text,omitempty"` + Age *AgeDecryptConfig `json:"age,omitempty"` } -type ConvertShareLinksToXrayJsonResponse = share.ParseStats +type ConvertShareLinksToXrayJsonResponse = share.ConvertShareLinksResult type AgeKeyType string diff --git a/invoke_probes_test.go b/invoke_probes_test.go index d19b9c15..e96d66b6 100644 --- a/invoke_probes_test.go +++ b/invoke_probes_test.go @@ -19,7 +19,7 @@ func TestInvokeShareStatsResponseShape(t *testing.T) { {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, IncludeStats: true}) + response := invokeForTest(t, LibXrayMethodConvertShareLinksToXrayJson, ConvertShareLinksToXrayJsonRequest{Text: test.text}) if response.Success != test.success { t.Fatalf("success = %v, error = %s", response.Success, response.Err) } @@ -39,7 +39,7 @@ func TestInvokeShareStatsResponseShape(t *testing.T) { } } for _, text := range []string{`{"outbounds":`, "-----BEGIN AGE ENCRYPTED FILE-----\ninvalid"} { - response := invokeForTest(t, LibXrayMethodConvertShareLinksToXrayJson, ConvertShareLinksToXrayJsonRequest{Text: text, IncludeStats: true}) + response := invokeForTest(t, LibXrayMethodConvertShareLinksToXrayJson, ConvertShareLinksToXrayJsonRequest{Text: text}) if response.Success || string(response.Data) != "null" { t.Fatalf("response = %+v", response) } diff --git a/invoke_test.go b/invoke_test.go index 9fd5d443..8d67b035 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{ @@ -285,7 +295,7 @@ func TestInvokeCheckRoute(t *testing.T) { if response.Success || string(response.Data) != "null" { t.Fatalf("invalid target should fail without evidence: %+v", response) } - response = invokeRawForTest(t, `{"apiVersion":4,"method":"checkRoute","payload":{"port":"443"}}`) + response = invokeRawForTest(t, `{"apiVersion":5,"method":"checkRoute","payload":{"port":"443"}}`) if response.Success { t.Fatal("invalid typed field accepted") } @@ -345,7 +355,7 @@ func TestInvokeRunXrayRuntimeIsOptionalTypedMetadata(t *testing.T) { if response.Success || !strings.Contains(response.Err, "absolute statePath") { t.Fatalf("runtime validation was bypassed: %+v", response) } - response = invokeRawForTest(t, `{"apiVersion":4,"method":"runXray","payload":{"xrayJson":"{}","runtime":"invalid"}}`) + response = invokeRawForTest(t, `{"apiVersion":5,"method":"runXray","payload":{"xrayJson":"{}","runtime":"invalid"}}`) if response.Success { t.Fatal("untyped runtime metadata was accepted") } @@ -456,7 +466,10 @@ 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)) } @@ -482,23 +495,23 @@ 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)) } @@ -553,7 +566,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)) } @@ -602,8 +618,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) } } @@ -734,7 +751,7 @@ func TestInvokeRemovedMethods(t *testing.T) { for _, method := range []string{"ping", "runXrayFromJson", "deriveAgePublicKey"} { response := invokeRawForTest( t, - `{"apiVersion":4,"method":"`+method+`","payload":{}}`, + `{"apiVersion":5,"method":"`+method+`","payload":{}}`, ) if response.Success { t.Fatalf("removed method %q should fail", method) @@ -786,17 +803,17 @@ func TestInvokeAPIVersion(t *testing.T) { t.Fatal("omitted apiVersion should fail") } - response = invokeRawForTest(t, `{"apiVersion":3,"method":"xrayVersion"}`) + response = invokeRawForTest(t, `{"apiVersion":4,"method":"xrayVersion"}`) if response.Success { - t.Fatal("v3 apiVersion should fail") + t.Fatal("v4 apiVersion should fail") } if got := string(response.Data); got != "null" { t.Fatalf("data = %s, want null", got) } - response = invokeRawForTest(t, `{"apiVersion":4,"method":"xrayVersion"}`) + response = invokeRawForTest(t, `{"apiVersion":5,"method":"xrayVersion"}`) if !response.Success { - t.Fatalf("v4 apiVersion should succeed: %s", response.Err) + t.Fatalf("v5 apiVersion should succeed: %s", response.Err) } } @@ -807,7 +824,7 @@ func TestInvokeNoDataResponseShape(t *testing.T) { } requireNoDataObject(t, response) - response = invokeRawForTest(t, `{"apiVersion":4,"method":"runXray","payload":"invalid"}`) + response = invokeRawForTest(t, `{"apiVersion":5,"method":"runXray","payload":"invalid"}`) if response.Success { t.Fatal("invalid runXray payload should fail") } @@ -820,7 +837,7 @@ func TestInvokeIgnoresTopLevelEnv(t *testing.T) { const key = "XRAY_LIBXRAY_UNKNOWN_ENV_TEST" _ = os.Unsetenv(key) t.Cleanup(func() { _ = os.Unsetenv(key) }) - requestJSON := `{"apiVersion":4,"method":"xrayVersion","env":{"` + key + `":"/tmp"}}` + requestJSON := `{"apiVersion":5,"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 edae12a9..cac7e788 100644 --- a/readme/README.zh_CN.md +++ b/readme/README.zh_CN.md @@ -134,7 +134,7 @@ void CGoFree(char* value); ```json { - "apiVersion": 4, + "apiVersion": 5, "method": "runXray", "payload": { "xrayJson": "{\"outbounds\":[...]}" @@ -154,12 +154,12 @@ void CGoFree(char* value); 设计决定: -1. Invoke 当前只接受 `apiVersion: 4`。Xray 配置通过 `xrayJson` 传递 UTF-8 JSON 文本;libXray 不读取配置文件路径。 +1. Invoke 当前只接受 `apiVersion: 5`。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;明文输入保持原有行为。 +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`(含 `buildOnly`)、`checkRoute` 及对应导出的 Go 入口均取得受管理生命周期锁,在加载/构建配置前拒绝同进程已运行的 `runXray` instance。批量测速在全部 worker 和临时核心关闭后才释放锁,这些临时操作也彼此串行。由管理 API 之外创建的 instance 不在检测或恢复范围内;可能与它们重叠的调用仍须使用独立进程。 支持的 method: @@ -205,13 +205,13 @@ reverse,不调用临时 instance 的 Start,也不发布为活动核心。结 ### 草稿路由检查 -API version 4 的 `checkRoute` 通过 `xrayJson` 接收完整草稿, +API version 5 的 `checkRoute` 通过 `xrayJson` 接收完整草稿, 调用当前锁定版本 Xray-core 的 Router;不启动临时 instance,也不向输入的目标 派发访问流量: ```json { - "apiVersion": 4, + "apiVersion": 5, "method": "checkRoute", "payload": { "xrayJson": "{\"outbounds\":[{\"tag\":\"direct\",\"protocol\":\"freedom\"}]}", @@ -326,16 +326,16 @@ libXray 使用 `tag` 存储节点名称。`sendThrough` 保留 Xray 原生语义 转换 VMessQRCode 为 Xray Json。 -#### 可选解析数量 +#### 解析结果 -给 `convertShareLinksToXrayJson` 传入 `payload.includeStats: true` 时,返回 +`convertShareLinksToXrayJson` 只有一种响应结构。payload 包含 `text` 和可选的 +`age`。每次转换成功均返回 `data: {"config":{"outbounds":[...]},"usableCount":2,"failedCount":1}`。 -省略或设为 `false` 时,保留原来的 `data.outbounds` 响应和转换行为。 数量只描述本次输入,不区分新增和更新。JSON 根 `outbounds` 的每个元素、YAML `proxies` 的每个元素各算一个候选。已识别的分享链接列表中,每条 URI 形式的行 算一个候选,空行、注释和文本标题忽略。Base64 / age 包装使用内部格式的候选 -数量。统计模式逐项跳过类型错误,不丢弃其余有效元素。`usableCount` 与最终投影且 +数量。类型错误的单项会被跳过,不丢弃其余有效元素。`usableCount` 与最终投影且 可构建的 outbound 数量相同;解析失败、构建失败和投影不支持的候选均计入 `failedCount`。不做节点 hash 比较或去重。 @@ -353,7 +353,7 @@ libXray 使用 `tag` 存储节点名称。`sendThrough` 保留 Xray 原生语义 ```json { - "apiVersion": 4, + "apiVersion": 5, "method": "convertShareLinksToXrayJson", "payload": { "text": "-----BEGIN AGE ENCRYPTED FILE-----\n...", @@ -371,7 +371,7 @@ libXray 使用 `tag` 存储节点名称。`sendThrough` 保留 Xray 原生语义 ```json { - "apiVersion": 4, + "apiVersion": 5, "method": "generateAgeKeyPair", "payload": { "keyType": "x25519" @@ -402,7 +402,7 @@ libXray 使用 `tag` 存储节点名称。`sendThrough` 保留 Xray 原生语义 ```json { - "apiVersion": 4, + "apiVersion": 5, "method": "pingBatch", "payload": { "configs": [ @@ -449,7 +449,7 @@ GET 成功后把响应正文原样放入 `locationJson` 字符串。JSON 解析 ```json { - "apiVersion": 4, + "apiVersion": 5, "method": "testXray", "payload": { "xrayJson": "{\"outbounds\":[...]}" @@ -464,7 +464,7 @@ GET 成功后把响应正文原样放入 `locationJson` 字符串。JSON 解析 ### 托管运行统计 -API v4 的 `runXray.payload.runtime` 为可选宿主元数据。省略时保留原生命周期, +API v5 的 `runXray.payload.runtime` 为可选宿主元数据。省略时保留原生命周期, 不写运行快照。宿主传入以下对象;Desktop 的 `-runtime` 文件也直接使用此对象, 不含外层 `runtime`,原始 Xray 配置仍通过独立的 `-config` 传入。 diff --git a/share/age.go b/share/age.go index 03644fa0..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,18 +61,6 @@ func GenerateAgeKeyPair(keyType AgeKeyType) (*AgeKeyPair, error) { } } -func ConvertShareLinksToXrayJsonWithAge(links, secretKey string) (*conf.Config, error) { - text, encrypted, err := decryptShareText(links, secretKey) - if err != nil { - return nil, err - } - config, err := ConvertShareLinksToXrayJson(text) - if err != nil && encrypted { - return nil, ErrAgePlaintextUnsupported - } - return config, err -} - func decryptShareText(links, secretKey string) (string, bool, error) { text := strings.TrimSpace(FixWindowsReturn(links)) if !strings.HasPrefix(text, ageArmorHeader) { 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..16746d0c 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,30 +122,6 @@ 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": diff --git a/share/clash_meta_test.go b/share/clash_meta_test.go index 15cec8d3..d24b0e2d 100644 --- a/share/clash_meta_test.go +++ b/share/clash_meta_test.go @@ -22,7 +22,7 @@ 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) @@ -32,7 +32,7 @@ func parseClashHy2(t *testing.T, yaml string) *conf.OutboundDetourConfig { 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/parse_stats.go b/share/convert_share.go similarity index 89% rename from share/parse_stats.go rename to share/convert_share.go index 83e8bfbc..8cbb83d7 100644 --- a/share/parse_stats.go +++ b/share/convert_share.go @@ -10,18 +10,18 @@ import ( "gopkg.in/yaml.v3" ) -// ParseStats counts source candidates, not lines or changes to a subscription. +// ConvertShareLinksResult counts source candidates, not lines or changes to a subscription. // Config contains exactly the projected, buildable outbounds counted as usable. -type ParseStats struct { +type ConvertShareLinksResult struct { Config json.RawMessage `json:"config"` UsableCount int `json:"usableCount"` FailedCount int `json:"failedCount"` } -// ConvertShareLinksToXrayJsonWithStats leaves the legacy conversion API intact. +// 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 ConvertShareLinksToXrayJsonWithStats(links, secretKey string) (*ParseStats, error) { +func ConvertShareLinksToXrayJson(links, secretKey string) (*ConvertShareLinksResult, error) { text, encrypted, err := decryptShareText(links, secretKey) if err != nil { return nil, err @@ -33,7 +33,7 @@ func ConvertShareLinksToXrayJsonWithStats(links, secretKey string) (*ParseStats, } return nil, err } - result := &ParseStats{Config: json.RawMessage(`{"outbounds":[]}`), FailedCount: candidates} + result := &ConvertShareLinksResult{Config: json.RawMessage(`{"outbounds":[]}`), FailedCount: candidates} config, err = filterBuildableOutbounds(config) if err == nil { var raw json.RawMessage diff --git a/share/parse_stats_test.go b/share/convert_share_test.go similarity index 72% rename from share/parse_stats_test.go rename to share/convert_share_test.go index d8eb204b..7a96fc91 100644 --- a/share/parse_stats_test.go +++ b/share/convert_share_test.go @@ -9,7 +9,7 @@ import ( 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 TestShareStatsCountActualCandidates(t *testing.T) { +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} @@ -30,7 +30,7 @@ func TestShareStatsCountActualCandidates(t *testing.T) { {"base64 links", base64.StdEncoding.EncodeToString([]byte(ageTestShareLink + "\nvless://bad@example.com:443")), 1, 1}, } { t.Run(test.name, func(t *testing.T) { - result, err := ConvertShareLinksToXrayJsonWithStats(test.text, "") + result, err := ConvertShareLinksToXrayJson(test.text, "") if err != nil { t.Fatal(err) } @@ -39,31 +39,31 @@ func TestShareStatsCountActualCandidates(t *testing.T) { } } -func TestShareStatsAllInvalidRetainsCountsWithoutSource(t *testing.T) { +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 := ConvertShareLinksToXrayJsonWithStats(input, "") + 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 := ConvertShareLinksToXrayJsonWithStats(`{"outbounds":[]}`, "") + result, err := ConvertShareLinksToXrayJson(`{"outbounds":[]}`, "") if err == nil { t.Fatal("empty array succeeded") } assertShareStats(t, result, 0, 0) } -func TestShareStatsMalformedDocumentHasNoCounts(t *testing.T) { +func TestConvertShareLinksMalformedDocumentHasNoCounts(t *testing.T) { for _, input := range []string{ `{"outbounds":[`, `{"outbounds":"private-source"}`, `{"outbounds":null}`, "proxies: [", "proxies: private-source", "not a subscription", } { - result, err := ConvertShareLinksToXrayJsonWithStats(input, "") + result, err := ConvertShareLinksToXrayJson(input, "") if err == nil || result != nil { t.Fatalf("result = %+v, error = %v", result, err) } @@ -73,7 +73,7 @@ func TestShareStatsMalformedDocumentHasNoCounts(t *testing.T) { } } -func TestShareStatsAgeCountsInnerCandidatesAndRedactsErrors(t *testing.T) { +func TestConvertShareLinksAgeCountsInnerCandidatesAndRedactsErrors(t *testing.T) { pair, err := GenerateAgeKeyPair(AgeKeyTypeX25519) if err != nil { t.Fatal(err) @@ -82,7 +82,7 @@ func TestShareStatsAgeCountsInnerCandidatesAndRedactsErrors(t *testing.T) { ageTestShareLink + "\nvless://secret-not-a-uuid@example.com:443", `{"outbounds":[` + statsValidOutbound + `,{"protocol":false}]}`, } { - result, err := ConvertShareLinksToXrayJsonWithStats(encryptAgeForTest(t, pair, input), pair.SecretKey) + result, err := ConvertShareLinksToXrayJson(encryptAgeForTest(t, pair, input), pair.SecretKey) if err != nil { t.Fatal(err) } @@ -95,7 +95,7 @@ func TestShareStatsAgeCountsInnerCandidatesAndRedactsErrors(t *testing.T) { {"vless://secret-not-a-uuid@example.com:443", true}, {`{"outbounds":"private-source"}`, false}, } { - result, err := ConvertShareLinksToXrayJsonWithStats(encryptAgeForTest(t, pair, test.text), pair.SecretKey) + result, err := ConvertShareLinksToXrayJson(encryptAgeForTest(t, pair, test.text), pair.SecretKey) if err != ErrAgePlaintextUnsupported { t.Fatalf("error = %v", err) } @@ -106,31 +106,13 @@ func TestShareStatsAgeCountsInnerCandidatesAndRedactsErrors(t *testing.T) { assertShareStats(t, result, 0, 1) } } - result, err := ConvertShareLinksToXrayJsonWithStats(encryptAgeForTest(t, pair, ageTestShareLink), "private-invalid-key") + result, err := ConvertShareLinksToXrayJson(encryptAgeForTest(t, pair, ageTestShareLink), "private-invalid-key") if err != ErrAgeSecretKeyInvalid || result != nil { t.Fatalf("result = %+v, error = %v", result, err) } } -func TestShareStatsPreservesLegacyProjectedConfig(t *testing.T) { - config, err := ConvertShareLinksToXrayJson(ageTestShareLink) - if err != nil { - t.Fatal(err) - } - legacy, err := MarshalShareConfigJSON(config) - if err != nil { - t.Fatal(err) - } - result, err := ConvertShareLinksToXrayJsonWithStats(ageTestShareLink, "") - if err != nil { - t.Fatal(err) - } - if string(result.Config) != string(legacy) { - t.Fatal("stats changed projected config") - } -} - -func assertShareStats(t *testing.T, result *ParseStats, usable, failed int) { +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) diff --git a/share/generate_share_test.go b/share/generate_share_test.go index 377e50bf..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) @@ -265,7 +265,7 @@ func TestConvertXrayJsonToShareLinksSkipsUnsupportedOutbounds(t *testing.T) { } func TestConvertXrayJsonToShareLinks_IgnoresSendThroughForName(t *testing.T) { - cfg, err := ConvertShareLinksToXrayJson(`trojan://pw@tag.example:443`) + cfg, err := convertShareLinksForTest(`trojan://pw@tag.example:443`) require.NoError(t, err) ob := cfg.OutboundConfigs[0] sendThrough := "127.0.0.1" diff --git a/share/marshal_share.go b/share/marshal_share.go index b0b4c4ef..1a1cbff7 100644 --- a/share/marshal_share.go +++ b/share/marshal_share.go @@ -10,12 +10,6 @@ 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) { - raw, _, err := marshalShareConfigJSON(config) - return raw, err -} - func marshalShareConfigJSON(config *conf.Config) (json.RawMessage, int, error) { if config == nil { return nil, 0, fmt.Errorf("no valid outbound found") diff --git a/share/marshal_share_test.go b/share/marshal_share_test.go index 7baac5e6..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":[ @@ -53,7 +53,7 @@ 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 = `{ @@ -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) }) diff --git a/share/parse_share.go b/share/parse_share.go index 7a24af6e..c59da5cf 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,53 +42,6 @@ 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://", @@ -140,31 +92,6 @@ func forEachLine(text string, visit func(string) bool) { } } -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 -} - type xrayShareLink struct { link *url.URL rawText string diff --git a/share/parse_share_test.go b/share/parse_share_test.go index 9e96b7ef..a14e9bc2 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,7 +208,7 @@ 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) assert.Equal(t, "Valid", config.OutboundConfigs[0].Tag) @@ -216,7 +216,7 @@ func TestConvertShareLinksToXrayJson_FiltersBuildInvalidOutbounds(t *testing.T) } 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", ) @@ -230,7 +230,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 +240,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 +248,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 +266,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 +293,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 +314,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 +328,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 +342,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 +352,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,7 +367,7 @@ 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) @@ -386,14 +386,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) @@ -404,7 +404,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) @@ -416,7 +416,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) @@ -429,7 +429,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) @@ -438,14 +438,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) @@ -461,7 +461,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) @@ -469,20 +469,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) @@ -496,14 +496,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") } @@ -516,7 +516,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) @@ -528,7 +528,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") } @@ -537,7 +537,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) @@ -548,7 +548,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) }) @@ -556,7 +556,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 +} From 854d18aa890bcf6cd420c03344964b0ddd5678f5 Mon Sep 17 00:00:00 2001 From: yiguo Date: Sat, 5 Sep 2026 00:48:59 +0800 Subject: [PATCH 13/16] chore: require Go 1.27.1 --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 8673f2966a9ec137b502e056af8935ac91939e30 Mon Sep 17 00:00:00 2001 From: yiguo Date: Sat, 5 Sep 2026 15:05:15 +0800 Subject: [PATCH 14/16] refactor: simplify configuration validation API --- AGENTS.md | 306 ++++++++------------------------------- README.md | 145 ++++--------------- invoke.go | 38 +---- invoke_model.go | 31 +--- invoke_probes_test.go | 21 --- invoke_test.go | 95 +++--------- readme/README.zh_CN.md | 111 +++----------- xray/check_route.go | 249 ------------------------------- xray/check_route_test.go | 214 --------------------------- xray/probe.go | 68 --------- xray/probe_test.go | 48 ------ xray/validation.go | 26 +--- xray/xray_test.go | 3 +- 13 files changed, 142 insertions(+), 1213 deletions(-) delete mode 100644 xray/check_route.go delete mode 100644 xray/check_route_test.go delete mode 100644 xray/probe.go delete mode 100644 xray/probe_test.go diff --git a/AGENTS.md b/AGENTS.md index 08a0ab0f..02e0ed80 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,250 +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 `5`. Requests using an omitted or different -`apiVersion` are rejected. - -```json -{ - "apiVersion": 5, - "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` -- `checkRoute` -- `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. - -`convertShareLinksToXrayJson` accepts `text` and optional `age` fields and has -one result shape. Every successful call returns a data object containing -`config`, `usableCount`, and `failedCount`. A recognized container with no -usable nodes returns the same object with `success: false`; whole-document and -decryption failures return `data: null` because counts are unknown. - -`pingBatch`, `testXray`, `checkRoute`, 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`. Its optional -location request returns the response body unchanged in `locationJson`; the App -owns JSON parsing and provider-specific semantics. - -# Runtime Semantics - -`runXray` manages one package-level Xray instance. A second `runXray` call fails -until `stopXray` closes the current instance. - -Optional `runXray.payload.runtime` saves only the current session's inbound -counters periodically and on normal stop. A new session replaces the previous -saved session without archiving it. The App owns device totals and reset; live -reads use Xray's native metrics endpoint. Optional runtime `listen`/`token` -expose the current saved snapshot over an authenticated loopback HTTP listener. -Read README.md's "Managed runtime accounting" section before changing -persistence or HTTP access. - -`testXray` (default `buildOnly: false`) and `pingBatch` create temporary Xray -instances. Xray-core has process-wide DNS client and outbound manager state. -These operations, `ValidateXray`/`buildOnly`, and `checkRoute` hold the managed -lifecycle lock and reject an active managed instance before config loading. -Batch workers share the outer lock through close. Unmanaged external instances -remain the caller's isolation responsibility; use separate processes if needed. - -`testXray` with `buildOnly: true` only loads/builds configuration and does not -construct runtime handlers. Use it for draft structure checks that must not -create TUN devices, logs, or background connections. Local asset/certificate -reads and process-level root `env` application remain core builder behavior; -successful building does not establish that runtime construction/start succeeds. - -`checkRoute` uses a temporary draft and the real Router without calling -`Start` or dispatching the supplied target. It rejects managed-instance overlap -and holds the managed lifecycle lock until matching and close finish. The draft copy removes -inbounds, log output, and webhooks; WireGuard and VLESS reverse outbounds are -rejected because construction itself has runtime side effects. DNS queries may -occur. The timeout reaches the core context, but cancellation is not a hard -wall-clock bound for every resolver. When changing route evidence or execution -boundaries, read README.md's "Draft route checking" section for field semantics -and default-loopback limitations. - -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 `run -dns -interface -config - [-runtime ]`, installs a process-wide protected Go -resolver, and runs one Xray instance until termination. The optional runtime -file contains host metadata, separate from the raw Xray configuration. - -# 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 distinguish build metadata from a successful artifact. +- 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 ``` -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 +Other targets and local-core options are documented in [build usage](README.md#usage). -1. Use `Invoke` for typed commands, Xray metrics for live counters, and runtime - HTTP only for the current saved snapshot. Keep App totals/reset - outside libXray and platform-only controller APIs 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. +## Verification -# 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 84de87bd..ccda61b4 100644 --- a/README.md +++ b/README.md @@ -175,7 +175,7 @@ The request is a JSON object: ```json { - "apiVersion": 5, + "apiVersion": 3, "method": "runXray", "payload": { "xrayJson": "{\"outbounds\":[...]}" @@ -195,9 +195,10 @@ The response is a JSON object: Design notes: -1. Invoke currently accepts only `apiVersion: 5`. 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 @@ -221,11 +222,11 @@ Design notes: 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. `pingBatch`, `testXray` (including `buildOnly`), - `checkRoute`, and their exported Go entrypoints take the managed lifecycle - lock and reject an active `runXray` instance before loading/building config. + 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 temporary operations with one another. Instances + 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. @@ -239,107 +240,12 @@ generateAgeKeyPair countGeoData pingBatch testXray -checkRoute runXray stopXray xrayVersion getXrayState ``` -### Configuration validation - -`testXray` accepts `{"xrayJson":"...","buildOnly":true}` to load and build -the complete configuration without creating an Xray instance. This validates -the 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 a build-only check. - -`buildOnly` is optional and defaults to `false`, preserving the existing -`testXray` create-and-close behavior. That behavior does not call `Start`, but -constructors can create TUN devices, open logs, or initiate background work. -Use build-only validation for an unstarted draft; a successful build does not -prove runtime resources are available or that an instance can start. Runtime -construction/start errors remain the caller's responsibility to handle. - -### Configuration URL probe - -`testXray` also accepts `url`, `timeout` (1–60 seconds), and optional -`inboundTag` with `xrayJson`. It returns `data: {"delay": 12}` in integer -milliseconds. `url` and `buildOnly: true` are mutually exclusive. Omit `url` -to retain the existing validation response. - -The probe sends an HTTP HEAD using the draft's DNS, routing and outbounds, -without forcing one outbound. It uses the route check's safe construction: -inbounds/log output/webhooks are disabled, WireGuard and VLESS reverse are -rejected, and the temporary instance is never started or published. It does -not test extra listeners, startup-only integrations, or every destination. -The lifecycle lock and managed-instance overlap rejection still apply. - -### Draft route checking - -In API version 5, `checkRoute` accepts a complete draft in -`xrayJson` and calls the pinned Xray-core Router, without starting the temporary -instance or dispatching traffic to the supplied target: - -```json -{ - "apiVersion": 5, - "method": "checkRoute", - "payload": { - "xrayJson": "{\"outbounds\":[{\"tag\":\"direct\",\"protocol\":\"freedom\"}]}", - "domain": "example.com", - "port": 443, - "network": "tcp", - "inboundTag": "tunIn", - "timeout": 5000 - } -} -``` - -Supply exactly one of `domain` (hostname, not URL) or `ip` (IPv4/IPv6 without a -zone). `port` is 1–65535, `network` is `tcp` or `udp`, and required `timeout` is -1–60000 milliseconds. `inboundTag` is optional; omitted means an empty tag, not -an assumed VPN inbound. The existing 16 MiB envelope limit applies. - -Successful `data` always includes all five fields: - -```json -{"matched":false,"ruleTag":"","outboundTag":"direct","balancerTag":"","defaulted":true} -``` - -`matched` and `ruleTag` describe the initial Router match, preserving an empty -or duplicated original rule tag. `defaulted` means the initial Router found no -matching rule. The outbound manager then supplies the actual default outbound; -a loopback's native inbound-tag/skip-DNS transition is checked again through the -Router. `outboundTag` is the terminal selected outbound, and `balancerTag` is -the last balancer encountered, or empty when none was used. Thus a draft with a -default loopback may resolve to its configured balancer, whereas an arbitrary -Raw JSON draft is never assumed to default to `proxy`. Rules reached only after -a default loopback are not reported as initial user matches. Missing handlers, loopback cycles, -traffic-dependent loopback sniffing, and routing/selection failures return an -error instead of invented evidence. Selection uses a fresh instance, not live -balancer health/history, and does not test connectivity or the exit IP. - -Only the in-memory check configuration removes inbounds, disables file/log -output, and removes rule webhooks; the caller's draft is not rewritten. -WireGuard outbounds are rejected because construction can create a TUN device -even without `Start`; VLESS reverse outbounds are rejected because construction -starts background connections. There are no inbound listeners or background probes. -DNS resolution may still send network queries through the draft configuration. -The timeout context reaches the core; a timed-out lookup never returns success. -Some core resolvers, notably `localhost`, do not honor cancellation immediately, -so this is not a strict wall-clock limit. The call waits for matching to finish -before closing the instance; it never leaves matching using an already-closed -core in the background. - -`checkRoute` rejects a managed `runXray` instance in the same process and holds -the managed lifecycle lock through construction, matching, and close. The same -managed-overlap guard also applies to `testXray` (including `buildOnly`) and -`pingBatch`. It does not detect externally created unmanaged instances; callers -must still use independent execution processes when those can overlap. - ## controller ### Socket protect @@ -462,7 +368,7 @@ decrypted in memory and limited to 16 MiB of plaintext. ```json { - "apiVersion": 5, + "apiVersion": 3, "method": "convertShareLinksToXrayJson", "payload": { "text": "-----BEGIN AGE ENCRYPTED FILE-----\n...", @@ -480,7 +386,7 @@ Generate a new keypair with `keyType` set to `x25519` or `hybrid`. An omitted ```json { - "apiVersion": 5, + "apiVersion": 3, "method": "generateAgeKeyPair", "payload": { "keyType": "x25519" @@ -513,7 +419,7 @@ by the `proxy` tag, and finally by the first outbound. ```json { - "apiVersion": 5, + "apiVersion": 3, "method": "pingBatch", "payload": { "configs": [ @@ -563,12 +469,12 @@ 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": 5, + "apiVersion": 3, "method": "testXray", "payload": { "xrayJson": "{\"outbounds\":[...]}" @@ -576,6 +482,18 @@ 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` @@ -583,7 +501,7 @@ to stop that instance. `runXrayFromJson` is no longer a separate method. ### Managed runtime accounting -`runXray.payload.runtime` is optional API v5 host metadata. Omitting it +`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): @@ -708,11 +626,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/invoke.go b/invoke.go index 1ca76e1b..4ae1a147 100644 --- a/invoke.go +++ b/invoke.go @@ -49,8 +49,6 @@ func Invoke(requestJSON string) string { return invokePingBatch(request.Payload) case LibXrayMethodTestXray: return invokeTestXray(request.Payload) - case LibXrayMethodCheckRoute: - return invokeCheckRoute(request.Payload) case LibXrayMethodRunXray: return invokeRunXray(request.Payload) case LibXrayMethodStopXray: @@ -226,21 +224,7 @@ func invokeTestXray(payload json.RawMessage) string { if err != nil { return encodeInvokeNoDataResponse(err) } - if request.URL != "" { - if request.BuildOnly { - return encodeInvokeNoDataResponse(errors.New("testXray URL and buildOnly are mutually exclusive")) - } - delay, err := xray.ProbeXray(request.XrayJson, request.URL, request.Timeout, request.InboundTag) - if err != nil { - return encodeInvokeNoDataResponse(err) - } - return encodeInvokeResponse(&TestXrayResponse{Delay: delay}, nil) - } - if request.BuildOnly { - err = xray.ValidateXray(request.XrayJson) - } else { - err = xray.TestXray(request.XrayJson) - } + err = xray.TestXray(request.XrayJson) return encodeInvokeNoDataResponse(err) } @@ -252,23 +236,3 @@ func invokeRunXray(payload json.RawMessage) string { err = xray.RunXrayWithRuntime(request.XrayJson, request.Runtime) return encodeInvokeNoDataResponse(err) } - -func invokeCheckRoute(payload json.RawMessage) string { - request, err := decodePayload[CheckRouteRequest](payload) - if err != nil { - return encodeInvokeResponse(nil, err) - } - result, err := xray.CheckRoute(xray.RouteCheckInput{ - XrayJSON: request.XrayJson, Domain: request.Domain, IP: request.IP, - Port: request.Port, Network: request.Network, - InboundTag: request.InboundTag, Timeout: request.Timeout, - }) - if err != nil { - return encodeInvokeResponse(nil, err) - } - return encodeInvokeResponse(&CheckRouteResponse{ - Matched: result.Matched, RuleTag: result.RuleTag, - OutboundTag: result.OutboundTag, BalancerTag: result.BalancerTag, - Defaulted: result.Defaulted, - }, nil) -} diff --git a/invoke_model.go b/invoke_model.go index 5d30e194..05fb45b4 100644 --- a/invoke_model.go +++ b/invoke_model.go @@ -10,7 +10,7 @@ import ( type LibXrayMethod string -const LibXrayAPIVersion = 5 +const LibXrayAPIVersion = 3 const ( LibXrayMethodGetFreePorts LibXrayMethod = "getFreePorts" @@ -20,7 +20,6 @@ const ( LibXrayMethodCountGeoData LibXrayMethod = "countGeoData" LibXrayMethodPingBatch LibXrayMethod = "pingBatch" LibXrayMethodTestXray LibXrayMethod = "testXray" - LibXrayMethodCheckRoute LibXrayMethod = "checkRoute" LibXrayMethodRunXray LibXrayMethod = "runXray" LibXrayMethodStopXray LibXrayMethod = "stopXray" LibXrayMethodXrayVersion LibXrayMethod = "xrayVersion" @@ -114,33 +113,7 @@ type RunXrayRequest struct { type RuntimeConfig = xray.RuntimeConfig type TestXrayRequest struct { - XrayJson string `json:"xrayJson,omitempty"` - BuildOnly bool `json:"buildOnly,omitempty"` - URL string `json:"url,omitempty"` - Timeout int `json:"timeout,omitempty"` - InboundTag string `json:"inboundTag,omitempty"` -} - -type TestXrayResponse struct { - Delay int64 `json:"delay"` -} - -type CheckRouteRequest struct { - XrayJson string `json:"xrayJson"` - Domain string `json:"domain,omitempty"` - IP string `json:"ip,omitempty"` - Port int `json:"port"` - Network string `json:"network"` - InboundTag string `json:"inboundTag,omitempty"` - Timeout int `json:"timeout"` -} - -type CheckRouteResponse struct { - Matched bool `json:"matched"` - RuleTag string `json:"ruleTag"` - OutboundTag string `json:"outboundTag"` - BalancerTag string `json:"balancerTag"` - Defaulted bool `json:"defaulted"` + XrayJson string `json:"xrayJson,omitempty"` } type XrayVersionResponse struct { diff --git a/invoke_probes_test.go b/invoke_probes_test.go index e96d66b6..56f795f6 100644 --- a/invoke_probes_test.go +++ b/invoke_probes_test.go @@ -46,27 +46,6 @@ func TestInvokeShareStatsResponseShape(t *testing.T) { } } -func TestInvokeConfigurationURLProbe(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNoContent) - })) - defer server.Close() - request := TestXrayRequest{XrayJson: `{"outbounds":[{"protocol":"freedom"}]}`, URL: server.URL, Timeout: 2} - response := invokeForTest(t, LibXrayMethodTestXray, request) - if !response.Success { - t.Fatal(response.Err) - } - result := decodeDataObject[TestXrayResponse](t, response) - if result.Delay < 0 || !strings.Contains(string(response.Data), `"delay"`) { - t.Fatalf("bad result: %s", response.Data) - } - request.BuildOnly = true - response = invokeForTest(t, LibXrayMethodTestXray, request) - if response.Success || string(response.Data) != "null" { - t.Fatalf("ambiguous request accepted: %+v", response) - } -} - func TestInvokePingLocationAndZeroDelayWireFields(t *testing.T) { raw, err := json.Marshal(PingBatchItemResponse{Success: true, Delay: 0}) if err != nil { diff --git a/invoke_test.go b/invoke_test.go index 8d67b035..76527de0 100644 --- a/invoke_test.go +++ b/invoke_test.go @@ -216,12 +216,12 @@ func TestInvokeTestXray(t *testing.T) { requireNoDataObject(t, response) } -func TestInvokeTestXrayBuildOnlyDoesNotCreateRuntimeResources(t *testing.T) { +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": "BuildOnlyMustNotCreate", "mtu": 1500}}, + 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{ @@ -234,70 +234,17 @@ func TestInvokeTestXrayBuildOnlyDoesNotCreateRuntimeResources(t *testing.T) { if err != nil { t.Fatal(err) } - response := invokeForTest(t, LibXrayMethodTestXray, TestXrayRequest{XrayJson: string(config), BuildOnly: true}) + response := invokeForTest(t, LibXrayMethodTestXray, TestXrayRequest{XrayJson: string(config)}) if !response.Success { - t.Fatalf("buildOnly must accept structurally valid TUN/WireGuard without construction: %s", response.Err) + 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("buildOnly created a runtime log directory: %v", err) + t.Fatalf("testXray created a runtime log directory: %v", err) } - response = invokeForTest(t, LibXrayMethodTestXray, TestXrayRequest{XrayJson: `{"outbounds":[{"protocol":"unknown"}]}`, BuildOnly: true}) + response = invokeForTest(t, LibXrayMethodTestXray, TestXrayRequest{XrayJson: `{"outbounds":[{"protocol":"unknown"}]}`}) if response.Success || string(response.Data) != "null" { - t.Fatalf("buildOnly must still reject invalid core configuration: %+v", response) - } -} - -func TestInvokeTestXrayDefaultStillConstructsRuntime(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"}, - "outbounds": []any{map[string]any{"protocol": "freedom"}}, - }) - if err != nil { - t.Fatal(err) - } - for _, payload := range []any{ - TestXrayRequest{XrayJson: string(config)}, - map[string]any{"xrayJson": string(config), "buildOnly": false}, - } { - response := invokeForTest(t, LibXrayMethodTestXray, payload) - if response.Success || !strings.Contains(response.Err, "failed to initialize error logger") { - t.Fatalf("omitted/false buildOnly must retain runtime construction: %+v", response) - } - } -} - -func TestInvokeCheckRoute(t *testing.T) { - request := CheckRouteRequest{ - XrayJson: `{"outbounds":[{"protocol":"freedom","tag":"direct"}],"routing":{"rules":[{"domain":["full:example.com"],"outboundTag":"direct"}]}}`, - Domain: "example.com", Port: 443, Network: "tcp", InboundTag: "tunIn", Timeout: 5000, - } - response := invokeForTest(t, LibXrayMethodCheckRoute, request) - if !response.Success { - t.Fatal(response.Err) - } - data := decodeDataObject[CheckRouteResponse](t, response) - if data != (CheckRouteResponse{Matched: true, OutboundTag: "direct"}) { - t.Fatalf("unexpected route evidence: %+v", data) - } - var fields map[string]json.RawMessage - if err := json.Unmarshal(response.Data, &fields); err != nil { - t.Fatal(err) - } - for _, name := range []string{"matched", "ruleTag", "outboundTag", "balancerTag", "defaulted"} { - if _, ok := fields[name]; !ok { - t.Fatalf("missing required evidence field %s", name) - } - } - request.IP = "192.0.2.1" - response = invokeForTest(t, LibXrayMethodCheckRoute, request) - if response.Success || string(response.Data) != "null" { - t.Fatalf("invalid target should fail without evidence: %+v", response) - } - response = invokeRawForTest(t, `{"apiVersion":5,"method":"checkRoute","payload":{"port":"443"}}`) - if response.Success { - t.Fatal("invalid typed field accepted") + t.Fatalf("testXray must still reject invalid core configuration: %+v", response) } } @@ -355,7 +302,7 @@ func TestInvokeRunXrayRuntimeIsOptionalTypedMetadata(t *testing.T) { if response.Success || !strings.Contains(response.Err, "absolute statePath") { t.Fatalf("runtime validation was bypassed: %+v", response) } - response = invokeRawForTest(t, `{"apiVersion":5,"method":"runXray","payload":{"xrayJson":"{}","runtime":"invalid"}}`) + response = invokeRawForTest(t, `{"apiVersion":3,"method":"runXray","payload":{"xrayJson":"{}","runtime":"invalid"}}`) if response.Success { t.Fatal("untyped runtime metadata was accepted") } @@ -748,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":5,"method":"`+method+`","payload":{}}`, + `{"apiVersion":3,"method":"`+method+`","payload":{}}`, ) if response.Success { t.Fatalf("removed method %q should fail", method) @@ -803,17 +750,19 @@ func TestInvokeAPIVersion(t *testing.T) { t.Fatal("omitted apiVersion should fail") } - response = invokeRawForTest(t, `{"apiVersion":4,"method":"xrayVersion"}`) - if response.Success { - t.Fatal("v4 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":5,"method":"xrayVersion"}`) + response = invokeRawForTest(t, `{"apiVersion":3,"method":"xrayVersion"}`) if !response.Success { - t.Fatalf("v5 apiVersion should succeed: %s", response.Err) + t.Fatalf("v3 apiVersion should succeed: %s", response.Err) } } @@ -824,7 +773,7 @@ func TestInvokeNoDataResponseShape(t *testing.T) { } requireNoDataObject(t, response) - response = invokeRawForTest(t, `{"apiVersion":5,"method":"runXray","payload":"invalid"}`) + response = invokeRawForTest(t, `{"apiVersion":3,"method":"runXray","payload":"invalid"}`) if response.Success { t.Fatal("invalid runXray payload should fail") } @@ -837,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":5,"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 cac7e788..d7f0dce0 100644 --- a/readme/README.zh_CN.md +++ b/readme/README.zh_CN.md @@ -134,7 +134,7 @@ void CGoFree(char* value); ```json { - "apiVersion": 5, + "apiVersion": 3, "method": "runXray", "payload": { "xrayJson": "{\"outbounds\":[...]}" @@ -154,13 +154,13 @@ void CGoFree(char* value); 设计决定: -1. Invoke 当前只接受 `apiVersion: 5`。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 保持不变。每次成功响应都会返回投影后的配置及 `usableCount` 和 `failedCount`。可选的 `age.secretKey` 会在现有解析流程前于内存中解密官方 age ASCII armor;明文输入保持原有行为。 -7. Xray-core 的系统拨号 DNS client 和 outbound manager 属于进程级状态。`pingBatch`、`testXray`(含 `buildOnly`)、`checkRoute` 及对应导出的 Go 入口均取得受管理生命周期锁,在加载/构建配置前拒绝同进程已运行的 `runXray` instance。批量测速在全部 worker 和临时核心关闭后才释放锁,这些临时操作也彼此串行。由管理 API 之外创建的 instance 不在检测或恢复范围内;可能与它们重叠的调用仍须使用独立进程。 +7. Xray-core 的系统拨号 DNS client 和 outbound manager 属于进程级状态。`pingBatch`、`testXray` 及对应导出的 Go 入口均取得受管理生命周期锁,在加载/构建配置前拒绝同进程已运行的 `runXray` instance。批量测速在全部 worker 和临时核心关闭后才释放锁,这些操作也彼此串行。由管理 API 之外创建的 instance 不在检测或恢复范围内;可能与它们重叠的调用仍须使用独立进程。 支持的 method: @@ -172,91 +172,12 @@ generateAgeKeyPair countGeoData pingBatch testXray -checkRoute runXray stopXray xrayVersion getXrayState ``` -### 配置校验 - -`testXray` 支持 `{"xrayJson":"...","buildOnly":true}`,只加载并构建完整配置, -不创建 Xray instance。它校验包括 TUN/WireGuard 定义在内的配置结构,不创建设备、 -监听、日志文件或后台连接。构建器仍可能读取本地 GeoData/证书,并将根 `env` 应用 -到当前进程。Geodata assets 声明只校验 HTTPS URL 和已存在的本地文件,下载器及 -cron 不会在只构建校验期间运行。 - -`buildOnly` 可选,默认 `false`,保留原有 `testXray` 创建并关闭 instance 的行为。 -原行为虽然不调用 `Start`,但构造函数可能创建 TUN、打开日志或启动后台任务。 -尚未启动的草稿应使用只构建校验;构建成功不代表运行资源可用,也不代表 instance -可以启动。调用方仍须处理真实构造和启动阶段的失败。 - -### 配置 URL 测试 - -`testXray` 还可随 `xrayJson` 提供 `url`、`timeout`(1–60 秒)和可选 -`inboundTag`,返回整数毫秒 `data: {"delay": 12}`。`url` 与 -`buildOnly: true` 互斥;省略 `url` 时保留原校验响应。 - -测试使用草稿完整的 DNS、routing 和 outbounds 发送 HTTP HEAD,不强制单个出站。 -沿用路由检查的安全构造:禁用入站、日志输出和 webhook,拒绝 WireGuard/VLESS -reverse,不调用临时 instance 的 Start,也不发布为活动核心。结果不证明额外监听、 -仅在启动时工作的集成或所有目标均可用;生命周期锁和受管理核心重叠拒绝仍然生效。 - -### 草稿路由检查 - -API version 5 的 `checkRoute` 通过 `xrayJson` 接收完整草稿, -调用当前锁定版本 Xray-core 的 Router;不启动临时 instance,也不向输入的目标 -派发访问流量: - -```json -{ - "apiVersion": 5, - "method": "checkRoute", - "payload": { - "xrayJson": "{\"outbounds\":[{\"tag\":\"direct\",\"protocol\":\"freedom\"}]}", - "domain": "example.com", - "port": 443, - "network": "tcp", - "inboundTag": "tunIn", - "timeout": 5000 - } -} -``` - -`domain`(主机名,不是 URL)和 `ip`(不带 zone 的 IPv4/IPv6)必须且只能提供 -一个。`port` 为 1–65535,`network` 为 `tcp` 或 `udp`,必填的 `timeout` 为 -1–60000 毫秒。`inboundTag` 可选,省略时为空,不默认假设 VPN 入站。沿用 -16 MiB 的完整请求/响应包体限制。 - -成功响应的 `data` 始终包含全部五个字段: - -```json -{"matched":false,"ruleTag":"","outboundTag":"direct","balancerTag":"","defaulted":true} -``` - -`matched` 和 `ruleTag` 表示首次 Router 匹配,保留原始的空名称或重名。 -`defaulted` 表示首次 Router 没有匹配规则,随后使用 outbound manager 的真实 -默认出站;如果是 loopback,则按其原生入站 tag / 跳过 DNS 解析的转换再次调用 -Router。`outboundTag` 是最终选中的出站,`balancerTag` 是路径中最后经过的 -balancer,没有则为空。因此带默认 loopback 的草稿可以得到其实际配置的 -balancer,但不会假设任意 Raw JSON 的默认动作都是 `proxy`。仅在默认 loopback -之后命中的规则不会被报告为首次用户规则命中。出站不存在、loopback 循环、依赖访问流量的 loopback -sniffing、路由或节点选择失败均返回错误,不生成虚假结果。节点选择使用新建 -instance,而非运行实例的健康度/历史;它不验证连通性或出口 IP。 - -只在内存中的检查配置移除 inbounds、禁用日志输出并移除规则 webhook,不回写 -调用方草稿。WireGuard 出站会在构造时创建 TUN,因此即使不调用 `Start`,也必须 -拒绝这类检查。不会启动入站监听或后台探测。DNS 解析仍可能通过草稿配置发出网络 -查询。VLESS reverse 出站也会在构造时启动后台连接,因此同样拒绝。timeout 的 -context 会传入核心,超时后不会返回成功;但部分核心解析器 -(尤其 `localhost`)不能立即响应取消,因此不承诺严格的墙钟耗时上限。调用会等 -匹配实际结束后才关闭 instance,不会留下继续使用已关闭核心的后台匹配。 - -`checkRoute` 拒绝与同进程受管理的 `runXray` instance 重叠,并在构建、匹配和 -关闭期间持有受管理生命周期锁。`testXray`(含 `buildOnly`)和 `pingBatch` 具有相同 -保护;未托管 instance 不在检测范围内,可能与其重叠时调用方仍须使用独立进程。 - ## controller 用于解决 Android 上 socket protect 问题。 @@ -353,7 +274,7 @@ libXray 使用 `tag` 存储节点名称。`sendThrough` 保留 Xray 原生语义 ```json { - "apiVersion": 5, + "apiVersion": 3, "method": "convertShareLinksToXrayJson", "payload": { "text": "-----BEGIN AGE ENCRYPTED FILE-----\n...", @@ -371,7 +292,7 @@ libXray 使用 `tag` 存储节点名称。`sendThrough` 保留 Xray 原生语义 ```json { - "apiVersion": 5, + "apiVersion": 3, "method": "generateAgeKeyPair", "payload": { "keyType": "x25519" @@ -402,7 +323,7 @@ libXray 使用 `tag` 存储节点名称。`sendThrough` 保留 Xray 原生语义 ```json { - "apiVersion": 5, + "apiVersion": 3, "method": "pingBatch", "payload": { "configs": [ @@ -445,11 +366,12 @@ GET 成功后把响应正文原样放入 `locationJson` 字符串。JSON 解析 ### testXray -直接校验传入的 Xray JSON 文本,不读取配置文件: +加载并构建传入的完整 Xray JSON 文本。payload 仅包含 `xrayJson`,成功时返回 +`data: {}`: ```json { - "apiVersion": 5, + "apiVersion": 3, "method": "testXray", "payload": { "xrayJson": "{\"outbounds\":[...]}" @@ -457,6 +379,14 @@ GET 成功后把响应正文原样放入 `locationJson` 字符串。JSON 解析 } ``` +Go 入口 `TestXray` 只调用 `core.LoadConfig`,不构造或启动 Xray instance 及运行时 +handler。它校验包括 TUN/WireGuard 定义在内的配置结构,不创建设备、监听、日志文件 +或后台连接。构建器仍可能读取本地 GeoData/证书,并将根 `env` 应用到当前进程。 +Geodata assets 声明只校验 HTTPS URL 和已存在的本地文件,下载器及 cron 不在校验时运行。 + +校验成功只说明配置可以构建,不保证运行资源可用、instance 可以启动或网络可以连接。 +调用方仍须处理实际启动失败。 + ### runXray 使用传入的 Xray JSON 文本启动由 libXray 管理的 Xray instance,并通过 @@ -464,7 +394,7 @@ GET 成功后把响应正文原样放入 `locationJson` 字符串。JSON 解析 ### 托管运行统计 -API v5 的 `runXray.payload.runtime` 为可选宿主元数据。省略时保留原生命周期, +API v3 的 `runXray.payload.runtime` 为可选宿主元数据。省略时保留原生命周期, 不写运行快照。宿主传入以下对象;Desktop 的 `-runtime` 文件也直接使用此对象, 不含外层 `runtime`,原始 Xray 配置仍通过独立的 `-config` 传入。 @@ -573,10 +503,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/xray/check_route.go b/xray/check_route.go deleted file mode 100644 index ed3e1bd5..00000000 --- a/xray/check_route.go +++ /dev/null @@ -1,249 +0,0 @@ -package xray - -import ( - "context" - "errors" - "fmt" - "net/netip" - "strings" - "time" - - xlog "github.com/xtls/xray-core/app/log" - "github.com/xtls/xray-core/app/router" - "github.com/xtls/xray-core/common" - xnet "github.com/xtls/xray-core/common/net" - "github.com/xtls/xray-core/common/serial" - "github.com/xtls/xray-core/common/session" - "github.com/xtls/xray-core/core" - "github.com/xtls/xray-core/features/outbound" - "github.com/xtls/xray-core/features/routing" - rsession "github.com/xtls/xray-core/features/routing/session" - "github.com/xtls/xray-core/proxy/loopback" - "github.com/xtls/xray-core/proxy/vless" - vlessoutbound "github.com/xtls/xray-core/proxy/vless/outbound" - "github.com/xtls/xray-core/proxy/wireguard" - "golang.org/x/net/idna" -) - -type RouteCheckInput struct { - XrayJSON string - Domain string - IP string - Port int - Network string - InboundTag string - Timeout int // milliseconds -} - -type RouteCheckResult struct { - Matched bool - RuleTag string - OutboundTag string - BalancerTag string - Defaulted bool -} - -type routeRuleEvidence struct { - ruleTag string - balancerTag string -} - -// CheckRoute checks a draft with the real Router without starting the instance -// or dispatching the target. DNS lookup can still use the draft's outbounds. -// Like TestXray, construction changes core process globals: callers must isolate -// this operation from other non-managed instances. Managed overlap is rejected. -func CheckRoute(input RouteCheckInput) (result RouteCheckResult, err error) { - target, err := routeCheckTarget(input) - if err != nil { - return result, err - } - coreServerMu.Lock() - defer coreServerMu.Unlock() - if coreServer != nil { - return result, errors.New("checkRoute requires an isolated process without a managed Xray instance") - } - - ctx, cancel := context.WithTimeout(context.Background(), time.Duration(input.Timeout)*time.Millisecond) - defer cancel() - config, err := core.LoadConfig("json", strings.NewReader(input.XrayJSON)) - if err != nil { - return result, err - } - rules, loops, err := prepareRouteCheck(config) - if err != nil { - return result, err - } - if err = ctx.Err(); err != nil { - return result, err - } - server, err := core.NewWithContext(ctx, config) - if err != nil { - return result, err - } - defer func() { - // Never close an instance while PickRoute/DNS is still using it. - cancel() - err = errors.Join(err, server.Close()) - }() - result, err = checkRoute(ctx, server, rules, loops, &rsession.Context{ - Inbound: &session.Inbound{Tag: input.InboundTag}, - Outbound: &session.Outbound{Target: target}, - }) - if deadlineErr := ctx.Err(); deadlineErr != nil { - return RouteCheckResult{}, deadlineErr - } - return result, err -} - -func routeCheckTarget(input RouteCheckInput) (xnet.Destination, error) { - invalid := xnet.Destination{} - if len(input.XrayJSON) == 0 || len(input.XrayJSON) > 16*1024*1024 { - return invalid, errors.New("checkRoute xrayJson must be nonempty and no larger than 16 MiB") - } - if (input.Domain == "") == (input.IP == "") { - return invalid, errors.New("checkRoute requires exactly one of domain or ip") - } - if input.Port < 1 || input.Port > 65535 { - return invalid, errors.New("checkRoute port must be between 1 and 65535") - } - if input.Timeout < 1 || input.Timeout > 60000 { - return invalid, errors.New("checkRoute timeout must be between 1 and 60000 milliseconds") - } - var network xnet.Network - switch input.Network { - case "tcp": - network = xnet.Network_TCP - case "udp": - network = xnet.Network_UDP - default: - return invalid, errors.New("checkRoute network must be tcp or udp") - } - var address xnet.Address - if input.IP != "" { - ip, err := netip.ParseAddr(input.IP) - if err != nil || ip.Zone() != "" { - return invalid, errors.New("checkRoute ip must be an IPv4 or IPv6 address without a zone") - } - address = xnet.IPAddress(ip.AsSlice()) - } else { - domain, err := idna.Lookup.ToASCII(input.Domain) - domain = strings.TrimSuffix(domain, ".") - if err != nil || len(domain) == 0 || len(domain) > 253 { - return invalid, errors.New("checkRoute domain must be a valid hostname") - } - if _, err := netip.ParseAddr(domain); err == nil { - return invalid, errors.New("checkRoute IP literals must use the ip field") - } - for _, label := range strings.Split(domain, ".") { - if len(label) == 0 || len(label) > 63 { - return invalid, errors.New("checkRoute domain contains an invalid label") - } - } - address = xnet.DomainAddress(domain) - } - return xnet.Destination{Network: network, Address: address, Port: xnet.Port(input.Port)}, nil -} - -func prepareRouteCheck(config *core.Config) (map[string]routeRuleEvidence, map[string]*loopback.Config, error) { - // A TUN inbound can allocate its device during construction, before Start. - config.Inbound = nil - rules := make(map[string]routeRuleEvidence) - for index, app := range config.App { - settings, err := app.GetInstance() - if err != nil { - return nil, nil, err - } - switch settings := settings.(type) { - case *xlog.Config: - // Logger construction opens files; draft checking must not write them. - config.App[index] = serial.ToTypedMessage(&xlog.Config{}) - case *router.Config: - for index, rule := range settings.Rule { - tag := fmt.Sprintf("__libxray_check_rule_%d", index) - rules[tag] = routeRuleEvidence{rule.GetRuleTag(), rule.GetBalancingTag()} - rule.RuleTag = tag - // PickRoute fires webhooks even without dispatcher publication. - rule.Webhook = nil - } - config.App[index] = serial.ToTypedMessage(settings) - } - } - loops := make(map[string]*loopback.Config) - for index, handler := range config.Outbound { - settings, err := handler.ProxySettings.GetInstance() - if err != nil { - return nil, nil, err - } - switch settings := settings.(type) { - case *wireguard.DeviceConfig: - return nil, nil, errors.New("checkRoute cannot construct WireGuard outbounds without creating a TUN device") - case *vlessoutbound.Config: - account, err := settings.GetVnext().GetUser().GetAccount().GetInstance() - if err != nil { - return nil, nil, err - } - if account.(*vless.Account).Reverse != nil { - return nil, nil, errors.New("checkRoute cannot construct VLESS reverse outbounds without starting background connections") - } - case *loopback.Config: - // Only the first untagged handler can be the default; later ones - // cannot be addressed by a routing rule. - if handler.Tag != "" || index == 0 { - loops[handler.Tag] = settings - } - } - } - return rules, loops, nil -} - -func checkRoute(ctx context.Context, server *core.Instance, rules map[string]routeRuleEvidence, loops map[string]*loopback.Config, input *rsession.Context) (RouteCheckResult, error) { - var result RouteCheckResult - router := server.GetFeature(routing.RouterType()).(routing.Router) - manager := server.GetFeature(outbound.ManagerType()).(outbound.Manager) - visited := make(map[string]bool) - for hop := 0; ; hop++ { - if err := ctx.Err(); err != nil { - return result, err - } - picked, err := router.PickRoute(input) - if err == nil { - evidence := rules[picked.GetRuleTag()] - if hop == 0 { - result.Matched = true - result.RuleTag = evidence.ruleTag - } - result.OutboundTag = picked.GetOutboundTag() - if evidence.balancerTag != "" { - result.BalancerTag = evidence.balancerTag - } - if manager.GetHandler(result.OutboundTag) == nil { - return result, errors.New("checkRoute matched an outbound that does not exist") - } - } else if errors.Is(err, common.ErrNoClue) { - if hop == 0 { - result.Defaulted = true - } - fallback := manager.GetDefaultHandler() - if fallback == nil { - return result, errors.New("checkRoute has no default outbound") - } - result.OutboundTag = fallback.Tag() - } else { - return result, err - } - loop := loops[result.OutboundTag] - if loop == nil { - return result, nil - } - if loop.Sniffing.GetEnabled() { - return result, errors.New("checkRoute cannot determine a loopback path that requires traffic sniffing") - } - if visited[result.OutboundTag] { - return result, errors.New("checkRoute encountered a loopback routing cycle") - } - visited[result.OutboundTag] = true - // Follow only the real loopback metadata transition, never DispatchLink. - input.Inbound = &session.Inbound{Tag: loop.InboundTag} - input.Content = &session.Content{SkipDNSResolve: true} - } -} diff --git a/xray/check_route_test.go b/xray/check_route_test.go deleted file mode 100644 index 5926e2f4..00000000 --- a/xray/check_route_test.go +++ /dev/null @@ -1,214 +0,0 @@ -package xray - -import ( - "context" - "errors" - "fmt" - "net" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "sync/atomic" - "testing" - "time" -) - -const routeCheckConfig = `{ - "log": {"loglevel":"none"}, - "dns": {"hosts":{"ip-rule.test":"192.0.2.2", "unknown.test":"198.51.100.2"}}, - "observatory": {"subjectSelector":[]}, - "outbounds": [ - {"tag":"default-loop","protocol":"loopback","settings":{"inboundTag":"default-vpn"}}, - {"tag":"direct","protocol":"freedom"}, - {"tag":"block","protocol":"blackhole"}, - {"tag":"entry-1","protocol":"freedom"} - ], - "routing": { - "domainStrategy":"IPIfNonMatch", - "balancers":[{"tag":"proxy","selector":["entry-1"],"strategy":{"type":"roundRobin"},"fallbackTag":"block"}], - "rules":[ - {"ruleTag":"default-vpn","inboundTag":["default-vpn"],"balancerTag":"proxy"}, - {"ruleTag":"duplicate","domain":["full:domain-rule.test"],"port":"443","network":"tcp","outboundTag":"direct"}, - {"ruleTag":"duplicate","ip":["192.0.2.0/24"],"outboundTag":"block"}, - {"ruleTag":"selected-vpn","domain":["full:vpn.test"],"balancerTag":"proxy"}, - {"domain":["full:unnamed.test"],"outboundTag":"direct"} - ] - } -}` - -func routeInput(config string) RouteCheckInput { - return RouteCheckInput{XrayJSON: config, Domain: "unknown.test", Port: 443, Network: "tcp", InboundTag: "tunIn", Timeout: 5000} -} - -func TestCheckRouteCoreEvidence(t *testing.T) { - for _, sample := range []struct { - name, domain, ip, network string - port int - want RouteCheckResult - }{ - {"domain", "domain-rule.test", "", "tcp", 443, RouteCheckResult{Matched: true, RuleTag: "duplicate", OutboundTag: "direct"}}, - {"resolved IP", "ip-rule.test", "", "tcp", 443, RouteCheckResult{Matched: true, RuleTag: "duplicate", OutboundTag: "block"}}, - {"IP literal", "", "192.0.2.3", "udp", 53, RouteCheckResult{Matched: true, RuleTag: "duplicate", OutboundTag: "block"}}, - {"explicit balancer", "vpn.test", "", "tcp", 443, RouteCheckResult{Matched: true, RuleTag: "selected-vpn", OutboundTag: "entry-1", BalancerTag: "proxy"}}, - {"unnamed", "unnamed.test", "", "tcp", 443, RouteCheckResult{Matched: true, OutboundTag: "direct"}}, - {"default VPN", "unknown.test", "", "tcp", 443, RouteCheckResult{Defaulted: true, OutboundTag: "entry-1", BalancerTag: "proxy"}}, - {"AND network", "domain-rule.test", "", "udp", 443, RouteCheckResult{Defaulted: true, OutboundTag: "entry-1", BalancerTag: "proxy"}}, - {"AND port", "domain-rule.test", "", "tcp", 80, RouteCheckResult{Defaulted: true, OutboundTag: "entry-1", BalancerTag: "proxy"}}, - } { - t.Run(sample.name, func(t *testing.T) { - input := routeInput(routeCheckConfig) - input.Domain, input.IP, input.Network, input.Port = sample.domain, sample.ip, sample.network, sample.port - // Keep negative domain cases local, too: no external DNS in tests. - input.XrayJSON = strings.Replace(input.XrayJSON, `"unknown.test":"198.51.100.2"`, `"unknown.test":"198.51.100.2","domain-rule.test":"198.51.100.3"`, 1) - got, err := CheckRoute(input) - if err != nil || got != sample.want { - t.Fatalf("got %+v, %v; want %+v", got, err, sample.want) - } - }) - } - - t.Run("Raw default is not assumed to be proxy", func(t *testing.T) { - got, err := CheckRoute(routeInput(minimalConfig)) - want := RouteCheckResult{Defaulted: true, OutboundTag: "direct"} - if err != nil || got != want { - t.Fatalf("got %+v, %v; want %+v", got, err, want) - } - }) - - t.Run("ordinary VLESS is supported without connecting", func(t *testing.T) { - input := routeInput(`{"outbounds":[{"tag":"entry","protocol":"vless","settings":{"address":"127.0.0.1","port":9,"id":"00000000-0000-0000-0000-000000000000","encryption":"none"}}]}`) - got, err := CheckRoute(input) - if err != nil || got != (RouteCheckResult{Defaulted: true, OutboundTag: "entry"}) { - t.Fatalf("ordinary VLESS: %+v %v", got, err) - } - }) -} - -func TestCheckRouteDoesNotStartListenPublishOrDialTarget(t *testing.T) { - var requests atomic.Int32 - target := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { - requests.Add(1) - })) - defer target.Close() - address := target.Listener.Addr().(*net.TCPAddr) - logPath := filepath.Join(t.TempDir(), "must-not-exist.log") - config := fmt.Sprintf(`{ - "log":{"access":%q,"error":%q,"loglevel":"debug"}, - "inbounds":[{"listen":"127.0.0.1","port":%d,"protocol":"socks"}], - "outbounds":[{"tag":"direct","protocol":"freedom"}], - "observatory":{"subjectSelector":["direct"],"probeUrl":%q,"probeInterval":"1ms"}, - "routing":{"rules":[{"ruleTag":"test","network":"tcp","outboundTag":"direct","webhook":{"url":%q}}]} - }`, logPath, logPath, address.Port, target.URL, target.URL) - input := routeInput(config) - input.Domain, input.IP, input.Port = "", "127.0.0.1", address.Port - got, err := CheckRoute(input) - if err != nil || !got.Matched || got.OutboundTag != "direct" { - t.Fatalf("route failed: %+v %v", got, err) - } - if requests.Load() != 0 { - t.Fatal("route checking must not dispatch target, webhook, or probes") - } - if _, err := os.Stat(logPath); !errors.Is(err, os.ErrNotExist) { - t.Fatalf("draft log was opened: %v", err) - } -} - -func TestCheckRouteRejectsManagedOverlapBeforeLoadingEnv(t *testing.T) { - if err := RunXray(minimalConfig); err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = StopXray() }) - const key = "XRAY_LIBXRAY_CHECK_ROUTE_TEST" - t.Setenv(key, "original") - input := routeInput(`{"env":{"` + key + `":"changed"},"outbounds":[{"protocol":"freedom"}]}`) - if _, err := CheckRoute(input); err == nil || !strings.Contains(err.Error(), "isolated process") { - t.Fatalf("expected managed-overlap error, got %v", err) - } - if os.Getenv(key) != "original" || !GetXrayState() { - t.Fatal("route check modified managed runtime or process environment") - } -} - -func TestCheckRouteDNSDeadline(t *testing.T) { - blackhole, err := net.ListenPacket("udp", "127.0.0.1:0") - if err != nil { - t.Fatal(err) - } - defer blackhole.Close() - address := blackhole.LocalAddr().(*net.UDPAddr) - config := fmt.Sprintf(`{ - "dns":{"servers":[{"address":"127.0.0.1","port":%d}]}, - "outbounds":[{"tag":"direct","protocol":"freedom"}], - "routing":{"domainStrategy":"IPIfNonMatch","rules":[{"ip":["192.0.2.0/24"],"outboundTag":"direct"}]} - }`, address.Port) - input := routeInput(config) - input.Timeout = 100 - started := time.Now() - if _, err := CheckRoute(input); !errors.Is(err, context.DeadlineExceeded) { - t.Fatalf("expected deadline, got %v", err) - } - if time.Since(started) > 3*time.Second { - t.Fatal("core DNS did not honor the operation context") - } - // The timed-out operation is fully closed before the managed instance starts. - if err := RunXray(minimalConfig); err != nil { - t.Fatal(err) - } - if err := StopXray(); err != nil { - t.Fatal(err) - } -} - -func TestCheckRouteRejectsInvalidInputsAndUnresolvedPaths(t *testing.T) { - for _, sample := range []struct { - name string - edit func(*RouteCheckInput) - }{ - {"empty config", func(i *RouteCheckInput) { i.XrayJSON = "" }}, - {"path is not JSON", func(i *RouteCheckInput) { i.XrayJSON = "/xray.json" }}, - {"malformed JSON", func(i *RouteCheckInput) { i.XrayJSON = "{" }}, - {"missing target", func(i *RouteCheckInput) { i.Domain = "" }}, - {"two targets", func(i *RouteCheckInput) { i.IP = "192.0.2.1" }}, - {"URL is not domain", func(i *RouteCheckInput) { i.Domain = "https://example.com" }}, - {"empty label", func(i *RouteCheckInput) { i.Domain = "example..com" }}, - {"IP in domain", func(i *RouteCheckInput) { i.Domain = "192.0.2.1" }}, - {"invalid IP", func(i *RouteCheckInput) { i.Domain, i.IP = "", "invalid" }}, - {"scoped IP", func(i *RouteCheckInput) { i.Domain, i.IP = "", "fe80::1%en0" }}, - {"port zero", func(i *RouteCheckInput) { i.Port = 0 }}, - {"port overflow", func(i *RouteCheckInput) { i.Port = 65536 }}, - {"unsupported network", func(i *RouteCheckInput) { i.Network = "icmp" }}, - {"missing timeout", func(i *RouteCheckInput) { i.Timeout = 0 }}, - {"timeout overflow", func(i *RouteCheckInput) { i.Timeout = 60001 }}, - {"no default", func(i *RouteCheckInput) { i.XrayJSON = "{}" }}, - {"loop cycle", func(i *RouteCheckInput) { - i.XrayJSON = `{"outbounds":[{"protocol":"loopback","settings":{"inboundTag":"repeat"}}]}` - }}, - {"loop sniffing", func(i *RouteCheckInput) { - i.XrayJSON = `{"outbounds":[{"protocol":"loopback","settings":{"inboundTag":"repeat","sniffing":{"enabled":true,"destOverride":["tls"]}}}]}` - }}, - {"missing selected handler", func(i *RouteCheckInput) { - i.XrayJSON = `{"outbounds":[{"protocol":"freedom"}],"routing":{"rules":[{"network":"tcp","outboundTag":"missing"}]}}` - }}, - } { - t.Run(sample.name, func(t *testing.T) { - input := routeInput(minimalConfig) - sample.edit(&input) - if _, err := CheckRoute(input); err == nil { - t.Fatal("invalid input/path accepted") - } - }) - } -} - -func TestCheckRouteRejectsConstructionSideEffects(t *testing.T) { - for _, sample := range []struct{ config, message string }{ - {`{"outbounds":[{"protocol":"wireguard","settings":{"secretKey":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=","address":["10.0.0.2/32"],"peers":[{"publicKey":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=","endpoint":"127.0.0.1:9"}]}}]}`, "without creating a TUN device"}, - {`{"outbounds":[{"protocol":"vless","settings":{"address":"127.0.0.1","port":9,"id":"00000000-0000-0000-0000-000000000000","encryption":"none","reverse":{"tag":"reverse"}}}]}`, "without starting background connections"}, - } { - if _, err := CheckRoute(routeInput(sample.config)); err == nil || !strings.Contains(err.Error(), sample.message) { - t.Fatalf("expected explicit construction guard %q, got %v", sample.message, err) - } - } -} diff --git a/xray/probe.go b/xray/probe.go deleted file mode 100644 index 6a5a1d66..00000000 --- a/xray/probe.go +++ /dev/null @@ -1,68 +0,0 @@ -package xray - -import ( - "context" - "errors" - "net" - "net/http" - "net/url" - "strings" - "time" - - "github.com/xtls/libxray/nodep" - xnet "github.com/xtls/xray-core/common/net" - "github.com/xtls/xray-core/common/session" - "github.com/xtls/xray-core/core" -) - -// ProbeXray dispatches one HTTP request through the draft's real DNS, routing -// and outbounds. It does not start listeners or startup-only integrations, and -// is not proof that extra inbounds or all destinations work. Like CheckRoute, -// the caller must isolate it from unmanaged instances in the same process. -func ProbeXray(xrayJSON, targetURL string, timeout int, inboundTag string) (int64, error) { - uri, err := url.ParseRequestURI(targetURL) - if err != nil || uri.Host == "" || uri.User != nil || - (uri.Scheme != "http" && uri.Scheme != "https") || timeout < 1 || timeout > 60 { - return 0, errors.New("configuration probe requires an HTTP(S) URL and a timeout of 1–60 seconds") - } - coreServerMu.Lock() - defer coreServerMu.Unlock() - if coreServer != nil { - return 0, errors.New("configuration probe requires an isolated process without a managed Xray instance") - } - ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) - defer cancel() - config, err := core.LoadConfig("json", strings.NewReader(xrayJSON)) - if err != nil { - return 0, errors.New("configuration probe could not build the configuration") - } - if _, _, err = prepareRouteCheck(config); err != nil { - return 0, err - } - server, err := core.NewWithContext(ctx, config) - if err != nil { - return 0, errors.New("configuration probe could not construct the Xray instance") - } - defer server.Close() - transport := &http.Transport{ - DisableKeepAlives: true, - DialContext: func(call context.Context, network, address string) (net.Conn, error) { - destination, err := xnet.ParseDestination("tcp:" + address) - if err != nil { - return nil, err - } - call = session.ContextWithInbound(call, &session.Inbound{Tag: inboundTag}) - return core.Dial(call, server, destination) - }, - } - defer transport.CloseIdleConnections() - delay, err := nodep.PingHTTPRequest(&http.Client{ - Transport: transport, - Timeout: time.Duration(timeout) * time.Second, - }, targetURL, timeout) - if err != nil { - // HTTP errors may include a credential-bearing URL. Keep them local. - return 0, errors.New("configuration probe URL request failed") - } - return delay, nil -} diff --git a/xray/probe_test.go b/xray/probe_test.go deleted file mode 100644 index 03d75233..00000000 --- a/xray/probe_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package xray - -import ( - "fmt" - "net/http" - "net/http/httptest" - "net/url" - "strings" - "testing" -) - -func TestProbeXrayUsesDraftDNSAndRoutingWithoutListening(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodHead { - t.Error("probe must use HEAD") - } - w.WriteHeader(http.StatusNoContent) - })) - defer server.Close() - u, _ := url.Parse(server.URL) - config := fmt.Sprintf(`{ - "inbounds":[{"listen":"127.0.0.1","port":%s,"protocol":"socks"}], - "dns":{"hosts":{"probe.test":"127.0.0.1"}}, - "outbounds":[{"tag":"blocked","protocol":"blackhole"},{"tag":"ok","protocol":"freedom","settings":{"domainStrategy":"UseIP"}}], - "routing":{"rules":[{"inboundTag":["tunIn"],"domain":["full:probe.test"],"outboundTag":"ok"}]} - }`, u.Port()) - // The configured listener port is already occupied. Only the routed request - // should run; accidentally starting the raw listeners would fail this test. - target := "http://probe.test:" + u.Port() + "/" - if delay, err := ProbeXray(config, target, 2, "tunIn"); err != nil || delay < 0 { - t.Fatalf("routed probe: delay=%d err=%v", delay, err) - } - if _, err := ProbeXray(config, target, 1, "other"); err == nil || !strings.HasPrefix(err.Error(), "configuration probe ") { - t.Fatalf("ignoring the draft routing: %v", err) - } - if GetXrayState() { - t.Fatal("probe published a managed instance") - } -} - -func TestProbeXrayRejectsUnsafeRequestWithoutLeakingURL(t *testing.T) { - for _, target := range []string{"file:///secret", "https://user:secret@example.com/"} { - _, err := ProbeXray(`{}`, target, 1, "") - if err == nil || !strings.HasPrefix(err.Error(), "configuration probe ") || strings.Contains(err.Error(), "secret") { - t.Fatalf("unsafe error: %v", err) - } - } -} diff --git a/xray/validation.go b/xray/validation.go index 53be1615..23c6ff4c 100644 --- a/xray/validation.go +++ b/xray/validation.go @@ -7,33 +7,15 @@ import ( "github.com/xtls/xray-core/core" ) -// ValidateXray only builds the configuration; it does not instantiate handlers. +// TestXray only builds the configuration; it does not instantiate handlers. // The core builder can read local assets/certificates and apply root env values. -func ValidateXray(xrayJSON string) error { - coreServerMu.Lock() - defer coreServerMu.Unlock() - if coreServer != nil { - return errors.New("validateXray requires an isolated process without a managed Xray instance") - } - _, err := core.LoadConfig("json", strings.NewReader(xrayJSON)) - return err -} - -// Test Xray Config. -// xrayJSON is the serialized Xray JSON configuration. +// Success does not guarantee that the configuration can start. 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") } - server, err := newXrayInstance(xrayJSON) - if err != nil { - return err - } - err = server.Close() - if err != nil { - return err - } - return nil + _, err := core.LoadConfig("json", strings.NewReader(xrayJSON)) + return err } diff --git a/xray/xray_test.go b/xray/xray_test.go index 47947b20..f766b029 100644 --- a/xray/xray_test.go +++ b/xray/xray_test.go @@ -23,8 +23,7 @@ func TestTemporaryOperationsRejectManagedOverlap(t *testing.T) { t.Setenv(key, "original") config := `{"env":{"` + key + `":"changed"},"outbounds":[{"protocol":"freedom"}]}` for name, operation := range map[string]func() error{ - "buildOnly": func() error { return ValidateXray(config) }, - "testXray": func() error { return TestXray(config) }, + "testXray": func() error { return TestXray(config) }, "pingBatch": func() error { _, err := PingBatch([]PingBatchItem{{XrayJSON: config}}, 10, "http://127.0.0.1:1/") return err From 4c1b9661cb466a1b9d888e93747a0f1be8bd01e5 Mon Sep 17 00:00:00 2001 From: yiguo Date: Sat, 5 Sep 2026 15:18:43 +0800 Subject: [PATCH 15/16] build: remove build input metadata --- .gitignore | 1 - AGENTS.md | 2 +- README.md | 18 +---- build/app/android.py | 5 +- build/app/apple_go.py | 5 +- build/app/apple_gomobile.py | 5 +- build/app/build.py | 101 ------------------------ build/app/linux.py | 5 +- build/app/windows.py | 5 +- build/test_build.py | 74 ++++++++++++++++++ build/test_build_metadata.py | 147 ----------------------------------- readme/README.zh_CN.md | 15 +--- 12 files changed, 86 insertions(+), 297 deletions(-) create mode 100644 build/test_build.py delete mode 100644 build/test_build_metadata.py diff --git a/.gitignore b/.gitignore index 53799c9f..872c7051 100644 --- a/.gitignore +++ b/.gitignore @@ -30,4 +30,3 @@ test/ config/ .DS_Store bin/ -/build/build-metadata-*.json diff --git a/AGENTS.md b/AGENTS.md index 02e0ed80..883d63a1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,7 +44,7 @@ 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 distinguish build metadata from a successful artifact. + 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: diff --git a/README.md b/README.md index ccda61b4..6f965e7c 100644 --- a/README.md +++ b/README.md @@ -66,21 +66,9 @@ python3 build/main.py windows local ``` -Before restoring `go.mod` and `go.sum`, each build attempt writes the ignored -`build/build-metadata-.json`, where builder is `android`, `apple-go`, -`apple-gomobile`, `linux`, or `windows`. It records the libXray commit and tracked -dirty state (including temporary module edits), Go version, effective -`go list -mod=readonly -m all` output, and SHA-256 hashes of the effective module -files. Gomobile builds resolve `latest` by default; the record includes that resolved -version and the actual PATH binary's module version and `go version -m` output. -Set `LIBXRAY_GOMOBILE_VERSION` to a Go module version to pin the resolution; `resolvedVersion` still records the resolved value. -Non-gomobile builds record `gomobile: null`. - -This is **build input evidence, not proof of a successful or matching artifact**: -failed builds also run this hook, and collection failures appear in `errors` or -as a warning without replacing the original build error. Consumers must check -the build command's success and the record's freshness; artifact verification -is separate. A missing or incomplete record must not be treated as verified input. +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: 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 6f63bd96..d516d3d8 100644 --- a/build/app/build.py +++ b/build/app/build.py @@ -1,10 +1,5 @@ -from datetime import datetime, timezone -import hashlib -import json import os.path -import shutil import subprocess -import sys from app.cmd import ( create_dir_if_not_exists, @@ -29,7 +24,6 @@ def __init__(self, build_dir: str, use_local_xray_core: bool = False): os.path.join(self.lib_dir, self.xray_core_replace_path) ) self._go_env_snapshot = None - self._gomobile_version = None def snapshot_go_env(self): paths = [ @@ -137,7 +131,6 @@ def prepare_gomobile(self): version = result.stdout.strip() if result.returncode != 0 or not version: raise Exception("resolve gomobile version failed") - self._gomobile_version = version ret = subprocess.run( [ @@ -199,97 +192,3 @@ def before_build(self): def build(self): pass - - def after_build(self): - # Called in finally, before restoring the effective module files. This - # records inputs even on failure; it never certifies an artifact. - try: - builder = { - "AndroidBuilder": "android", - "AppleGoBuilder": "apple-go", - "AppleGoMobileBuilder": "apple-gomobile", - "LinuxBuilder": "linux", - "WindowsBuilder": "windows", - }.get(type(self).__name__, type(self).__name__) - errors = [] - - def capture(label, operation): - try: - return operation() - except Exception as error: - errors.append(f"{label}: {error}") - return None - - def output(*command): - return subprocess.run( - command, - cwd=self.lib_dir, - check=True, - capture_output=True, - text=True, - timeout=60, - ).stdout.strip() - - def file_hash(name): - with open(os.path.join(self.lib_dir, name), "rb") as file: - return hashlib.sha256(file.read()).hexdigest() - - metadata = { - "schemaVersion": 1, - "evidence": "build-inputs-only", - "builder": builder, - "recordedAt": datetime.now(timezone.utc).isoformat(), - "goModSha256": capture("go.mod", lambda: file_hash("go.mod")), - "goSumSha256": capture("go.sum", lambda: file_hash("go.sum")), - "libXrayCommit": capture( - "git commit", lambda: output("git", "rev-parse", "HEAD") - ), - "goVersion": capture("Go version", lambda: output("go", "version")), - "modules": capture( - "Go modules", - lambda: output("go", "list", "-mod=readonly", "-m", "all"), - ), - "gomobile": None, - "errors": errors, - } - status = capture( - "git status", - lambda: output("git", "status", "--porcelain", "--untracked-files=no"), - ) - metadata["libXrayDirty"] = None if status is None else bool(status) - if self._gomobile_version is not None: - binary = shutil.which("gomobile") - build_info = None - used_version = None - if binary is None: - errors.append("gomobile binary: not found in PATH") - else: - build_info = capture( - "gomobile build info", - lambda: output("go", "version", "-m", binary), - ) - for line in (build_info or "").splitlines(): - fields = line.split() - if ( - fields[:2] == ["mod", "golang.org/x/mobile"] - and len(fields) >= 3 - ): - used_version = fields[2] - break - if build_info is not None and used_version is None: - errors.append("gomobile build info: module version missing") - metadata["gomobile"] = { - "resolvedVersion": self._gomobile_version, - "usedVersion": used_version, - "binary": binary, - "buildInfo": build_info, - } - path = os.path.join(self.build_dir, f"build-metadata-{builder}.json") - with open(path, "w", encoding="utf-8") as file: - json.dump(metadata, file, indent=2) - file.write("\n") - if errors: - print(f"Build input metadata is incomplete: {path}", file=sys.stderr) - except Exception as error: - # A metadata failure must not replace the original build exception. - print(f"Unable to record build input metadata: {error}", file=sys.stderr) 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/build/test_build_metadata.py b/build/test_build_metadata.py deleted file mode 100644 index 347e056e..00000000 --- a/build/test_build_metadata.py +++ /dev/null @@ -1,147 +0,0 @@ -"""Run: python3 build/test_build_metadata.py. No Go or platform build is run.""" -import hashlib -import io -import json -from pathlib import Path -import shutil -import subprocess -import unittest -from unittest.mock import patch -from uuid import uuid4 - -from app.android import AndroidBuilder -from app.build import Builder - - -class BuildMetadataTest(unittest.TestCase): - def setUp(self): - # Keep every test fixture in the permitted references tree, never /tmp. - self.root = ( - Path(__file__).resolve().parents[2] - / "references" - / "onexray-refactor-validation" - / "build-metadata" - / uuid4().hex - ) - (self.root / "build").mkdir(parents=True) - self.addCleanup(shutil.rmtree, self.root) - (self.root / "go.mod").write_text("original module\n") - (self.root / "go.sum").write_text("original sums\n") - self.builder = AndroidBuilder(str(self.root / "build")) - run_patch = patch("app.build.subprocess.run", side_effect=self.command) - self.run = run_patch.start() - self.addCleanup(run_patch.stop) - - def command(self, command, **kwargs): - command = list(command) - outputs = { - ("git", "rev-parse", "HEAD"): "abc123\n", - ("git", "status", "--porcelain", "--untracked-files=no"): " M go.mod\n", - ("go", "version"): "go version go1.26.6 darwin/arm64\n", - ("go", "list", "-mod=readonly", "-m", "all"): ( - "github.com/xtls/libxray\ngolang.org/x/mobile v0.0.0-resolved\n" - ), - ( - "go", "list", "-m", "-f", "{{.Version}}", - "golang.org/x/mobile@latest", - ): "v0.0.0-resolved\n", - ("go", "version", "-m", "/fixture/gomobile"): ( - "/fixture/gomobile: go1.26.6\n" - "\tmod\tgolang.org/x/mobile\tv0.0.0-actual\th1:fixture\n" - ), - } - return subprocess.CompletedProcess(command, 0, outputs.get(tuple(command), ""), "") - - def read(self, builder="android"): - path = self.root / "build" / f"build-metadata-{builder}.json" - return json.loads(path.read_text()) - - def test_effective_inputs_are_captured_before_restoration(self): - self.builder.snapshot_go_env() - (self.root / "go.mod").write_text("effective module\n") - (self.root / "go.sum").write_text("effective sums\n") - with patch.dict("app.build.os.environ", {"LIBXRAY_GOMOBILE_VERSION": ""}): - self.builder.prepare_gomobile() - with patch("app.build.shutil.which", return_value="/fixture/gomobile"): - self.builder.after_build() - self.builder.restore_go_env() - metadata = self.read() - self.assertEqual(metadata["evidence"], "build-inputs-only") - self.assertEqual(metadata["builder"], "android") - self.assertEqual(metadata["libXrayCommit"], "abc123") - self.assertTrue(metadata["libXrayDirty"]) - self.assertIn("go1.26.6", metadata["goVersion"]) - self.assertIn("golang.org/x/mobile v0.0.0-resolved", metadata["modules"]) - self.assertEqual( - metadata["goModSha256"], hashlib.sha256(b"effective module\n").hexdigest() - ) - self.assertEqual( - metadata["goSumSha256"], hashlib.sha256(b"effective sums\n").hexdigest() - ) - self.assertEqual(metadata["gomobile"]["resolvedVersion"], "v0.0.0-resolved") - self.assertEqual(metadata["gomobile"]["usedVersion"], "v0.0.0-actual") - self.assertEqual(metadata["errors"], []) - self.assertEqual((self.root / "go.mod").read_text(), "original module\n") - self.assertEqual((self.root / "go.sum").read_text(), "original sums\n") - for call in self.run.call_args_list: - if "check" in call.kwargs: - self.assertEqual(call.kwargs["cwd"], str(self.root)) - - def test_non_gomobile_build_has_its_own_name(self): - builder = type("AppleGoBuilder", (Builder,), {})(str(self.root / "build")) - builder.after_build() - metadata = self.read("apple-go") - self.assertIsNone(metadata["gomobile"]) - self.assertEqual(metadata["errors"], []) - - def test_gomobile_version_environment_selects_resolution_query(self): - version = "v0.0.0-20260821190718-4776eadac327" - with ( - patch.dict("app.build.os.environ", {"LIBXRAY_GOMOBILE_VERSION": version}), - patch( - "app.build.subprocess.run", - return_value=subprocess.CompletedProcess([], 0, version + "\n", ""), - ) as run, - ): - self.builder.prepare_gomobile() - self.assertEqual(run.call_args_list[0].args[0], [ - "go", "list", "-m", "-f", "{{.Version}}", - f"golang.org/x/mobile@{version}", - ]) - self.assertEqual(self.builder._gomobile_version, version) - - def test_collection_failure_keeps_original_build_error(self): - with ( - patch.object( - self.builder, "before_build", - side_effect=RuntimeError("original build failed"), - ), - patch("app.build.subprocess.run", side_effect=OSError("tool unavailable")), - patch("sys.stderr", new_callable=io.StringIO), - ): - with self.assertRaisesRegex(RuntimeError, "original build failed"): - self.builder.build() - metadata = self.read() - self.assertIsNone(metadata["libXrayCommit"]) - self.assertIsNone(metadata["modules"]) - self.assertTrue(metadata["errors"]) - self.assertIsNone(self.builder._go_env_snapshot) - - def test_write_failure_keeps_original_build_error(self): - blocked = self.root / "not-a-directory" - blocked.write_text("fixture") - self.builder.build_dir = str(blocked) - with ( - patch.object( - self.builder, "before_build", - side_effect=RuntimeError("original build failed"), - ), - patch("sys.stderr", new_callable=io.StringIO), - ): - with self.assertRaisesRegex(RuntimeError, "original build failed"): - self.builder.build() - self.assertIsNone(self.builder._go_env_snapshot) - - -if __name__ == "__main__": - unittest.main() diff --git a/readme/README.zh_CN.md b/readme/README.zh_CN.md index d7f0dce0..41d3f310 100644 --- a/readme/README.zh_CN.md +++ b/readme/README.zh_CN.md @@ -34,18 +34,9 @@ python3 build/main.py windows python3 build/main.py windows local ``` -每次构建尝试都会在恢复 `go.mod` 和 `go.sum` 前写入已忽略的 -`build/build-metadata-.json`;builder 为 `android`、`apple-go`、 -`apple-gomobile`、`linux` 或 `windows`。记录包含 libXray commit、受跟踪文件的 -dirty 状态(包括临时模块修改)、Go 版本、实际生效的 -`go list -mod=readonly -m all` 输出,以及生效模块文件的 SHA-256。 -gomobile 仍默认解析 `latest`,同时记录解析版本、实际 PATH 中二进制的模块版本和 -`go version -m` 输出;不使用 gomobile 的构建记录 `gomobile: null`。 -设置环境变量 `LIBXRAY_GOMOBILE_VERSION` 可指定 Go 模块版本,`resolvedVersion` 仍记录实际解析结果。 - -这些记录是**构建输入证据,不是构建成功或产物匹配的证明**。失败构建也会执行记录; -采集失败写入 `errors` 或输出警告,不会覆盖原始构建错误。使用方必须独立确认构建 -命令成功、记录属于本次构建,并另行验证产物;记录缺失或不完整不能视为输入已验证。 +构建成功或失败后都会恢复 `go.mod` 和 `go.sum`。gomobile 默认解析 `latest`, +也可通过环境变量 `LIBXRAY_GOMOBILE_VERSION` 指定 Go 模块版本;`gomobile` 与 +`gobind` 使用同一个解析版本。 Linux 和 Windows 构建还会生成 `bin/xray` 或 `bin/xray.exe`。该会话 Core 会保护 Go DNS 查询不被 VPN 路由重新捕获,并且只接受以下命令: From df52e2ce66e63d7097b62d4139f1759a559a776d Mon Sep 17 00:00:00 2001 From: yiguo Date: Sat, 5 Sep 2026 15:35:30 +0800 Subject: [PATCH 16/16] refactor: simplify share parsing and runtime accounting --- share/clash_meta.go | 39 ++++++-------------------------------- share/convert_share.go | 9 ++++----- share/marshal_share.go | 7 ------- share/parse_share.go | 35 ++++++++-------------------------- share/parse_share_test.go | 3 +-- share/validate_outbound.go | 7 ------- xray/runtime.go | 22 ++++----------------- xray/runtime_http.go | 2 +- 8 files changed, 24 insertions(+), 100 deletions(-) diff --git a/share/clash_meta.go b/share/clash_meta.go index 16746d0c..d746d060 100644 --- a/share/clash_meta.go +++ b/share/clash_meta.go @@ -125,44 +125,17 @@ type ClashProxyXhttpOptsDownloadSettings struct { 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/convert_share.go b/share/convert_share.go index 8cbb83d7..74c19b73 100644 --- a/share/convert_share.go +++ b/share/convert_share.go @@ -72,25 +72,24 @@ func parseShareCandidates(links string, allowBase64 bool) (*conf.Config, int, er } if hasShareSchemeLine(text) { candidates := 0 - forEachLine(text, func(raw string) bool { + 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#") { - return true + continue } candidates++ parsed, err := url.Parse(line) if err != nil { - return true + continue } outbound, err := (xrayShareLink{link: parsed, rawText: line}).outbound() if err == nil { config.OutboundConfigs = append(config.OutboundConfigs, *outbound) } - return true - }) + } return config, candidates, nil } if allowBase64 { diff --git a/share/marshal_share.go b/share/marshal_share.go index 1a1cbff7..835f425f 100644 --- a/share/marshal_share.go +++ b/share/marshal_share.go @@ -16,7 +16,6 @@ func marshalShareConfigJSON(config *conf.Config) (json.RawMessage, int, error) { } outbounds := make([]map[string]any, 0, len(config.OutboundConfigs)) - var firstBuildError error for _, outbound := range config.OutboundConfigs { source, err := marshalShareJSONObject(outbound) if err != nil { @@ -27,18 +26,12 @@ func marshalShareConfigJSON(config *conf.Config) (json.RawMessage, int, error) { 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, 0, fmt.Errorf("no valid outbound found: %w", firstBuildError) - } return nil, 0, fmt.Errorf("no valid outbound found") } diff --git a/share/parse_share.go b/share/parse_share.go index c59da5cf..0b2bac8d 100644 --- a/share/parse_share.go +++ b/share/parse_share.go @@ -48,48 +48,29 @@ var shareSchemes = []string{ } 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 + return true } - text = rest } + return false } type xrayShareLink struct { diff --git a/share/parse_share_test.go b/share/parse_share_test.go index a14e9bc2..89f3dd5c 100644 --- a/share/parse_share_test.go +++ b/share/parse_share_test.go @@ -221,8 +221,7 @@ func TestConvertShareLinksToXrayJson_AllBuildInvalidOutbounds(t *testing.T) { ) 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) { diff --git a/share/validate_outbound.go b/share/validate_outbound.go index fdfb9ba5..d0fa295f 100644 --- a/share/validate_outbound.go +++ b/share/validate_outbound.go @@ -22,20 +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 { 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/xray/runtime.go b/xray/runtime.go index c3f24935..24483d6b 100644 --- a/xray/runtime.go +++ b/xray/runtime.go @@ -67,21 +67,14 @@ func prepareRuntime(config *RuntimeConfig) (*managedRuntime, error) { if err := validateRuntimeHTTP(config); err != nil { return nil, err } - stateLock, err := lockRuntimeState(config.StatePath) - if err != nil { + var id [16]byte + if _, err := rand.Read(id[:]); err != nil { return nil, err } - prepared := false - defer func() { - if !prepared { - _ = stateLock.Close() - } - }() - var id [16]byte - if _, err = rand.Read(id[:]); err != nil { + stateLock, err := lockRuntimeState(config.StatePath) + if err != nil { return nil, err } - prepared = true return &managedRuntime{ config: *config, stateLock: stateLock, snapshot: runtimeSnapshot{ @@ -181,10 +174,6 @@ func (r *managedRuntime) sample() { r.snapshot.Session.Uplink, r.snapshot.Session.Downlink = u, d } } - r.snapshot.Error = "" - if !r.snapshot.Available { - r.snapshot.Error = "counters_unavailable" - } } func (r *managedRuntime) save() error { @@ -226,9 +215,6 @@ func (r *managedRuntime) stop() error { func readRuntimeState(path string) (runtimeSnapshot, error) { var state runtimeSnapshot info, err := os.Lstat(path) - if errors.Is(err, os.ErrNotExist) { - return state, nil - } if err != nil || !info.Mode().IsRegular() || info.Size() > 64*1024 { return state, errors.New("runtime state is not a readable regular file") } diff --git a/xray/runtime_http.go b/xray/runtime_http.go index d5d106d8..86d9a9d8 100644 --- a/xray/runtime_http.go +++ b/xray/runtime_http.go @@ -72,7 +72,7 @@ func (r *managedRuntime) handleHTTP(w http.ResponseWriter, request *http.Request return } snapshot, err := readRuntimeState(r.config.StatePath) - if err != nil || snapshot.Version == 0 { + if err != nil { http.Error(w, "runtime snapshot unavailable", http.StatusServiceUnavailable) return }