xray-setting: strip legacy reverse block on save

xray-core v26 dropped the original reverse-proxy feature and replaced
it with VLESS Reverse Proxy (XTLS/Xray-core#5101). After upgrading
xray, any panel template that still has the old
`reverse: { portals: [...], bridges: [...] }` block 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 shape so a lot of in-the-wild templates carry it.

strip the legacy block silently on save and log a warning. the rest of
the config stays valid, xray comes back up, and the issue is
self-healing on the next save.

the replacement (VLESS Reverse Proxy) is a per-client field on a VLESS
inbound, not a top-level block, so dropping the legacy block does not
collide with the new mechanism.

also adds a deprecation banner on the reverse-proxy panel tab so admins
stop generating new broken configs from the UI.

closes #4115.
This commit is contained in:
pwnnex 2026-04-28 14:23:32 +03:00
parent 15be803da9
commit 6a293b4b2b
3 changed files with 180 additions and 0 deletions

View file

@ -1,4 +1,8 @@
{{define "settings/xray/reverse"}} {{define "settings/xray/reverse"}}
<a-alert type="warning" show-icon :style="{ marginBottom: '12px' }"
message="Legacy reverse proxy is deprecated"
description="Xray-core v26+ removed the top-level `reverse` config (portals/bridges). Saving a config that still contains it will silently strip the block so the daemon can start. The replacement is VLESS Reverse Proxy, configured per-client on a VLESS inbound — see XTLS/Xray-core#5101.">
</a-alert>
<template v-if="reverseData.length > 0"> <template v-if="reverseData.length > 0">
<a-space direction="vertical" size="middle"> <a-space direction="vertical" size="middle">
<a-button type="primary" icon="plus" @click="addReverse()"> <a-button type="primary" icon="plus" @click="addReverse()">

View file

@ -4,6 +4,7 @@ import (
_ "embed" _ "embed"
"encoding/json" "encoding/json"
"github.com/mhsanaei/3x-ui/v2/logger"
"github.com/mhsanaei/3x-ui/v2/util/common" "github.com/mhsanaei/3x-ui/v2/util/common"
"github.com/mhsanaei/3x-ui/v2/xray" "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 // back here. Strip it before validation/storage, otherwise we save
// garbage the next read can't recover from without this same call. // garbage the next read can't recover from without this same call.
newXraySettings = UnwrapXrayTemplateConfig(newXraySettings) 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 { if err := s.CheckXrayConfig(newXraySettings); err != nil {
return err return err
} }
@ -36,6 +41,58 @@ func (s *XraySettingService) CheckXrayConfig(XrayTemplateConfig string) error {
return nil 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`, // UnwrapXrayTemplateConfig returns the raw xray config JSON from `raw`,
// peeling off any number of `{ "inboundTags": ..., "outboundTestUrl": ..., // peeling off any number of `{ "inboundTags": ..., "outboundTestUrl": ...,
// "xraySetting": <real config> }` response-shaped wrappers that may have // "xraySetting": <real config> }` response-shaped wrappers that may have

View file

@ -88,3 +88,122 @@ func equalJSON(t *testing.T, a, b string) bool {
jb, _ := json.Marshal(vb) jb, _ := json.Marshal(vb)
return string(ja) == string(jb) 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)
}
}