Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,9 @@ Design notes:
4. The complete UTF-8 encoded Invoke request and response JSON envelopes are
limited to 16 MiB. If either limit is exceeded, Invoke returns a failure
response with `success: false`, `data: null`, and a size-limit error.
5. `convertShareLinksToXrayJson` validates each parsed outbound with the current
Xray-core config builder. Invalid outbounds are omitted, and the method fails
if none remain. Validation does not create or start an Xray instance.

Supported methods:

Expand Down
42 changes: 42 additions & 0 deletions invoke_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (

"github.com/xtls/libxray/nodep"
"github.com/xtls/xray-core/common/geodata"
"github.com/xtls/xray-core/infra/conf"
"google.golang.org/protobuf/proto"
)

Expand Down Expand Up @@ -307,6 +308,47 @@ func TestInvokeMapResponseShape(t *testing.T) {
}
}

func TestInvokeConvertShareLinksFiltersBuildInvalidOutbounds(t *testing.T) {
const validName = "Valid"
links := "vless://2418d087-648k-4990-86e8-19dca1d006d3@invalid.example:443?encryption=none&security=tls&sni=invalid.example&fp=chrome\n" +
"vless://12345678-abcd-abcd-abcd-123456789abc@valid.example:443?encryption=none&security=tls&sni=valid.example&fp=chrome#" + validName

response := invokeForTest(
t,
LibXrayMethodConvertShareLinksToXrayJson,
ConvertShareLinksToXrayJsonRequest{Text: links},
)
if !response.Success {
t.Fatalf("ConvertShareLinksToXrayJson failed: %s", response.Err)
}
config := decodeDataObject[conf.Config](t, response)
if len(config.OutboundConfigs) != 1 {
t.Fatalf("outbounds = %d, want 1", len(config.OutboundConfigs))
}
if config.OutboundConfigs[0].SendThrough == nil || *config.OutboundConfigs[0].SendThrough != validName {
t.Fatalf("sendThrough = %v, want %q", config.OutboundConfigs[0].SendThrough, validName)
}
}

func TestInvokeConvertShareLinksFailsWhenAllOutboundsAreBuildInvalid(t *testing.T) {
response := invokeForTest(
t,
LibXrayMethodConvertShareLinksToXrayJson,
ConvertShareLinksToXrayJsonRequest{
Text: "vless://2418d087-648k-4990-86e8-19dca1d006d3@invalid.example:443?encryption=none&security=tls&sni=invalid.example&fp=chrome",
},
)
if response.Success {
t.Fatal("ConvertShareLinksToXrayJson should fail when all outbounds are invalid")
}
if !strings.Contains(response.Err, "no valid outbound found") {
t.Fatalf("error = %q", response.Err)
}
if got := string(response.Data); got != "null" {
t.Fatalf("data = %s, want null", got)
}
}

func TestInvokePingReturnsDelaySentinelOnXrayError(t *testing.T) {
response := invokeForTest(
t,
Expand Down
1 change: 1 addition & 0 deletions readme/README.zh_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ void CGoFree(char* value);
2. `SetTunFd` 已删除。如果 fd 只能在运行时获得,请在调用 `runXray` 前把 `xray.tun.fd` 写入 Xray 配置根 `env` 对象。
3. `countGeoData` 不依赖 Xray 配置,因此通过 method payload 的 `datDir` 传入数据目录。
4. 完整的 UTF-8 编码 Invoke 请求和响应 JSON 包体限制为 16 MiB。任一方向超过限制时,Invoke 将返回 `success: false`、`data: null` 和对应的大小限制错误。
5. `convertShareLinksToXrayJson` 会使用当前 Xray-core 配置构建器校验每个已解析的 outbound。无效 outbound 会被忽略;如果没有剩余的有效 outbound,该方法返回失败。校验不会创建或启动 Xray instance。

支持的 method:

Expand Down
6 changes: 5 additions & 1 deletion share/parse_share.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,11 @@ func decodeBase64Text(text string) (string, error) {
// - one base64 blob that decodes to Xray JSON, share lines, or Clash YAML
// - Clash / Clash.Meta YAML (proxies:)
func ConvertShareLinksToXrayJson(links string) (*conf.Config, error) {
return convertShareLinksToXrayJson(links, true)
config, err := convertShareLinksToXrayJson(links, true)
if err != nil {
return nil, err
}
return filterBuildableOutbounds(config)
}

func convertShareLinksToXrayJson(links string, allowBase64 bool) (*conf.Config, error) {
Expand Down
58 changes: 37 additions & 21 deletions share/parse_share_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,28 @@ func TestConvertShareLinksToXrayJson_XrayJSONNoOutbounds(t *testing.T) {
assert.Contains(t, err.Error(), "outbound")
}

func TestConvertShareLinksToXrayJson_FiltersBuildInvalidOutbounds(t *testing.T) {
links := "vless://2418d087-648k-4990-86e8-19dca1d006d3@invalid-id.example:443?encryption=none&security=tls&sni=invalid-id.example&fp=chrome\n" +
"vless://" + testShareUUID + "@invalid-reality.example:443?encryption=none&security=reality&sni=invalid-reality.example&pbk=invalid&fp=chrome\n" +
"vless://" + testShareUUID + "@valid.example:443?encryption=none&security=tls&sni=valid.example&fp=chrome#Valid"

config, err := ConvertShareLinksToXrayJson(links)
require.NoError(t, err)
require.Len(t, config.OutboundConfigs, 1)
require.NotNil(t, config.OutboundConfigs[0].SendThrough)
assert.Equal(t, "Valid", *config.OutboundConfigs[0].SendThrough)
}

func TestConvertShareLinksToXrayJson_AllBuildInvalidOutbounds(t *testing.T) {
_, err := ConvertShareLinksToXrayJson(
"vless://2418d087-648k-4990-86e8-19dca1d006d3@invalid.example:443?encryption=none&security=tls&sni=invalid.example&fp=chrome",
)

require.Error(t, err)
assert.Contains(t, err.Error(), "no valid outbound found")
assert.Contains(t, err.Error(), "invalid byte")
}

func TestConvertShareLinksToXrayJson_Base64EncodedLines(t *testing.T) {
lines := "trojan://secret@trojan.example.com:443?sni=trojan.example.com\n" +
"ss://" + ssUserB64("aes-128-gcm", "pwd") + "@ss.example.com:8388#ssn"
Expand All @@ -197,7 +219,7 @@ func TestConvertShareLinksToXrayJson_Base64EncodedLines(t *testing.T) {
}

func TestConvertShareLinksToXrayJson_Base64URLSafeBlob(t *testing.T) {
inner := "vless://" + testShareUUID + "@v.example.com:443?encryption=none&security=none"
inner := "vless://" + testShareUUID + "@10.0.0.1:443?encryption=none&security=none"
b := base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString([]byte(inner))
cfg, err := ConvertShareLinksToXrayJson(b)
require.NoError(t, err)
Expand All @@ -220,7 +242,7 @@ func TestConvertShareLinksToXrayJson_Shadowsocks(t *testing.T) {
}

func TestConvertShareLinksToXrayJson_VlessWSAndTLS(t *testing.T) {
link := "vless://" + testShareUUID + "@edge.example:443?encryption=none&type=ws&path=%2Fws&host=cdn.edge&security=tls&sni=edge.example&alpn=h2%2Ch3&fp=chrome&insecure=1"
link := "vless://" + testShareUUID + "@edge.example:443?encryption=none&type=ws&path=%2Fws&host=cdn.edge&security=tls&sni=edge.example&alpn=h2%2Ch3&fp=chrome&vcn=edge.example"
cfg, err := ConvertShareLinksToXrayJson(link)
require.NoError(t, err)
require.Len(t, cfg.OutboundConfigs, 1)
Expand All @@ -233,15 +255,15 @@ func TestConvertShareLinksToXrayJson_VlessWSAndTLS(t *testing.T) {
require.NotNil(t, ss.TLSSettings)
assert.Equal(t, "edge.example", ss.TLSSettings.ServerName)
assert.Equal(t, "chrome", ss.TLSSettings.Fingerprint)
assert.True(t, ss.TLSSettings.AllowInsecure)
assert.Equal(t, "edge.example", ss.TLSSettings.VerifyPeerCertByName)
require.NotNil(t, ss.TLSSettings.ALPN)
assert.Contains(t, []string(*ss.TLSSettings.ALPN), "h2")
}

func TestConvertShareLinksToXrayJson_VlessReality(t *testing.T) {
pbk := "ZXYAbCdEfGhIjKlMnOpQrStUvWxYz0123456789ABCD"
pbk := "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
link := "vless://" + testShareUUID + "@reality.example:443?encryption=none&security=reality&type=tcp&sni=reality.example&pbk=" +
pbk + "&sid=abcd&fp=qq&pqv=pqv1&spx=%2F"
pbk + "&sid=abcd&fp=chrome&spx=%2F"
cfg, err := ConvertShareLinksToXrayJson(link)
require.NoError(t, err)
ss := cfg.OutboundConfigs[0].StreamSetting
Expand All @@ -250,8 +272,7 @@ func TestConvertShareLinksToXrayJson_VlessReality(t *testing.T) {
require.NotNil(t, ss.REALITYSettings)
assert.Equal(t, pbk, ss.REALITYSettings.PublicKey)
assert.Equal(t, "abcd", ss.REALITYSettings.ShortId)
assert.Equal(t, "qq", ss.REALITYSettings.Fingerprint)
assert.Equal(t, "pqv1", ss.REALITYSettings.Mldsa65Verify)
assert.Equal(t, "chrome", ss.REALITYSettings.Fingerprint)
assert.Equal(t, "/", ss.REALITYSettings.SpiderX)
}

Expand Down Expand Up @@ -313,12 +334,9 @@ func TestConvertShareLinksToXrayJson_VmessBase64QR(t *testing.T) {
func TestConvertShareLinksToXrayJson_TransportKcpGrpcHttpUpgradeXhttp(t *testing.T) {
t.Run("kcp", func(t *testing.T) {
link := "vless://" + testShareUUID + "@k.example:443?encryption=none&type=kcp&headerType=srtp&seed=myseed"
cfg, err := ConvertShareLinksToXrayJson(link)
require.NoError(t, err)
ss := cfg.OutboundConfigs[0].StreamSetting
require.NotNil(t, ss.KCPSettings)
require.NotNil(t, ss.KCPSettings.Seed)
assert.Equal(t, "myseed", *ss.KCPSettings.Seed)
_, err := ConvertShareLinksToXrayJson(link)
require.Error(t, err)
assert.Contains(t, err.Error(), "mkcp header & seed has been removed")
})

t.Run("grpc", func(t *testing.T) {
Expand Down Expand Up @@ -350,20 +368,21 @@ func TestConvertShareLinksToXrayJson_TransportKcpGrpcHttpUpgradeXhttp(t *testing
require.NoError(t, err)
x := cfg.OutboundConfigs[0].StreamSetting.XHTTPSettings
require.NotNil(t, x)
assert.Nil(t, cfg.OutboundConfigs[0].StreamSetting.SplitHTTPSettings)
assert.Equal(t, "stream-up", x.Mode)
require.NotNil(t, x.Extra)
})
}

func TestConvertShareLinksToXrayJson_FinalMaskQuery(t *testing.T) {
fm := `{"udp":[{"type":"test-mask"}]}`
fm := `{"udp":[{"type":"noise","settings":{}}]}`
link := "vless://" + testShareUUID + "@fm.example:443?encryption=none&type=tcp&fm=" + url.QueryEscape(fm)
cfg, err := ConvertShareLinksToXrayJson(link)
require.NoError(t, err)
ss := cfg.OutboundConfigs[0].StreamSetting
require.NotNil(t, ss.FinalMask)
require.Len(t, ss.FinalMask.Udp, 1)
assert.Equal(t, "test-mask", ss.FinalMask.Udp[0].Type)
assert.Equal(t, "noise", ss.FinalMask.Udp[0].Type)
}

func TestConvertShareLinksToXrayJson_Hysteria2InvalidHop(t *testing.T) {
Expand Down Expand Up @@ -477,12 +496,9 @@ func TestConvertShareLinksToXrayJson_VmessQRGrpcAndKcp(t *testing.T) {
t.Run("kcp", func(t *testing.T) {
qr := `{"ps":"k","add":"kcp.host","port":"8391","id":"` + testShareUUID + `","net":"kcp","path":"seedval","type":"wireguard"}`
link := "vmess://" + base64.StdEncoding.EncodeToString([]byte(qr))
cfg, err := ConvertShareLinksToXrayJson(link)
require.NoError(t, err)
ks := cfg.OutboundConfigs[0].StreamSetting.KCPSettings
require.NotNil(t, ks)
require.NotNil(t, ks.Seed)
assert.Equal(t, "seedval", *ks.Seed)
_, err := ConvertShareLinksToXrayJson(link)
require.Error(t, err)
assert.Contains(t, err.Error(), "mkcp header & seed has been removed")
})
}

Expand Down
86 changes: 86 additions & 0 deletions share/validate_outbound.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package share

import (
"bytes"
"encoding/json"
"fmt"
"reflect"

"github.com/xtls/xray-core/infra/conf"
)

func filterBuildableOutbounds(config *conf.Config) (*conf.Config, error) {
raw, err := json.Marshal(config.OutboundConfigs)
if err != nil {
return nil, fmt.Errorf("failed to copy outbounds for validation: %w", err)
}

var validationOutbounds []conf.OutboundDetourConfig
if err := json.Unmarshal(raw, &validationOutbounds); err != nil {
return nil, fmt.Errorf("failed to copy outbounds for validation: %w", err)
}
restoreNilRawMessages(reflect.ValueOf(&validationOutbounds))

validOutbounds := make([]conf.OutboundDetourConfig, 0, len(config.OutboundConfigs))
var firstBuildError error
for index := range validationOutbounds {
// Share conversion stores the display name in sendThrough because Xray
// has no outbound name field. It is metadata here, not a bind address.
validationOutbounds[index].SendThrough = nil
if _, err := validationOutbounds[index].Build(); err != nil {
if firstBuildError == nil {
firstBuildError = err
}
continue
}
validOutbounds = append(validOutbounds, config.OutboundConfigs[index])
}
if len(validOutbounds) == 0 {
if firstBuildError != nil {
return nil, fmt.Errorf("no valid outbound found: %w", firstBuildError)
}
return nil, fmt.Errorf("no valid outbound found")
}

config.OutboundConfigs = validOutbounds
return config, nil
}

var rawMessageType = reflect.TypeOf(json.RawMessage{})

func restoreNilRawMessages(value reflect.Value) {
if !value.IsValid() {
return
}
if value.Kind() == reflect.Pointer || value.Kind() == reflect.Interface {
if !value.IsNil() {
restoreNilRawMessages(value.Elem())
}
return
}
if value.Type() == rawMessageType {
if value.CanSet() && bytes.Equal(bytes.TrimSpace(value.Bytes()), []byte("null")) {
value.SetZero()
}
return
}

switch value.Kind() {
case reflect.Struct:
for index := range value.NumField() {
restoreNilRawMessages(value.Field(index))
}
case reflect.Slice, reflect.Array:
for index := range value.Len() {
restoreNilRawMessages(value.Index(index))
}
case reflect.Map:
iterator := value.MapRange()
for iterator.Next() {
item := reflect.New(iterator.Value().Type()).Elem()
item.Set(iterator.Value())
restoreNilRawMessages(item)
value.SetMapIndex(iterator.Key(), item)
}
}
}