From de97ebf516c1a45c65d8a9add01d4876634ba943 Mon Sep 17 00:00:00 2001 From: yiguo Date: Thu, 23 Jul 2026 18:34:47 +0800 Subject: [PATCH] Validate imported outbounds with Xray-core --- README.md | 3 ++ invoke_test.go | 42 +++++++++++++++++++ readme/README.zh_CN.md | 1 + share/parse_share.go | 6 ++- share/parse_share_test.go | 58 +++++++++++++++---------- share/validate_outbound.go | 86 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 174 insertions(+), 22 deletions(-) create mode 100644 share/validate_outbound.go diff --git a/README.md b/README.md index f4427494..35cd72d9 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,9 @@ Design notes: 4. The complete UTF-8 encoded Invoke request and response JSON envelopes are limited to 16 MiB. If either limit is exceeded, Invoke returns a failure response with `success: false`, `data: null`, and a size-limit error. +5. `convertShareLinksToXrayJson` validates each parsed outbound with the current + Xray-core config builder. Invalid outbounds are omitted, and the method fails + if none remain. Validation does not create or start an Xray instance. Supported methods: diff --git a/invoke_test.go b/invoke_test.go index 74329339..1c94f0fc 100644 --- a/invoke_test.go +++ b/invoke_test.go @@ -10,6 +10,7 @@ import ( "github.com/xtls/libxray/nodep" "github.com/xtls/xray-core/common/geodata" + "github.com/xtls/xray-core/infra/conf" "google.golang.org/protobuf/proto" ) @@ -307,6 +308,47 @@ func TestInvokeMapResponseShape(t *testing.T) { } } +func TestInvokeConvertShareLinksFiltersBuildInvalidOutbounds(t *testing.T) { + const validName = "Valid" + links := "vless://2418d087-648k-4990-86e8-19dca1d006d3@invalid.example:443?encryption=none&security=tls&sni=invalid.example&fp=chrome\n" + + "vless://12345678-abcd-abcd-abcd-123456789abc@valid.example:443?encryption=none&security=tls&sni=valid.example&fp=chrome#" + validName + + response := invokeForTest( + t, + LibXrayMethodConvertShareLinksToXrayJson, + ConvertShareLinksToXrayJsonRequest{Text: links}, + ) + if !response.Success { + t.Fatalf("ConvertShareLinksToXrayJson failed: %s", response.Err) + } + config := decodeDataObject[conf.Config](t, response) + if len(config.OutboundConfigs) != 1 { + t.Fatalf("outbounds = %d, want 1", len(config.OutboundConfigs)) + } + if config.OutboundConfigs[0].SendThrough == nil || *config.OutboundConfigs[0].SendThrough != validName { + t.Fatalf("sendThrough = %v, want %q", config.OutboundConfigs[0].SendThrough, validName) + } +} + +func TestInvokeConvertShareLinksFailsWhenAllOutboundsAreBuildInvalid(t *testing.T) { + response := invokeForTest( + t, + LibXrayMethodConvertShareLinksToXrayJson, + ConvertShareLinksToXrayJsonRequest{ + Text: "vless://2418d087-648k-4990-86e8-19dca1d006d3@invalid.example:443?encryption=none&security=tls&sni=invalid.example&fp=chrome", + }, + ) + if response.Success { + t.Fatal("ConvertShareLinksToXrayJson should fail when all outbounds are invalid") + } + if !strings.Contains(response.Err, "no valid outbound found") { + t.Fatalf("error = %q", response.Err) + } + if got := string(response.Data); got != "null" { + t.Fatalf("data = %s, want null", got) + } +} + func TestInvokePingReturnsDelaySentinelOnXrayError(t *testing.T) { response := invokeForTest( t, diff --git a/readme/README.zh_CN.md b/readme/README.zh_CN.md index 263ee5d9..bf826b45 100644 --- a/readme/README.zh_CN.md +++ b/readme/README.zh_CN.md @@ -120,6 +120,7 @@ void CGoFree(char* value); 2. `SetTunFd` 已删除。如果 fd 只能在运行时获得,请在调用 `runXray` 前把 `xray.tun.fd` 写入 Xray 配置根 `env` 对象。 3. `countGeoData` 不依赖 Xray 配置,因此通过 method payload 的 `datDir` 传入数据目录。 4. 完整的 UTF-8 编码 Invoke 请求和响应 JSON 包体限制为 16 MiB。任一方向超过限制时,Invoke 将返回 `success: false`、`data: null` 和对应的大小限制错误。 +5. `convertShareLinksToXrayJson` 会使用当前 Xray-core 配置构建器校验每个已解析的 outbound。无效 outbound 会被忽略;如果没有剩余的有效 outbound,该方法返回失败。校验不会创建或启动 Xray instance。 支持的 method: diff --git a/share/parse_share.go b/share/parse_share.go index 55436a01..f57e33b9 100644 --- a/share/parse_share.go +++ b/share/parse_share.go @@ -49,7 +49,11 @@ func decodeBase64Text(text string) (string, error) { // - one base64 blob that decodes to Xray JSON, share lines, or Clash YAML // - Clash / Clash.Meta YAML (proxies:) func ConvertShareLinksToXrayJson(links string) (*conf.Config, error) { - return convertShareLinksToXrayJson(links, true) + config, err := convertShareLinksToXrayJson(links, true) + if err != nil { + return nil, err + } + return filterBuildableOutbounds(config) } func convertShareLinksToXrayJson(links string, allowBase64 bool) (*conf.Config, error) { diff --git a/share/parse_share_test.go b/share/parse_share_test.go index 748b0210..c2984d88 100644 --- a/share/parse_share_test.go +++ b/share/parse_share_test.go @@ -184,6 +184,28 @@ func TestConvertShareLinksToXrayJson_XrayJSONNoOutbounds(t *testing.T) { assert.Contains(t, err.Error(), "outbound") } +func TestConvertShareLinksToXrayJson_FiltersBuildInvalidOutbounds(t *testing.T) { + links := "vless://2418d087-648k-4990-86e8-19dca1d006d3@invalid-id.example:443?encryption=none&security=tls&sni=invalid-id.example&fp=chrome\n" + + "vless://" + testShareUUID + "@invalid-reality.example:443?encryption=none&security=reality&sni=invalid-reality.example&pbk=invalid&fp=chrome\n" + + "vless://" + testShareUUID + "@valid.example:443?encryption=none&security=tls&sni=valid.example&fp=chrome#Valid" + + config, err := ConvertShareLinksToXrayJson(links) + require.NoError(t, err) + require.Len(t, config.OutboundConfigs, 1) + require.NotNil(t, config.OutboundConfigs[0].SendThrough) + assert.Equal(t, "Valid", *config.OutboundConfigs[0].SendThrough) +} + +func TestConvertShareLinksToXrayJson_AllBuildInvalidOutbounds(t *testing.T) { + _, err := ConvertShareLinksToXrayJson( + "vless://2418d087-648k-4990-86e8-19dca1d006d3@invalid.example:443?encryption=none&security=tls&sni=invalid.example&fp=chrome", + ) + + require.Error(t, err) + assert.Contains(t, err.Error(), "no valid outbound found") + assert.Contains(t, err.Error(), "invalid byte") +} + func TestConvertShareLinksToXrayJson_Base64EncodedLines(t *testing.T) { lines := "trojan://secret@trojan.example.com:443?sni=trojan.example.com\n" + "ss://" + ssUserB64("aes-128-gcm", "pwd") + "@ss.example.com:8388#ssn" @@ -197,7 +219,7 @@ func TestConvertShareLinksToXrayJson_Base64EncodedLines(t *testing.T) { } func TestConvertShareLinksToXrayJson_Base64URLSafeBlob(t *testing.T) { - inner := "vless://" + testShareUUID + "@v.example.com:443?encryption=none&security=none" + inner := "vless://" + testShareUUID + "@10.0.0.1:443?encryption=none&security=none" b := base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString([]byte(inner)) cfg, err := ConvertShareLinksToXrayJson(b) require.NoError(t, err) @@ -220,7 +242,7 @@ func TestConvertShareLinksToXrayJson_Shadowsocks(t *testing.T) { } func TestConvertShareLinksToXrayJson_VlessWSAndTLS(t *testing.T) { - link := "vless://" + testShareUUID + "@edge.example:443?encryption=none&type=ws&path=%2Fws&host=cdn.edge&security=tls&sni=edge.example&alpn=h2%2Ch3&fp=chrome&insecure=1" + link := "vless://" + testShareUUID + "@edge.example:443?encryption=none&type=ws&path=%2Fws&host=cdn.edge&security=tls&sni=edge.example&alpn=h2%2Ch3&fp=chrome&vcn=edge.example" cfg, err := ConvertShareLinksToXrayJson(link) require.NoError(t, err) require.Len(t, cfg.OutboundConfigs, 1) @@ -233,15 +255,15 @@ func TestConvertShareLinksToXrayJson_VlessWSAndTLS(t *testing.T) { require.NotNil(t, ss.TLSSettings) assert.Equal(t, "edge.example", ss.TLSSettings.ServerName) assert.Equal(t, "chrome", ss.TLSSettings.Fingerprint) - assert.True(t, ss.TLSSettings.AllowInsecure) + assert.Equal(t, "edge.example", ss.TLSSettings.VerifyPeerCertByName) require.NotNil(t, ss.TLSSettings.ALPN) assert.Contains(t, []string(*ss.TLSSettings.ALPN), "h2") } func TestConvertShareLinksToXrayJson_VlessReality(t *testing.T) { - pbk := "ZXYAbCdEfGhIjKlMnOpQrStUvWxYz0123456789ABCD" + pbk := "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" link := "vless://" + testShareUUID + "@reality.example:443?encryption=none&security=reality&type=tcp&sni=reality.example&pbk=" + - pbk + "&sid=abcd&fp=qq&pqv=pqv1&spx=%2F" + pbk + "&sid=abcd&fp=chrome&spx=%2F" cfg, err := ConvertShareLinksToXrayJson(link) require.NoError(t, err) ss := cfg.OutboundConfigs[0].StreamSetting @@ -250,8 +272,7 @@ func TestConvertShareLinksToXrayJson_VlessReality(t *testing.T) { require.NotNil(t, ss.REALITYSettings) assert.Equal(t, pbk, ss.REALITYSettings.PublicKey) assert.Equal(t, "abcd", ss.REALITYSettings.ShortId) - assert.Equal(t, "qq", ss.REALITYSettings.Fingerprint) - assert.Equal(t, "pqv1", ss.REALITYSettings.Mldsa65Verify) + assert.Equal(t, "chrome", ss.REALITYSettings.Fingerprint) assert.Equal(t, "/", ss.REALITYSettings.SpiderX) } @@ -313,12 +334,9 @@ func TestConvertShareLinksToXrayJson_VmessBase64QR(t *testing.T) { func TestConvertShareLinksToXrayJson_TransportKcpGrpcHttpUpgradeXhttp(t *testing.T) { t.Run("kcp", func(t *testing.T) { link := "vless://" + testShareUUID + "@k.example:443?encryption=none&type=kcp&headerType=srtp&seed=myseed" - cfg, err := ConvertShareLinksToXrayJson(link) - require.NoError(t, err) - ss := cfg.OutboundConfigs[0].StreamSetting - require.NotNil(t, ss.KCPSettings) - require.NotNil(t, ss.KCPSettings.Seed) - assert.Equal(t, "myseed", *ss.KCPSettings.Seed) + _, err := ConvertShareLinksToXrayJson(link) + require.Error(t, err) + assert.Contains(t, err.Error(), "mkcp header & seed has been removed") }) t.Run("grpc", func(t *testing.T) { @@ -350,20 +368,21 @@ func TestConvertShareLinksToXrayJson_TransportKcpGrpcHttpUpgradeXhttp(t *testing require.NoError(t, err) x := cfg.OutboundConfigs[0].StreamSetting.XHTTPSettings require.NotNil(t, x) + assert.Nil(t, cfg.OutboundConfigs[0].StreamSetting.SplitHTTPSettings) assert.Equal(t, "stream-up", x.Mode) require.NotNil(t, x.Extra) }) } func TestConvertShareLinksToXrayJson_FinalMaskQuery(t *testing.T) { - fm := `{"udp":[{"type":"test-mask"}]}` + fm := `{"udp":[{"type":"noise","settings":{}}]}` link := "vless://" + testShareUUID + "@fm.example:443?encryption=none&type=tcp&fm=" + url.QueryEscape(fm) cfg, err := ConvertShareLinksToXrayJson(link) require.NoError(t, err) ss := cfg.OutboundConfigs[0].StreamSetting require.NotNil(t, ss.FinalMask) require.Len(t, ss.FinalMask.Udp, 1) - assert.Equal(t, "test-mask", ss.FinalMask.Udp[0].Type) + assert.Equal(t, "noise", ss.FinalMask.Udp[0].Type) } func TestConvertShareLinksToXrayJson_Hysteria2InvalidHop(t *testing.T) { @@ -477,12 +496,9 @@ func TestConvertShareLinksToXrayJson_VmessQRGrpcAndKcp(t *testing.T) { t.Run("kcp", func(t *testing.T) { qr := `{"ps":"k","add":"kcp.host","port":"8391","id":"` + testShareUUID + `","net":"kcp","path":"seedval","type":"wireguard"}` link := "vmess://" + base64.StdEncoding.EncodeToString([]byte(qr)) - cfg, err := ConvertShareLinksToXrayJson(link) - require.NoError(t, err) - ks := cfg.OutboundConfigs[0].StreamSetting.KCPSettings - require.NotNil(t, ks) - require.NotNil(t, ks.Seed) - assert.Equal(t, "seedval", *ks.Seed) + _, err := ConvertShareLinksToXrayJson(link) + require.Error(t, err) + assert.Contains(t, err.Error(), "mkcp header & seed has been removed") }) } diff --git a/share/validate_outbound.go b/share/validate_outbound.go new file mode 100644 index 00000000..4f89d292 --- /dev/null +++ b/share/validate_outbound.go @@ -0,0 +1,86 @@ +package share + +import ( + "bytes" + "encoding/json" + "fmt" + "reflect" + + "github.com/xtls/xray-core/infra/conf" +) + +func filterBuildableOutbounds(config *conf.Config) (*conf.Config, error) { + raw, err := json.Marshal(config.OutboundConfigs) + if err != nil { + return nil, fmt.Errorf("failed to copy outbounds for validation: %w", err) + } + + var validationOutbounds []conf.OutboundDetourConfig + if err := json.Unmarshal(raw, &validationOutbounds); err != nil { + return nil, fmt.Errorf("failed to copy outbounds for validation: %w", err) + } + restoreNilRawMessages(reflect.ValueOf(&validationOutbounds)) + + validOutbounds := make([]conf.OutboundDetourConfig, 0, len(config.OutboundConfigs)) + var firstBuildError error + for index := range validationOutbounds { + // Share conversion stores the display name in sendThrough because Xray + // has no outbound name field. It is metadata here, not a bind address. + validationOutbounds[index].SendThrough = nil + if _, err := validationOutbounds[index].Build(); err != nil { + if firstBuildError == nil { + firstBuildError = err + } + continue + } + validOutbounds = append(validOutbounds, config.OutboundConfigs[index]) + } + if len(validOutbounds) == 0 { + if firstBuildError != nil { + return nil, fmt.Errorf("no valid outbound found: %w", firstBuildError) + } + return nil, fmt.Errorf("no valid outbound found") + } + + config.OutboundConfigs = validOutbounds + return config, nil +} + +var rawMessageType = reflect.TypeOf(json.RawMessage{}) + +func restoreNilRawMessages(value reflect.Value) { + if !value.IsValid() { + return + } + if value.Kind() == reflect.Pointer || value.Kind() == reflect.Interface { + if !value.IsNil() { + restoreNilRawMessages(value.Elem()) + } + return + } + if value.Type() == rawMessageType { + if value.CanSet() && bytes.Equal(bytes.TrimSpace(value.Bytes()), []byte("null")) { + value.SetZero() + } + return + } + + switch value.Kind() { + case reflect.Struct: + for index := range value.NumField() { + restoreNilRawMessages(value.Field(index)) + } + case reflect.Slice, reflect.Array: + for index := range value.Len() { + restoreNilRawMessages(value.Index(index)) + } + case reflect.Map: + iterator := value.MapRange() + for iterator.Next() { + item := reflect.New(iterator.Value().Type()).Elem() + item.Set(iterator.Value()) + restoreNilRawMessages(item) + value.SetMapIndex(iterator.Key(), item) + } + } +}