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) + } + } + }) + } +}