diff --git a/frontend/src/pages/index/OverviewActionBar.tsx b/frontend/src/pages/index/OverviewActionBar.tsx
index dfdb0cc8e..7600dc4ae 100644
--- a/frontend/src/pages/index/OverviewActionBar.tsx
+++ b/frontend/src/pages/index/OverviewActionBar.tsx
@@ -159,7 +159,7 @@ export default function OverviewActionBar({
return (
- {status.xray.state === 'error' && status.xray.errorMsg ? (
+ {status.xray.errorMsg ? (
{status.xray.errorMsg}}>
{statePill}
@@ -167,6 +167,12 @@ export default function OverviewActionBar({
statePill
)}
+ {status.xray.state === 'running' && status.xray.errorMsg ? (
+ {status.xray.errorMsg}}>
+ {t('pages.index.xrayStatusError')}
+
+ ) : null}
+
{updateAvailable ? (
0 {
+ refused := fmt.Sprintf("config refused: %s", conflicts[0])
+ for _, conflict := range conflicts {
+ logger.Error("xray config refused:", conflict.String())
+ }
+ // The refusal is otherwise invisible: the operator's request
+ // succeeded, so the status page has to carry the stale state.
+ xrayState.holdBack(refused)
+ return fmt.Errorf("xray %s", refused)
+ }
if !isForce && !configUnchanged && s.tryHotApply(process, xrayConfig) {
logger.Info("Xray config changes applied through the core API, no restart needed")
return nil
}
_ = process.Stop()
+ } else if conflicts := bindConflicts(xrayConfig, nil); len(conflicts) > 0 {
+ // Nothing is running to protect and the core is the authority on what it
+ // can bind: start it and let its own error name the port it lost.
+ logger.Warning("xray config may not start:", conflicts[0].String())
}
process = xray.NewProcess(xrayConfig)
diff --git a/internal/web/service/xray_bind_conflict.go b/internal/web/service/xray_bind_conflict.go
new file mode 100644
index 000000000..66688ce7e
--- /dev/null
+++ b/internal/web/service/xray_bind_conflict.go
@@ -0,0 +1,147 @@
+package service
+
+import (
+ "cmp"
+ "encoding/json"
+ "fmt"
+ "slices"
+
+ "github.com/mhsanaei/3x-ui/v3/internal/database/model"
+ "github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
+ "github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+// bindConflict names two generated inbounds whose listens cannot coexist.
+type bindConflict struct {
+ tagA string
+ tagB string
+ listen string
+ listenB string
+ port int
+ shared transportBits
+}
+
+func (c bindConflict) String() string {
+ return fmt.Sprintf("inbounds %q and %q both bind %s:%d (%s)",
+ c.tagA, c.tagB, displayListen(c.listen), c.port, transportTagSuffix(c.shared))
+}
+
+// bindConflicts reports inbounds of newCfg whose sockets collide. A collision the
+// running config already serves is excused: it is demonstration, not a guess.
+func bindConflicts(newCfg, runningCfg *xray.Config) []bindConflict {
+ conflicts := rawBindConflicts(newCfg)
+ if len(conflicts) == 0 {
+ return nil
+ }
+ excused := runningBindPairs(runningCfg)
+ if len(excused) == 0 {
+ return conflicts
+ }
+ kept := make([]bindConflict, 0, len(conflicts))
+ for _, c := range conflicts {
+ if _, ok := excused[bindPairKey(c)]; !ok {
+ kept = append(kept, c)
+ }
+ }
+ return kept
+}
+
+// rawBindConflicts groups inbounds by port first: only a port two inbounds share
+// is worth parsing transports for, which keeps the probe free on clean configs.
+func rawBindConflicts(cfg *xray.Config) []bindConflict {
+ if cfg == nil {
+ return nil
+ }
+ byPort := make(map[int][]*xray.InboundConfig, len(cfg.InboundConfigs))
+ for i := range cfg.InboundConfigs {
+ ib := &cfg.InboundConfigs[i]
+ if ib.Port > 0 {
+ byPort[ib.Port] = append(byPort[ib.Port], ib)
+ }
+ }
+
+ var conflicts []bindConflict
+ for port, group := range byPort {
+ for i := range group {
+ for j := i + 1; j < len(group); j++ {
+ left, right := group[i], group[j]
+ listenLeft, listenRight := configListen(left.Listen), configListen(right.Listen)
+ if !listenOverlaps(listenLeft, listenRight) {
+ continue
+ }
+ // One port carrying tcp on one inbound and udp on another is a
+ // supported deployment (vless/tcp + hysteria2/udp), never a clash.
+ shared := configTransports(left) & configTransports(right)
+ if shared == 0 {
+ continue
+ }
+ conflicts = append(conflicts, bindConflict{
+ tagA: left.Tag,
+ tagB: right.Tag,
+ listen: listenLeft,
+ listenB: listenRight,
+ port: port,
+ shared: shared,
+ })
+ }
+ }
+ }
+ // Port grouping iterates a map: order the report so the first conflict the
+ // caller shows is the same on every restart.
+ slices.SortFunc(conflicts, func(a, b bindConflict) int {
+ return cmp.Or(cmp.Compare(a.port, b.port), cmp.Compare(a.tagA, b.tagA), cmp.Compare(a.tagB, b.tagB))
+ })
+ return conflicts
+}
+
+// runningBindPairs is what the core is demonstrably binding right now, keyed the
+// way a new config's conflicts are, so only the identical one is excused.
+func runningBindPairs(cfg *xray.Config) map[string]struct{} {
+ conflicts := rawBindConflicts(cfg)
+ if len(conflicts) == 0 {
+ return nil
+ }
+ pairs := make(map[string]struct{}, len(conflicts))
+ for _, c := range conflicts {
+ pairs[bindPairKey(c)] = struct{}{}
+ }
+ return pairs
+}
+
+// bindPairKey is the socket set an excuse was granted for: the two listens, the
+// port and the shared transports, never the tags, which the generator reorders.
+func bindPairKey(c bindConflict) string {
+ left, right := c.listen, c.listenB
+ if left > right {
+ left, right = right, left
+ }
+ return fmt.Sprintf("%d\x00%s\x00%s\x00%d", c.port, left, right, c.shared)
+}
+
+func displayListen(listen string) string {
+ if isAnyListen(listen) {
+ return "*"
+ }
+ return listen
+}
+
+// configListen decodes a generated inbound's listen field: absent or empty means
+// every address, which is what the core does with an empty listen too.
+func configListen(raw json_util.RawMessage) string {
+ var listen string
+ if len(raw) == 0 || json.Unmarshal(raw, &listen) != nil {
+ return ""
+ }
+ return listen
+}
+
+// configTransports reads a generated inbound's transports through the same rule
+// the save-time guards use, so the two cannot drift apart.
+func configTransports(ib *xray.InboundConfig) transportBits {
+ // "socks" is the panel's own bridge shape (injectAmneziawgnetSocks and
+ // friends): its udp flag lives in settings like a mixed inbound's.
+ if ib.Protocol == "socks" {
+ return inboundTransports(model.Mixed, string(ib.StreamSettings), string(ib.Settings))
+ }
+ return inboundTransports(model.Protocol(ib.Protocol), string(ib.StreamSettings), string(ib.Settings))
+}
diff --git a/internal/web/service/xray_bind_conflict_test.go b/internal/web/service/xray_bind_conflict_test.go
new file mode 100644
index 000000000..791d8a73c
--- /dev/null
+++ b/internal/web/service/xray_bind_conflict_test.go
@@ -0,0 +1,237 @@
+package service
+
+import (
+ "encoding/json"
+ "strconv"
+ "strings"
+ "testing"
+
+ "github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
+ "github.com/mhsanaei/3x-ui/v3/internal/database"
+ "github.com/mhsanaei/3x-ui/v3/internal/database/model"
+ "github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+// configFromInbounds builds the config the way the panel does, from raw JSON:
+// the probe is only worth anything if it parses what is really written.
+func configFromInbounds(t *testing.T, inbounds string) *xray.Config {
+ t.Helper()
+ var cfg xray.Config
+ if err := json.Unmarshal([]byte(`{"inbounds":[`+inbounds+`]}`), &cfg); err != nil {
+ t.Fatalf("build config: %v", err)
+ }
+ return &cfg
+}
+
+func TestBindConflicts(t *testing.T) {
+ const (
+ relay = `{"listen":"127.0.0.1","port":65101,"protocol":"socks","tag":"relay","settings":{"auth":"password","udp":true,"accounts":[]}}`
+ user = `{"listen":"0.0.0.0","port":65101,"protocol":"vless","tag":"user","streamSettings":{"network":"tcp"}}`
+ )
+
+ cases := []struct {
+ name string
+ inbounds string
+ running string
+ want int
+ }{
+ {
+ "same listen, port and tcp",
+ `{"listen":"0.0.0.0","port":443,"protocol":"vless","tag":"a"},
+ {"listen":"0.0.0.0","port":443,"protocol":"vmess","tag":"b"}`,
+ ``, 1,
+ },
+ {
+ "tcp and udp on one port are legal",
+ `{"listen":"0.0.0.0","port":443,"protocol":"vless","tag":"a"},
+ {"listen":"0.0.0.0","port":443,"protocol":"hysteria","tag":"b"}`,
+ ``, 0,
+ },
+ {
+ "kcp moves vless to udp and frees the port",
+ `{"listen":"0.0.0.0","port":443,"protocol":"vless","tag":"a","streamSettings":{"network":"kcp"}},
+ {"listen":"0.0.0.0","port":443,"protocol":"vless","tag":"b","streamSettings":{"network":"tcp"}}`,
+ ``, 0,
+ },
+ {
+ "wildcard listen overlaps a loopback one",
+ `{"listen":"0.0.0.0","port":8443,"protocol":"vless","tag":"a"},
+ {"listen":"127.0.0.1","port":8443,"protocol":"trojan","tag":"b"}`,
+ ``, 1,
+ },
+ {
+ "absent listen means wildcard",
+ `{"port":8443,"protocol":"vless","tag":"a"},
+ {"listen":"127.0.0.1","port":8443,"protocol":"trojan","tag":"b"}`,
+ ``, 1,
+ },
+ {
+ "distinct loopback addresses do not overlap",
+ `{"listen":"127.0.0.1","port":8443,"protocol":"vless","tag":"a"},
+ {"listen":"127.0.0.2","port":8443,"protocol":"vless","tag":"b"}`,
+ ``, 0,
+ },
+ {
+ "port zero is not a bind",
+ `{"listen":"0.0.0.0","port":0,"protocol":"tunnel","tag":"a"},
+ {"listen":"0.0.0.0","port":0,"protocol":"vless","tag":"b"}`,
+ ``, 0,
+ },
+ {
+ "clean config",
+ `{"listen":"0.0.0.0","port":443,"protocol":"vless","tag":"a"},
+ {"listen":"127.0.0.1","port":62789,"protocol":"tunnel","tag":"api"},
+ ` + relay,
+ ``, 0,
+ },
+ {
+ // The relay the AmneziaWG family lives on: loopback tcp+udp, on the
+ // same port as a user inbound's tcp.
+ "amneziawg relay against a tcp inbound",
+ relay + "," + user, ``, 1,
+ },
+ {
+ "amneziawg relay against a udp inbound",
+ relay + `,{"listen":"0.0.0.0","port":65101,"protocol":"hysteria","tag":"user"}`,
+ ``, 1,
+ },
+ {
+ // Proves the relay's transports come from settings.udp and not from a
+ // blanket "loopback owns everything" rule.
+ "socks bridge without udp coexists with a udp inbound",
+ `{"listen":"127.0.0.1","port":65101,"protocol":"socks","tag":"bridge","settings":{"auth":"noauth"}},
+ {"listen":"0.0.0.0","port":65101,"protocol":"hysteria","tag":"user"}`,
+ ``, 0,
+ },
+ {
+ "reserved api inbound against a user inbound",
+ `{"listen":"127.0.0.1","port":62789,"protocol":"tunnel","tag":"api","settings":{"rewriteAddress":"127.0.0.1"}},
+ {"listen":"0.0.0.0","port":62789,"protocol":"vless","tag":"user"}`,
+ ``, 1,
+ },
+ {
+ // The core is running this pair right now, so whatever a static read
+ // says about it, it binds: an established setup is never refused.
+ "collision the running config already serves",
+ relay + "," + user, relay + "," + user, 0,
+ },
+ {
+ "collision the running config does not have",
+ relay + "," + user, user, 1,
+ },
+ {
+ // `::` and `0.0.0.0` on one port is what bindv6only=1 makes legal, so the
+ // running config excuses it -- but moving one onto the other is not it.
+ "excused pair whose listen changed into a real collision",
+ `{"listen":"0.0.0.0","port":443,"protocol":"vless","tag":"a"},
+ {"listen":"0.0.0.0","port":443,"protocol":"vless","tag":"b"}`,
+ `{"listen":"::","port":443,"protocol":"vless","tag":"a"},
+ {"listen":"0.0.0.0","port":443,"protocol":"vless","tag":"b"}`,
+ 1,
+ },
+ {
+ // The same pair of sockets is the same evidence, whichever order the
+ // generator happened to emit them in.
+ "excused pair with its listens swapped stays excused",
+ `{"listen":"0.0.0.0","port":443,"protocol":"vless","tag":"a"},
+ {"listen":"::","port":443,"protocol":"vless","tag":"b"}`,
+ `{"listen":"::","port":443,"protocol":"vless","tag":"a"},
+ {"listen":"0.0.0.0","port":443,"protocol":"vless","tag":"b"}`,
+ 0,
+ },
+ {
+ "same pair on another port is still new",
+ `{"listen":"127.0.0.1","port":65102,"protocol":"socks","tag":"relay","settings":{"auth":"password","udp":true,"accounts":[]}},
+ {"listen":"0.0.0.0","port":65102,"protocol":"vless","tag":"user","streamSettings":{"network":"tcp"}}`,
+ relay + "," + user, 1,
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ var running *xray.Config
+ if tc.running != "" {
+ running = configFromInbounds(t, tc.running)
+ }
+ got := bindConflicts(configFromInbounds(t, tc.inbounds), running)
+ if len(got) != tc.want {
+ t.Fatalf("bindConflicts = %v, want %d conflict(s)", got, tc.want)
+ }
+ for _, c := range got {
+ if c.tagA == "" || c.tagB == "" || c.tagA == c.tagB {
+ t.Fatalf("a conflict must name both tags, got %+v", c)
+ }
+ }
+ })
+ }
+}
+
+func TestBindConflicts_MessageNamesBothSides(t *testing.T) {
+ conflicts := bindConflicts(configFromInbounds(t, `
+ {"listen":"127.0.0.1","port":65101,"protocol":"socks","tag":"relay","settings":{"auth":"password","udp":true,"accounts":[]}},
+ {"listen":"0.0.0.0","port":65101,"protocol":"vless","tag":"user","streamSettings":{"network":"tcp"}}`), nil)
+ if len(conflicts) != 1 {
+ t.Fatalf("want exactly one conflict, got %v", conflicts)
+ }
+ msg := conflicts[0].String()
+ for _, want := range []string{`"relay"`, `"user"`, "127.0.0.1", "65101"} {
+ if !strings.Contains(msg, want) {
+ t.Fatalf("conflict message %q must contain %q", msg, want)
+ }
+ }
+}
+
+// The probe must read what the panel really emits: the AmneziaWG relay is a
+// loopback "socks" inbound whose udp flag lives in settings, not streamSettings.
+func TestBindConflicts_GeneratedConfig(t *testing.T) {
+ cases := []struct {
+ name string
+ portOff int
+ running bool
+ want int
+ }{
+ {"user inbound on the relay port", 0, false, 1},
+ {"already running config", 0, true, 0},
+ {"user inbound on a free port", 1, false, 0},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ setupSettingTestDB(t)
+ seedInboundConflict(t, "awg-1", "0.0.0.0", 51820, model.AmneziaWG, ``, amneziawgRoutedSettings)
+
+ var awg model.Inbound
+ if err := database.GetDB().Where("tag = ?", "awg-1").First(&awg).Error; err != nil {
+ t.Fatalf("read seeded row: %v", err)
+ }
+ relayPort := amneziawgnet.SOCKSPortForInbound(awg.Id)
+ seedInboundConflict(t, "user", "0.0.0.0", relayPort+tc.portOff, model.VLESS, `{"network":"tcp"}`, `{}`)
+
+ svc := &XrayService{}
+ cfg, err := svc.GetXrayConfig()
+ if err != nil {
+ t.Fatalf("GetXrayConfig: %v", err)
+ }
+ running := (*xray.Config)(nil)
+ if tc.running {
+ if running, err = svc.GetXrayConfig(); err != nil {
+ t.Fatalf("second GetXrayConfig: %v", err)
+ }
+ }
+
+ got := bindConflicts(cfg, running)
+ if len(got) != tc.want {
+ t.Fatalf("bindConflicts = %v, want %d", got, tc.want)
+ }
+ if tc.want == 0 {
+ return
+ }
+ msg := got[0].String()
+ for _, want := range []string{`"awg-1"`, `"user"`, strconv.Itoa(relayPort)} {
+ if !strings.Contains(msg, want) {
+ t.Fatalf("conflict message %q must contain %q", msg, want)
+ }
+ }
+ })
+ }
+}