diff --git a/web/html/settings/xray/reverse.html b/web/html/settings/xray/reverse.html
index c15b4a8c..204ed3e8 100644
--- a/web/html/settings/xray/reverse.html
+++ b/web/html/settings/xray/reverse.html
@@ -1,4 +1,8 @@
{{define "settings/xray/reverse"}}
+
+
diff --git a/web/service/xray_setting.go b/web/service/xray_setting.go
index 4c3892e4..f584a133 100644
--- a/web/service/xray_setting.go
+++ b/web/service/xray_setting.go
@@ -4,6 +4,7 @@ import (
_ "embed"
"encoding/json"
+ "github.com/mhsanaei/3x-ui/v2/logger"
"github.com/mhsanaei/3x-ui/v2/util/common"
"github.com/mhsanaei/3x-ui/v2/xray"
)
@@ -21,6 +22,10 @@ func (s *XraySettingService) SaveXraySetting(newXraySettings string) error {
// 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 stripped, removed, err := StripLegacyReverse(newXraySettings); err == nil && removed {
+ newXraySettings = stripped
+ logger.Warning("[xray-setting] removed legacy `reverse` block from saved template (xray-core v26+ no longer supports it; see XTLS/Xray-core#5101 for VLESS Reverse Proxy)")
+ }
if err := s.CheckXrayConfig(newXraySettings); err != nil {
return err
}
@@ -36,6 +41,58 @@ func (s *XraySettingService) CheckXrayConfig(XrayTemplateConfig string) error {
return nil
}
+// StripLegacyReverse removes the top-level `reverse` block from a
+// saved xray template if it carries the deprecated `portals` / `bridges`
+// shape that xray-core v26+ refuses to parse.
+//
+// Why (#4115): xray-core dropped the original reverse-proxy feature in
+// favour of "VLESS Reverse Proxy" (XTLS/Xray-core#5101). After upgrading
+// xray, any panel template that still contains a `reverse: { portals
+// [...], bridges: [...] }` section makes the daemon fail to start with
+// `The feature "legacy reverse" has been removed and migrated to "VLESS
+// Reverse Proxy"`. The panel UI used to write configs in that exact old
+// shape, so a lot of in-the-wild templates carry it.
+//
+// Stripping it here keeps the rest of the config valid, lets xray come
+// back up, and makes the issue self-healing on the next save. The new
+// VLESS Reverse Proxy is a per-client field on a VLESS inbound (and a
+// per-outbound field on the bridge side), not a top-level block, so
+// dropping the legacy block does not collide with the new mechanism.
+//
+// Returns the (possibly modified) JSON, a flag indicating whether
+// anything was actually removed, and a parse error if the input is not
+// valid JSON. The caller decides whether to log/notify on `removed`.
+func StripLegacyReverse(raw string) (out string, removed bool, err error) {
+ var cfg map[string]json.RawMessage
+ if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
+ return raw, false, err
+ }
+ rev, ok := cfg["reverse"]
+ if !ok || len(rev) == 0 {
+ return raw, false, nil
+ }
+ var revObj map[string]json.RawMessage
+ if err := json.Unmarshal(rev, &revObj); err != nil {
+ // Not an object (could be `null`, an array, etc). Leave alone.
+ return raw, false, nil
+ }
+ // The legacy shape is identified by the `portals` or `bridges`
+ // fields. Anything else under `reverse` (currently nothing valid in
+ // xray-core, but be conservative) we leave alone for the user to
+ // inspect.
+ _, hasPortals := revObj["portals"]
+ _, hasBridges := revObj["bridges"]
+ if !hasPortals && !hasBridges {
+ return raw, false, nil
+ }
+ delete(cfg, "reverse")
+ rebuilt, marshalErr := json.Marshal(cfg)
+ if marshalErr != nil {
+ return raw, false, marshalErr
+ }
+ return string(rebuilt), true, nil
+}
+
// UnwrapXrayTemplateConfig returns the raw xray config JSON from `raw`,
// peeling off any number of `{ "inboundTags": ..., "outboundTestUrl": ...,
// "xraySetting": }` response-shaped wrappers that may have
diff --git a/web/service/xray_setting_test.go b/web/service/xray_setting_test.go
index 2c165576..22ef3e02 100644
--- a/web/service/xray_setting_test.go
+++ b/web/service/xray_setting_test.go
@@ -88,3 +88,122 @@ func equalJSON(t *testing.T, a, b string) bool {
jb, _ := json.Marshal(vb)
return string(ja) == string(jb)
}
+
+func TestStripLegacyReverse_RemovesPortalsAndBridges(t *testing.T) {
+ // #4115: this is the exact shape the panel UI used to write and
+ // xray-core v26+ now refuses to parse.
+ in := `{
+ "inbounds":[],
+ "reverse":{
+ "portals":[{"tag":"Portal1","domain":"reverse.xui1"}],
+ "bridges":[{"tag":"Bridge1","domain":"reverse.xui1"}]
+ }
+ }`
+ out, removed, err := StripLegacyReverse(in)
+ if err != nil {
+ t.Fatalf("unexpected err: %v", err)
+ }
+ if !removed {
+ t.Fatalf("removed flag should be true when legacy reverse block is present")
+ }
+ var cfg map[string]any
+ if err := json.Unmarshal([]byte(out), &cfg); err != nil {
+ t.Fatalf("output is not valid json: %v\n%s", err, out)
+ }
+ if _, still := cfg["reverse"]; still {
+ t.Fatalf("reverse block should have been removed, got: %s", out)
+ }
+ if _, ok := cfg["inbounds"]; !ok {
+ t.Fatalf("unrelated fields should be preserved, got: %s", out)
+ }
+}
+
+func TestStripLegacyReverse_NoOpWhenNoReverseBlock(t *testing.T) {
+ // Don't touch configs that never had legacy reverse in the first
+ // place. Saving stays a no-op so the diff in the panel UI stays
+ // quiet.
+ in := `{"inbounds":[],"outbounds":[],"routing":{}}`
+ out, removed, err := StripLegacyReverse(in)
+ if err != nil {
+ t.Fatalf("unexpected err: %v", err)
+ }
+ if removed {
+ t.Fatalf("removed flag should be false when there's no reverse block")
+ }
+ if out != in {
+ t.Fatalf("expected unchanged input, got: %s", out)
+ }
+}
+
+func TestStripLegacyReverse_LeavesNonLegacyReverseAlone(t *testing.T) {
+ // The new VLESS Reverse Proxy lives as a `reverse` field on a VLESS
+ // client (inside inbound.settings.clients[].reverse), NOT at the
+ // top level. But just in case some future xray version puts
+ // something else under top-level `reverse` that's not the legacy
+ // shape, leave it alone if neither `portals` nor `bridges` are
+ // present.
+ in := `{"reverse":{"someFutureField":42}}`
+ out, removed, err := StripLegacyReverse(in)
+ if err != nil {
+ t.Fatalf("unexpected err: %v", err)
+ }
+ if removed {
+ t.Fatalf("removed flag should be false when reverse has no portals/bridges")
+ }
+ if out != in {
+ t.Fatalf("expected unchanged input, got: %s", out)
+ }
+}
+
+func TestStripLegacyReverse_DoesNotTouchNestedReverseFields(t *testing.T) {
+ // VLESS Reverse Proxy puts a `reverse` field inside an inbound
+ // client. Make sure we only target the TOP-LEVEL key, not anything
+ // nested.
+ in := `{"inbounds":[{"settings":{"clients":[{"id":"abc","reverse":{"tag":"r-out"}}]}}]}`
+ out, removed, err := StripLegacyReverse(in)
+ if err != nil {
+ t.Fatalf("unexpected err: %v", err)
+ }
+ if removed {
+ t.Fatalf("nested client.reverse must not be touched, removed should be false")
+ }
+ if out != in {
+ t.Fatalf("nested client.reverse must not be touched\nin: %s\nout: %s", in, out)
+ }
+}
+
+func TestStripLegacyReverse_OnlyPortals(t *testing.T) {
+ // Some configs have only `portals` (or only `bridges`). Either
+ // alone is enough to trigger removal.
+ in := `{"reverse":{"portals":[{"tag":"P","domain":"r.xui"}]}}`
+ out, removed, err := StripLegacyReverse(in)
+ if err != nil {
+ t.Fatalf("unexpected err: %v", err)
+ }
+ if !removed {
+ t.Fatalf("portals-only reverse should still be removed")
+ }
+ var cfg map[string]any
+ json.Unmarshal([]byte(out), &cfg)
+ if _, still := cfg["reverse"]; still {
+ t.Fatalf("reverse should be gone, got: %s", out)
+ }
+}
+
+func TestStripLegacyReverse_InvalidJsonReturnsError(t *testing.T) {
+ // SaveXraySetting calls CheckXrayConfig after this helper, but we
+ // want the helper itself to be defensive — return raw input plus
+ // an error if the JSON is unparseable, so the caller can decide
+ // whether to skip or block.
+ in := "not json"
+ out, removed, err := StripLegacyReverse(in)
+ if err == nil {
+ t.Fatalf("expected error for invalid json, got none")
+ }
+ if removed {
+ t.Fatalf("nothing should be removed on parse error")
+ }
+ if out != in {
+ t.Fatalf("expected raw passthrough on error, got %q", out)
+ }
+}