Welcome to mirror list, hosted at ThFree Co, Russian Federation.

github.com/MHSanaei/3x-ui.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorpwnnex <pwnnex@proton.me>2026-04-21 21:30:02 +0300
committerGitHub <noreply@github.com>2026-04-21 21:30:02 +0300
commit15be803da982ab362f6859c0ec91a582c4b1fea6 (patch)
treea4e716380ede864b97ef98b6dccb05926ffec32f
parentc79b45e512bd41d24f668a373fdabb922c437877 (diff)
Fix blank Xray Settings page from wrapped xrayTemplateConfig (#4059) (#4069)
`getXraySetting` builds its response as { "xraySetting": <db value>, "inboundTags": ..., "outboundTestUrl": ... } and embeds the raw DB value as the `xraySetting` field without checking whether the stored value already has that exact shape. The frontend pulls the textarea content from `result.xraySetting` and saves it back verbatim. If the DB ever ends up holding the response-shaped wrapper instead of a real xray config (older installs where this happened at least once, users who imported a copy-pasted response into the textarea, a botched migration, etc.), the next save nests another layer, the one after that nests a third, and the Vue-side JSON.parse of the resulting blob silently fails — the Xray Settings page goes blank. Fix both ends of the round-trip: * Add `service.UnwrapXrayTemplateConfig`. It peels off any number of `xraySetting`-keyed layers, leaving a real xray config behind. The check is conservative: if the outer object already contains any top-level xray key (`inbounds`, `outbounds`, `routing`, `api`, `dns`, `log`, `policy`, `stats`), it is returned unchanged, and there is a depth cap to avoid pathological inputs. * `SaveXraySetting` unwraps before validation so a round-tripped wrapper from an already-corrupted page can no longer re-poison the DB on save. * `getXraySetting` unwraps on read and, when it finds a wrapper, rewrites the DB with the corrected value. Existing broken installs heal themselves on the next visit to the page. Includes unit tests for the passthrough, single-wrap, multi-wrap, string-encoded-inner, and false-positive cases. Co-authored-by: pwnnex <eternxles@gmail.com>
-rw-r--r--web/controller/xray_setting.go17
-rw-r--r--web/service/xray_setting.go54
-rw-r--r--web/service/xray_setting_test.go90
3 files changed, 161 insertions, 0 deletions
diff --git a/web/controller/xray_setting.go b/web/controller/xray_setting.go
index 0c382fb9..7e4c7966 100644
--- a/web/controller/xray_setting.go
+++ b/web/controller/xray_setting.go
@@ -49,6 +49,23 @@ func (a *XraySettingController) getXraySetting(c *gin.Context) {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.getSettings"), err)
return
}
+ // Older versions of this handler embedded the raw DB value as
+ // `xraySetting` in the response without checking if the value
+ // already had that wrapper shape. When the frontend saved it
+ // back through the textarea verbatim, the wrapper got persisted
+ // and every subsequent save nested another layer, which is what
+ // eventually produced the blank Xray Settings page in #4059.
+ // Strip any such wrapper here, and heal the DB if we found one so
+ // the next read is O(1) instead of climbing the same pile again.
+ if unwrapped := service.UnwrapXrayTemplateConfig(xraySetting); unwrapped != xraySetting {
+ if saveErr := a.XraySettingService.SaveXraySetting(unwrapped); saveErr == nil {
+ xraySetting = unwrapped
+ } else {
+ // Don't fail the read — just serve the unwrapped value
+ // and leave the DB healing for a later save.
+ xraySetting = unwrapped
+ }
+ }
inboundTags, err := a.InboundService.GetInboundTags()
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.getSettings"), err)
diff --git a/web/service/xray_setting.go b/web/service/xray_setting.go
index 5df8a211..4c3892e4 100644
--- a/web/service/xray_setting.go
+++ b/web/service/xray_setting.go
@@ -15,6 +15,12 @@ type XraySettingService struct {
}
func (s *XraySettingService) SaveXraySetting(newXraySettings string) error {
+ // The frontend round-trips the whole getXraySetting response back
+ // through the textarea, so if it has ever received a wrapped
+ // payload (see UnwrapXrayTemplateConfig) it sends that same wrapper
+ // back here. Strip it before validation/storage, otherwise we save
+ // garbage the next read can't recover from without this same call.
+ newXraySettings = UnwrapXrayTemplateConfig(newXraySettings)
if err := s.CheckXrayConfig(newXraySettings); err != nil {
return err
}
@@ -29,3 +35,51 @@ func (s *XraySettingService) CheckXrayConfig(XrayTemplateConfig string) error {
}
return nil
}
+
+// UnwrapXrayTemplateConfig returns the raw xray config JSON from `raw`,
+// peeling off any number of `{ "inboundTags": ..., "outboundTestUrl": ...,
+// "xraySetting": <real config> }` response-shaped wrappers that may have
+// ended up in the database.
+//
+// How it got there: getXraySetting used to embed the raw DB value as
+// `xraySetting` in its response without checking whether the stored
+// value was already that exact response shape. If the frontend then
+// saved it verbatim (the textarea is a round-trip of the JSON it was
+// handed), the wrapper got persisted — and each subsequent save nested
+// another layer, producing the blank Xray Settings page reported in
+// issue #4059.
+//
+// If `raw` does not look like a wrapper, it is returned unchanged.
+func UnwrapXrayTemplateConfig(raw string) string {
+ const maxDepth = 8 // defensive cap against pathological multi-nest values
+ for i := 0; i < maxDepth; i++ {
+ var top map[string]json.RawMessage
+ if err := json.Unmarshal([]byte(raw), &top); err != nil {
+ return raw
+ }
+ inner, ok := top["xraySetting"]
+ if !ok {
+ return raw
+ }
+ // Real xray configs never contain a top-level "xraySetting" key,
+ // but they do contain things like "inbounds"/"outbounds"/"api".
+ // If any of those are present, we're already at the real config
+ // and the "xraySetting" field is either user data or coincidence
+ // — don't touch it.
+ for _, k := range []string{"inbounds", "outbounds", "routing", "api", "dns", "log", "policy", "stats"} {
+ if _, hit := top[k]; hit {
+ return raw
+ }
+ }
+ // Peel off one layer.
+ unwrapped := string(inner)
+ // `xraySetting` may be stored either as a JSON object or as a
+ // JSON-encoded string of an object. Handle both.
+ var asStr string
+ if err := json.Unmarshal(inner, &asStr); err == nil {
+ unwrapped = asStr
+ }
+ raw = unwrapped
+ }
+ return raw
+}
diff --git a/web/service/xray_setting_test.go b/web/service/xray_setting_test.go
new file mode 100644
index 00000000..2c165576
--- /dev/null
+++ b/web/service/xray_setting_test.go
@@ -0,0 +1,90 @@
+package service
+
+import (
+ "encoding/json"
+ "strings"
+ "testing"
+)
+
+func TestUnwrapXrayTemplateConfig(t *testing.T) {
+ real := `{"log":{},"inbounds":[],"outbounds":[],"routing":{}}`
+
+ t.Run("passes through a clean config", func(t *testing.T) {
+ if got := UnwrapXrayTemplateConfig(real); got != real {
+ t.Fatalf("clean config was modified: %s", got)
+ }
+ })
+
+ t.Run("passes through invalid JSON unchanged", func(t *testing.T) {
+ in := "not json at all"
+ if got := UnwrapXrayTemplateConfig(in); got != in {
+ t.Fatalf("invalid input was modified: %s", got)
+ }
+ })
+
+ t.Run("unwraps one layer of response-shaped wrapper", func(t *testing.T) {
+ wrapper := `{"inboundTags":["tag"],"outboundTestUrl":"x","xraySetting":` + real + `}`
+ got := UnwrapXrayTemplateConfig(wrapper)
+ if !equalJSON(t, got, real) {
+ t.Fatalf("want %s, got %s", real, got)
+ }
+ })
+
+ t.Run("unwraps multiple stacked layers", func(t *testing.T) {
+ lvl1 := `{"xraySetting":` + real + `}`
+ lvl2 := `{"xraySetting":` + lvl1 + `}`
+ lvl3 := `{"xraySetting":` + lvl2 + `}`
+ got := UnwrapXrayTemplateConfig(lvl3)
+ if !equalJSON(t, got, real) {
+ t.Fatalf("want %s, got %s", real, got)
+ }
+ })
+
+ t.Run("handles an xraySetting stored as a JSON-encoded string", func(t *testing.T) {
+ encoded, _ := json.Marshal(real) // becomes a quoted string
+ wrapper := `{"xraySetting":` + string(encoded) + `}`
+ got := UnwrapXrayTemplateConfig(wrapper)
+ if !equalJSON(t, got, real) {
+ t.Fatalf("want %s, got %s", real, got)
+ }
+ })
+
+ t.Run("does not unwrap when top level already has real xray keys", func(t *testing.T) {
+ // Pathological but defensible: if a user's actual config somehow
+ // has both the real keys and an unrelated `xraySetting` key, we
+ // must not strip it.
+ in := `{"inbounds":[],"xraySetting":{"some":"thing"}}`
+ got := UnwrapXrayTemplateConfig(in)
+ if got != in {
+ t.Fatalf("should have left real config alone, got %s", got)
+ }
+ })
+
+ t.Run("stops at a reasonable depth", func(t *testing.T) {
+ // Build a deeper-than-maxDepth chain that ends at something
+ // non-wrapped, and confirm we end up at some valid JSON (we
+ // don't loop forever and we don't blow the stack).
+ s := real
+ for i := 0; i < 16; i++ {
+ s = `{"xraySetting":` + s + `}`
+ }
+ got := UnwrapXrayTemplateConfig(s)
+ if !strings.Contains(got, `"inbounds"`) && !strings.Contains(got, `"xraySetting"`) {
+ t.Fatalf("unexpected tail: %s", got)
+ }
+ })
+}
+
+func equalJSON(t *testing.T, a, b string) bool {
+ t.Helper()
+ var va, vb any
+ if err := json.Unmarshal([]byte(a), &va); err != nil {
+ return false
+ }
+ if err := json.Unmarshal([]byte(b), &vb); err != nil {
+ return false
+ }
+ ja, _ := json.Marshal(va)
+ jb, _ := json.Marshal(vb)
+ return string(ja) == string(jb)
+}