diff --git a/docs/content/docs/en/reference/api/xray-settings.mdx b/docs/content/docs/en/reference/api/xray-settings.mdx index 92567e59c..4576a0687 100644 --- a/docs/content/docs/en/reference/api/xray-settings.mdx +++ b/docs/content/docs/en/reference/api/xray-settings.mdx @@ -59,9 +59,10 @@ _openapi: url: '#live-state-of-routing-balancers-in-the-running-core-routingservicegetbalancerinfo-current-override-and-the-targets-the-strategy-prefers-returns-a-map-keyed-by-balancer-tag' - depth: 2 title: Force a balancer in the running core to always pick one outbound - (RoutingService.OverrideBalancerTarget). Applied live without a restart; - cleared automatically when Xray restarts. - url: '#force-a-balancer-in-the-running-core-to-always-pick-one-outbound-routingserviceoverridebalancertarget-applied-live-without-a-restart-cleared-automatically-when-xray-restarts' + (RoutingService.OverrideBalancerTarget). Applied live without a restart + and kept across config saves; cleared when Xray restarts or the target + outbound is removed. + url: '#force-a-balancer-in-the-running-core-to-always-pick-one-outbound-routingserviceoverridebalancertarget-applied-live-without-a-restart-and-kept-across-config-saves-cleared-when-xray-restarts-or-the-target-outbound-is-removed' - depth: 2 title: Ask the running core which outbound its router would pick for a synthetic connection (RoutingService.TestRoute). No traffic is sent. @@ -160,8 +161,9 @@ _openapi: id: live-state-of-routing-balancers-in-the-running-core-routingservicegetbalancerinfo-current-override-and-the-targets-the-strategy-prefers-returns-a-map-keyed-by-balancer-tag - content: Force a balancer in the running core to always pick one outbound (RoutingService.OverrideBalancerTarget). Applied live without a - restart; cleared automatically when Xray restarts. - id: force-a-balancer-in-the-running-core-to-always-pick-one-outbound-routingserviceoverridebalancertarget-applied-live-without-a-restart-cleared-automatically-when-xray-restarts + restart and kept across config saves; cleared when Xray restarts or + the target outbound is removed. + id: force-a-balancer-in-the-running-core-to-always-pick-one-outbound-routingserviceoverridebalancertarget-applied-live-without-a-restart-and-kept-across-config-saves-cleared-when-xray-restarts-or-the-target-outbound-is-removed - content: Ask the running core which outbound its router would pick for a synthetic connection (RoutingService.TestRoute). No traffic is sent. id: ask-the-running-core-which-outbound-its-router-would-pick-for-a-synthetic-connection-routingservicetestroute-no-traffic-is-sent diff --git a/docs/public/openapi.json b/docs/public/openapi.json index 570f87ec3..b50cb368f 100644 --- a/docs/public/openapi.json +++ b/docs/public/openapi.json @@ -14058,7 +14058,7 @@ "tags": [ "Xray Settings" ], - "summary": "Force a balancer in the running core to always pick one outbound (RoutingService.OverrideBalancerTarget). Applied live without a restart; cleared automatically when Xray restarts.", + "summary": "Force a balancer in the running core to always pick one outbound (RoutingService.OverrideBalancerTarget). Applied live without a restart and kept across config saves; cleared when Xray restarts or the target outbound is removed.", "operationId": "post_panel_api_xray_balancerOverride", "requestBody": { "required": true, @@ -14073,7 +14073,7 @@ }, "target": { "type": "string", - "description": "Outbound tag to force. Empty clears the override and returns control to the strategy." + "description": "Outbound tag to force; it must exist in the running config. Empty clears the override and returns control to the strategy." } }, "required": [ diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index 570f87ec3..b50cb368f 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -14058,7 +14058,7 @@ "tags": [ "Xray Settings" ], - "summary": "Force a balancer in the running core to always pick one outbound (RoutingService.OverrideBalancerTarget). Applied live without a restart; cleared automatically when Xray restarts.", + "summary": "Force a balancer in the running core to always pick one outbound (RoutingService.OverrideBalancerTarget). Applied live without a restart and kept across config saves; cleared when Xray restarts or the target outbound is removed.", "operationId": "post_panel_api_xray_balancerOverride", "requestBody": { "required": true, @@ -14073,7 +14073,7 @@ }, "target": { "type": "string", - "description": "Outbound tag to force. Empty clears the override and returns control to the strategy." + "description": "Outbound tag to force; it must exist in the running config. Empty clears the override and returns control to the strategy." } }, "required": [ diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts index 438b8037b..a0b343a5b 100644 --- a/frontend/src/pages/api-docs/endpoints.ts +++ b/frontend/src/pages/api-docs/endpoints.ts @@ -2336,14 +2336,14 @@ export const sections: readonly Section[] = [ method: 'POST', path: '/panel/api/xray/balancerOverride', summary: - 'Force a balancer in the running core to always pick one outbound (RoutingService.OverrideBalancerTarget). Applied live without a restart; cleared automatically when Xray restarts.', + 'Force a balancer in the running core to always pick one outbound (RoutingService.OverrideBalancerTarget). Applied live without a restart and kept across config saves; cleared when Xray restarts or the target outbound is removed.', params: [ { name: 'tag', in: 'body (form)', type: 'string', desc: 'Balancer tag (required).' }, { name: 'target', in: 'body (form)', type: 'string', - desc: 'Outbound tag to force. Empty clears the override and returns control to the strategy.', + desc: 'Outbound tag to force; it must exist in the running config. Empty clears the override and returns control to the strategy.', optional: true, }, ], diff --git a/internal/web/service/node.go b/internal/web/service/node.go index 6e014ec87..9a770f0c6 100644 --- a/internal/web/service/node.go +++ b/internal/web/service/node.go @@ -1145,27 +1145,46 @@ func (s *NodeService) ProbeWithOutbound(ctx context.Context, n *model.Node, outb // Xray isn't running or the bridge can't be built, fn runs with an empty // proxyURL so callers fall back to a direct connection. func (s *NodeService) withOutboundBridge(nodeID int, outboundTag string, fn func(proxyURL string)) { - proc := XrayProcess() - if proc == nil || !proc.IsRunning() { + bridge := openOutboundBridge(nodeID, outboundTag) + if bridge == nil { fn("") return } + defer bridge.close() + fn(bridge.proxyURL) +} + +// outboundBridge is one withOutboundBridge call's temporary inbound and leading +// routing rule, tied to the core process it was built in. +type outboundBridge struct { + proc *xray.Process + api xray.XrayAPI + inboundTag string + proxyURL string +} + +// openOutboundBridge holds the package lock, like every change to the running +// core, but not while the probe runs; nil means Xray is down or the build failed. +func openOutboundBridge(nodeID int, outboundTag string) *outboundBridge { + lock.Lock() + defer lock.Unlock() + proc := XrayProcess() + if proc == nil || !proc.IsRunning() { + return nil + } apiPort := proc.GetAPIPort() if apiPort <= 0 { - fn("") - return + return nil } listener, err := (&net.ListenConfig{}).Listen(context.Background(), "tcp", "127.0.0.1:0") if err != nil { - fn("") - return + return nil } port := listener.Addr().(*net.TCPAddr).Port listener.Close() tag := fmt.Sprintf("node-test-%d-%d", nodeID, time.Now().UnixNano()) - proxyURL := fmt.Sprintf("socks5://127.0.0.1:%d", port) inboundJSON, err := json.Marshal(xray.InboundConfig{ Listen: json_util.RawMessage(`"127.0.0.1"`), @@ -1175,8 +1194,7 @@ func (s *NodeService) withOutboundBridge(nodeID int, outboundTag string, fn func Tag: tag, }) if err != nil { - fn("") - return + return nil } cfg := proc.GetConfig() @@ -1197,43 +1215,51 @@ func (s *NodeService) withOutboundBridge(nodeID int, outboundTag string, fn func routing["rules"] = append([]any{rule}, rules...) routingJSON, err := json.Marshal(routing) if err != nil { - fn("") + return nil + } + + bridge := &outboundBridge{proc: proc, inboundTag: tag, proxyURL: fmt.Sprintf("socks5://127.0.0.1:%d", port)} + if err := bridge.api.Init(apiPort); err != nil { + return nil + } + if err := bridge.api.AddInbound(inboundJSON); err != nil { + bridge.api.Close() + return nil + } + if err := bridge.api.ApplyRoutingConfig(routingJSON); err != nil { + bridge.removeInbound() + bridge.api.Close() + return nil + } + balancerOverrides.reapply(proc, &bridge.api, cfg) + return bridge +} + +// close restores the routing the core's config holds now, which a save during the +// probe may have changed. A core restarted meanwhile never had the bridge. +func (b *outboundBridge) close() { + defer b.api.Close() + lock.Lock() + defer lock.Unlock() + if XrayProcess() != b.proc { return } - originalRoutingJSON := cfg.RouterConfig - - api := xray.XrayAPI{} - if err := api.Init(apiPort); err != nil { - fn("") - return + cfg := b.proc.GetConfig() + restore := cfg.RouterConfig + if len(restore) == 0 { + restore = []byte("{}") } - defer api.Close() - - if err := api.AddInbound(inboundJSON); err != nil { - fn("") - return + if err := b.api.ApplyRoutingConfig(restore); err != nil { + logger.Warning("restore routing after node bridge failed:", err) } - defer func() { - if err := api.DelInbound(tag); err != nil { - logger.Warning("remove temp node bridge inbound failed:", err) - } - }() + balancerOverrides.reapply(b.proc, &b.api, cfg) + b.removeInbound() +} - if err := api.ApplyRoutingConfig(routingJSON); err != nil { - fn("") - return +func (b *outboundBridge) removeInbound() { + if err := b.api.DelInbound(b.inboundTag); err != nil { + logger.Warning("remove temp node bridge inbound failed:", err) } - defer func() { - restore := originalRoutingJSON - if len(restore) == 0 { - restore = []byte("{}") - } - if err := api.ApplyRoutingConfig(restore); err != nil { - logger.Warning("restore routing after node bridge failed:", err) - } - }() - - fn(proxyURL) } // A status envelope holds a handful of scalars; the cap keeps a hostile or diff --git a/internal/web/service/xray.go b/internal/web/service/xray.go index d0ebc3cfb..c1e205e09 100644 --- a/internal/web/service/xray.go +++ b/internal/web/service/xray.go @@ -1340,65 +1340,21 @@ func (s *XrayService) GetBalancersStatus(tags []string) ([]BalancerStatus, error return statuses, nil } -// OverrideBalancer forces a balancer in the running core to use the given -// outbound tag; an empty target clears the override. When target names -// another balancer, the override resolves to the loopback outbound that -// routes traffic through the target balancer via the routing rules. +// OverrideBalancer forces balancer tag onto target in the running core; an empty +// target clears it. The panel keeps it across routing reloads until Xray restarts. func (s *XrayService) OverrideBalancer(tag, target string) error { + // Serialized with config applies, so the target is checked against the config the core runs. + lock.Lock() + defer lock.Unlock() process := currentXrayProcess() if process == nil || !process.IsRunning() { return errors.New("xray is not running") } - if target != "" { - resolved, err := s.resolveOverrideTarget(target) - if err != nil { - return err - } - if resolved != "" { - target = resolved - } - } if err := s.xrayAPI.Init(process.GetAPIPort()); err != nil { return err } defer s.xrayAPI.Close() - return s.xrayAPI.SetBalancerTarget(tag, target) -} - -// resolveOverrideTarget checks if target names a balancer and, if so, -// returns the loopback outbound tag that routes to it through the -// routing rules. Returns empty if target is already a concrete outbound. -func (s *XrayService) resolveOverrideTarget(target string) (string, error) { - template, err := s.settingService.GetXrayConfigTemplate() - if err != nil { - return "", err - } - var cfg map[string]any - if err := json.Unmarshal([]byte(template), &cfg); err != nil { - return "", err - } - routing, _ := cfg["routing"].(map[string]any) - if routing == nil { - return "", nil - } - rules, _ := routing["rules"].([]any) - for _, r := range rules { - rule, ok := r.(map[string]any) - if !ok { - continue - } - if rule["balancerTag"] != target { - continue - } - inboundTags, ok := rule["inboundTag"].([]any) - if !ok || len(inboundTags) == 0 { - continue - } - if lbTag, ok := inboundTags[0].(string); ok && strings.HasPrefix(lbTag, "_bl_") { - return lbTag, nil - } - } - return "", nil + return balancerOverrides.set(process, &s.xrayAPI, tag, target) } // TestRoute asks the running core which outbound its router picks for the @@ -1571,6 +1527,8 @@ func (s *XrayService) tryHotApply(process *xray.Process, newCfg *xray.Config) bo return false } } + // A routing reload rebuilds every balancer, and a removed outbound strands an override. + balancerOverrides.reapply(process, &hotAPI, newCfg) process.SetConfig(newCfg) persistHotConfig(process) diff --git a/internal/web/service/xray_balancer_override.go b/internal/web/service/xray_balancer_override.go new file mode 100644 index 000000000..035ee4e3a --- /dev/null +++ b/internal/web/service/xray_balancer_override.go @@ -0,0 +1,112 @@ +package service + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/mhsanaei/3x-ui/v3/internal/logger" + "github.com/mhsanaei/3x-ui/v3/internal/xray" +) + +var errOverrideTargetUnknown = errors.New("override target is not an outbound in the running config") + +// balancerOverrides keeps the panel's overrides across routing reloads, which drop +// them. The package lock guards it, as it does every change to the running core. +var balancerOverrides balancerOverrideSet + +// balancerOverrideSet belongs to one core process: a restart clears overrides, +// so records made for an earlier process are never re-applied. +type balancerOverrideSet struct { + process *xray.Process + targets map[string]string +} + +// set forces balancer tag to target in the running core and records it; an +// empty target clears the override. +func (s *balancerOverrideSet) set(process *xray.Process, api *xray.XrayAPI, tag, target string) error { + coreTarget := "" + if target != "" { + resolved, err := resolveOverrideTarget(process.GetConfig(), target) + if err != nil { + return err + } + coreTarget = resolved + } + if err := api.SetBalancerTarget(tag, coreTarget); err != nil { + return err + } + if s.process != process { + s.process = process + s.targets = map[string]string{} + } + if target == "" { + delete(s.targets, tag) + } else { + s.targets[tag] = target + } + return nil +} + +// reapply restores the recorded overrides once cfg is live, dropping any whose +// balancer or target cfg no longer has. +func (s *balancerOverrideSet) reapply(process *xray.Process, api *xray.XrayAPI, cfg *xray.Config) { + if s.process != process { + return + } + routing := map[string]any{} + if len(cfg.RouterConfig) > 0 { + _ = json.Unmarshal(cfg.RouterConfig, &routing) + } + for tag, target := range s.targets { + if !routingTagIsBalancer(routing, tag) { + delete(s.targets, tag) + continue + } + coreTarget, err := resolveOverrideTarget(cfg, target) + if err != nil { + logger.Warning("balancer [", tag, "] override dropped:", err) + delete(s.targets, tag) + } + if err := api.SetBalancerTarget(tag, coreTarget); err != nil { + logger.Warning("re-apply override of balancer [", tag, "] failed:", err) + } + } +} + +// resolveOverrideTarget maps target to the outbound tag the core routes to: a +// balancer becomes the _bl_ loopback outbound whose rule feeds it. +func resolveOverrideTarget(cfg *xray.Config, target string) (string, error) { + routing := map[string]any{} + if len(cfg.RouterConfig) > 0 { + if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil { + return "", err + } + } + if loopback := balancerLoopbackTag(routing, target); loopback != "" && outboundTagExists(cfg.OutboundConfigs, loopback) { + return loopback, nil + } + if outboundTagExists(cfg.OutboundConfigs, target) { + return target, nil + } + return "", fmt.Errorf("%w: %q", errOverrideTargetUnknown, target) +} + +func balancerLoopbackTag(routing map[string]any, balancerTag string) string { + rules, _ := routing["rules"].([]any) + for _, r := range rules { + rule, ok := r.(map[string]any) + if !ok || rule["balancerTag"] != balancerTag { + continue + } + inboundTags, _ := rule["inboundTag"].([]any) + if len(inboundTags) == 0 { + continue + } + if tag, ok := inboundTags[0].(string); ok && strings.HasPrefix(tag, "_bl_") { + return tag + } + } + return "" +} diff --git a/internal/web/service/xray_balancer_override_e2e_test.go b/internal/web/service/xray_balancer_override_e2e_test.go new file mode 100644 index 000000000..aa03132a9 --- /dev/null +++ b/internal/web/service/xray_balancer_override_e2e_test.go @@ -0,0 +1,454 @@ +package service + +import ( + "encoding/json" + "errors" + "net" + "os" + "path/filepath" + "testing" + "time" + + "github.com/mhsanaei/3x-ui/v3/internal/xray" +) + +// Real-core tests: the bugs they pin live in how RoutingService rebuilds +// balancers, which no fake reproduces faithfully. + +type balancerTestConfig struct { + outbounds []string + extraRule bool +} + +func (c balancerTestConfig) build(t *testing.T, apiPort int) *xray.Config { + t.Helper() + outbounds := []any{} + for _, tag := range c.outbounds { + protocol := "freedom" + if tag == "blocked" { + protocol = "blackhole" + } + outbounds = append(outbounds, map[string]any{"tag": tag, "protocol": protocol, "settings": map[string]any{}}) + } + rules := []any{ + map[string]any{"type": "field", "inboundTag": []string{"api"}, "outboundTag": "api"}, + map[string]any{"type": "field", "port": "7777", "balancerTag": "b1"}, + } + if c.extraRule { + rules = append(rules, map[string]any{"type": "field", "port": "8888", "outboundTag": "blocked"}) + } + routing := map[string]any{ + "domainStrategy": "AsIs", + "rules": rules, + "balancers": []any{map[string]any{"tag": "b1", "selector": []string{"proxy-"}}}, + } + return &xray.Config{ + LogConfig: mustJSON(t, map[string]any{"loglevel": "warning"}), + RouterConfig: mustJSON(t, routing), + OutboundConfigs: mustJSON(t, outbounds), + API: mustJSON(t, map[string]any{"tag": "api", "services": []string{"HandlerService", "RoutingService"}}), + InboundConfigs: []xray.InboundConfig{{ + Listen: mustJSON(t, "127.0.0.1"), + Port: apiPort, + Protocol: "tunnel", + Settings: mustJSON(t, map[string]any{"rewriteAddress": "127.0.0.1"}), + Tag: "api", + }}, + } +} + +var allBalancerTestOutbounds = []string{"direct", "blocked", "proxy-a", "proxy-b"} + +func mustJSON(t *testing.T, v any) []byte { + t.Helper() + data, err := json.Marshal(v) + if err != nil { + t.Fatal(err) + } + return data +} + +func freeLoopbackPort(t *testing.T) int { + t.Helper() + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer l.Close() + return l.Addr().(*net.TCPAddr).Port +} + +// startBalancerTestXray runs a real core and installs it as the panel's +// current process, the way RestartXray does. +func startBalancerTestXray(t *testing.T, spec balancerTestConfig) (*xray.Process, int) { + t.Helper() + return startBalancerTestXrayOnPort(t, spec, freeLoopbackPort(t)) +} + +// startBalancerTestXrayOnPort keeps the API port, as a panel restart does, +// so clients still holding the old core's port reach the new one. +func startBalancerTestXrayOnPort(t *testing.T, spec balancerTestConfig, apiPort int) (*xray.Process, int) { + t.Helper() + bin := os.Getenv("XRAY_E2E_BINARY") + if bin == "" { + t.Skip("set XRAY_E2E_BINARY to an xray built from go.mod's xray-core (go build github.com/xtls/xray-core/main)") + } + binDir := t.TempDir() + if err := os.Symlink(bin, filepath.Join(binDir, xray.GetBinaryName())); err != nil { + t.Fatal(err) + } + t.Setenv("XUI_BIN_FOLDER", binDir) + + process := xray.NewTestProcess(spec.build(t, apiPort), filepath.Join(binDir, "config.json")) + if err := process.Start(); err != nil { + t.Fatalf("start xray: %v", err) + } + t.Cleanup(func() { _ = process.Stop() }) + + previousProcess, previousResult := xrayState.snapshot() + xrayState.replace(process) + t.Cleanup(func() { + xrayState.mu.Lock() + xrayState.process = previousProcess + xrayState.result = previousResult + xrayState.mu.Unlock() + }) + + deadline := time.Now().Add(15 * time.Second) + for { + if _, err := liveBalancerInfo(process, "b1"); err == nil { + return process, apiPort + } else if time.Now().After(deadline) { + t.Fatalf("xray api never became ready: %v", err) + } + time.Sleep(100 * time.Millisecond) + } +} + +func liveBalancerInfo(process *xray.Process, tag string) (*xray.BalancerInfo, error) { + api := xray.XrayAPI{} + if err := api.Init(process.GetAPIPort()); err != nil { + return nil, err + } + defer api.Close() + return api.GetBalancerInfo(tag) +} + +func liveBalancerOverride(t *testing.T, process *xray.Process, tag string) string { + t.Helper() + info, err := liveBalancerInfo(process, tag) + if err != nil { + t.Fatalf("GetBalancerInfo(%s): %v", tag, err) + } + return info.Override +} + +// routeFor is the outbound the running core picks for a TCP connection to port. +func routeFor(process *xray.Process, port int) (string, error) { + api := xray.XrayAPI{} + if err := api.Init(process.GetAPIPort()); err != nil { + return "", err + } + defer api.Close() + result, err := api.TestRoute(xray.RouteTestRequest{IP: "203.0.113.1", Port: port}) + if err != nil { + return "", err + } + return result.OutboundTag, nil +} + +func liveRoute(t *testing.T, process *xray.Process, port int) string { + t.Helper() + route, err := routeFor(process, port) + if err != nil { + t.Fatalf("TestRoute(port %d): %v", port, err) + } + return route +} + +// saveDuringTest hot-applies cfg the way RestartXray does, holding the package lock. +func saveDuringTest(t *testing.T, process *xray.Process, cfg *xray.Config) { + t.Helper() + lock.Lock() + defer lock.Unlock() + if !(&XrayService{}).tryHotApply(process, cfg) { + t.Fatal("tryHotApply = false, want the change applied live") + } +} + +// Every routing save is hot-applied with AddRule(shouldAppend=false), which +// rebuilds all balancers and used to drop the operator's override silently. +func TestBalancerOverrideSurvivesRoutingHotApply(t *testing.T) { + process, apiPort := startBalancerTestXray(t, balancerTestConfig{outbounds: allBalancerTestOutbounds}) + svc := &XrayService{} + if err := svc.OverrideBalancer("b1", "proxy-b"); err != nil { + t.Fatalf("OverrideBalancer: %v", err) + } + + newCfg := balancerTestConfig{outbounds: allBalancerTestOutbounds, extraRule: true}.build(t, apiPort) + if !svc.tryHotApply(process, newCfg) { + t.Fatal("tryHotApply = false, want the routing change applied live") + } + + if got := liveBalancerOverride(t, process, "b1"); got != "proxy-b" { + t.Fatalf("override after routing hot apply = %q, want proxy-b", got) + } +} + +// An override naming a removed outbound makes the core close every connection +// the balancer routes ("non existing outTag"), so removing the target clears it. +func TestBalancerOverrideClearedWhenTargetOutboundRemoved(t *testing.T) { + process, apiPort := startBalancerTestXray(t, balancerTestConfig{outbounds: allBalancerTestOutbounds}) + svc := &XrayService{} + if err := svc.OverrideBalancer("b1", "proxy-b"); err != nil { + t.Fatalf("OverrideBalancer: %v", err) + } + + newCfg := balancerTestConfig{outbounds: []string{"direct", "blocked", "proxy-a"}}.build(t, apiPort) + if !svc.tryHotApply(process, newCfg) { + t.Fatal("tryHotApply = false, want the outbound removal applied live") + } + + if got := liveBalancerOverride(t, process, "b1"); got != "" { + t.Fatalf("override after its target was removed = %q, want cleared", got) + } +} + +// Testing a node through an outbound swaps the live routing in and back out; +// the override must hold while the probe runs and after the restore. +func TestBalancerOverrideSurvivesNodeOutboundBridge(t *testing.T) { + process, _ := startBalancerTestXray(t, balancerTestConfig{outbounds: allBalancerTestOutbounds}) + if err := (&XrayService{}).OverrideBalancer("b1", "proxy-b"); err != nil { + t.Fatalf("OverrideBalancer: %v", err) + } + + var duringProbe string + (&NodeService{}).withOutboundBridge(1, "direct", func(proxyURL string) { + if proxyURL == "" { + t.Fatal("bridge was not built, so the routing swap under test never ran") + } + duringProbe = liveBalancerOverride(t, process, "b1") + }) + + if duringProbe != "proxy-b" { + t.Errorf("override while the node bridge was up = %q, want proxy-b", duringProbe) + } + if got := liveBalancerOverride(t, process, "b1"); got != "proxy-b" { + t.Errorf("override after the node bridge was torn down = %q, want proxy-b", got) + } +} + +// A node probe can run for seconds; an override the operator sets meanwhile, +// onto an outbound saved meanwhile, must outlive the bridge's teardown. +func TestBalancerOverrideSetDuringNodeBridgeSurvivesTeardown(t *testing.T) { + process, apiPort := startBalancerTestXray(t, balancerTestConfig{outbounds: allBalancerTestOutbounds}) + withProxyC := balancerTestConfig{outbounds: []string{"direct", "blocked", "proxy-a", "proxy-b", "proxy-c"}}.build(t, apiPort) + + (&NodeService{}).withOutboundBridge(1, "direct", func(proxyURL string) { + if proxyURL == "" { + t.Fatal("bridge was not built, so the teardown under test never runs") + } + saveDuringTest(t, process, withProxyC) + if err := (&XrayService{}).OverrideBalancer("b1", "proxy-c"); err != nil { + t.Fatalf("OverrideBalancer: %v", err) + } + }) + + if got := liveBalancerOverride(t, process, "b1"); got != "proxy-c" { + t.Fatalf("override after the node bridge was torn down = %q, want proxy-c", got) + } +} + +// The teardown used to restore the routing captured before the probe, silently +// reverting a rule change saved while the probe ran. +func TestNodeBridgeTeardownKeepsRoutingSavedDuringProbe(t *testing.T) { + process, apiPort := startBalancerTestXray(t, balancerTestConfig{outbounds: allBalancerTestOutbounds}) + withPort8888Rule := balancerTestConfig{outbounds: allBalancerTestOutbounds, extraRule: true}.build(t, apiPort) + + (&NodeService{}).withOutboundBridge(1, "direct", func(proxyURL string) { + if proxyURL == "" { + t.Fatal("bridge was not built, so the teardown under test never runs") + } + saveDuringTest(t, process, withPort8888Rule) + }) + + if got := liveRoute(t, process, 8888); got != "blocked" { + t.Fatalf("route for port 8888 after the node bridge was torn down = %q, want blocked", got) + } +} + +// Mid-apply, the core already runs the saved rules but its config snapshot is +// stale; a bridge opened then ran every connection on the stale rules. +func TestNodeBridgeOpenedDuringConfigApplyRoutesByTheAppliedConfig(t *testing.T) { + process, apiPort := startBalancerTestXray(t, balancerTestConfig{outbounds: allBalancerTestOutbounds}) + withPort8888Rule := balancerTestConfig{outbounds: allBalancerTestOutbounds, extraRule: true}.build(t, apiPort) + api := xray.XrayAPI{} + if err := api.Init(apiPort); err != nil { + t.Fatal(err) + } + defer api.Close() + + type probeView struct { + proxyURL, route string + err error + } + seen, torndown := make(chan probeView, 1), make(chan struct{}) + lock.Lock() + if err := api.ApplyRoutingConfig(withPort8888Rule.RouterConfig); err != nil { + lock.Unlock() + t.Fatalf("ApplyRoutingConfig: %v", err) + } + go func() { + defer close(torndown) + (&NodeService{}).withOutboundBridge(1, "direct", func(proxyURL string) { + route, err := routeFor(process, 8888) + seen <- probeView{proxyURL, route, err} + }) + }() + time.Sleep(300 * time.Millisecond) // room for an unserialized bridge to open mid-apply + process.SetConfig(withPort8888Rule) + lock.Unlock() + view := <-seen + <-torndown + + if view.proxyURL == "" { + t.Fatal("bridge was not built, so the routing under test never ran") + } + if view.err != nil { + t.Fatalf("TestRoute while the bridge was up: %v", view.err) + } + if view.route != "blocked" { + t.Fatalf("route for port 8888 while the node bridge was up = %q, want blocked", view.route) + } +} + +// Mid-apply, the core already runs the saved rules but its config snapshot is +// stale; a teardown landing then restored the stale rules over the save. +func TestNodeBridgeTeardownDuringConfigApplyRestoresTheAppliedRouting(t *testing.T) { + process, apiPort := startBalancerTestXray(t, balancerTestConfig{outbounds: allBalancerTestOutbounds}) + withPort8888Rule := balancerTestConfig{outbounds: allBalancerTestOutbounds, extraRule: true}.build(t, apiPort) + api := xray.XrayAPI{} + if err := api.Init(apiPort); err != nil { + t.Fatal(err) + } + defer api.Close() + + probing, finishProbe, torndown := make(chan string), make(chan struct{}), make(chan struct{}) + go func() { + defer close(torndown) + (&NodeService{}).withOutboundBridge(1, "direct", func(proxyURL string) { + probing <- proxyURL + <-finishProbe + }) + }() + if proxyURL := <-probing; proxyURL == "" { + close(finishProbe) + t.Fatal("bridge was not built, so the teardown under test never runs") + } + + lock.Lock() + if err := api.ApplyRoutingConfig(withPort8888Rule.RouterConfig); err != nil { + lock.Unlock() + close(finishProbe) + t.Fatalf("ApplyRoutingConfig: %v", err) + } + close(finishProbe) + time.Sleep(300 * time.Millisecond) // room for an unserialized teardown to land mid-apply + process.SetConfig(withPort8888Rule) + lock.Unlock() + <-torndown + + if got := liveRoute(t, process, 8888); got != "blocked" { + t.Fatalf("route for port 8888 after the node bridge was torn down = %q, want blocked", got) + } +} + +// A core restarted during the probe never had the bridge; the teardown must not +// push the old core's routing or overrides into it through the shared API port. +func TestNodeBridgeTeardownLeavesRestartedCoreAlone(t *testing.T) { + first, apiPort := startBalancerTestXray(t, balancerTestConfig{outbounds: allBalancerTestOutbounds}) + if err := (&XrayService{}).OverrideBalancer("b1", "proxy-b"); err != nil { + t.Fatalf("OverrideBalancer: %v", err) + } + + var restarted *xray.Process + (&NodeService{}).withOutboundBridge(1, "direct", func(proxyURL string) { + if proxyURL == "" { + t.Fatal("bridge was not built, so the teardown under test never runs") + } + lock.Lock() + defer lock.Unlock() + _ = first.Stop() + restarted, _ = startBalancerTestXrayOnPort(t, balancerTestConfig{outbounds: allBalancerTestOutbounds, extraRule: true}, apiPort) + }) + + if got := liveRoute(t, restarted, 8888); got != "blocked" { + t.Errorf("route for port 8888 on the restarted core = %q, want blocked from its own config", got) + } + if got := liveBalancerOverride(t, restarted, "b1"); got != "" { + t.Errorf("override on the restarted core = %q, want none", got) + } +} + +// The core accepts any string as a target and then closes every connection the +// balancer routes, so the panel must refuse a target the running config lacks. +func TestOverrideBalancerRejectsUnknownTarget(t *testing.T) { + process, _ := startBalancerTestXray(t, balancerTestConfig{outbounds: allBalancerTestOutbounds}) + + err := (&XrayService{}).OverrideBalancer("b1", "no-such-outbound") + if !errors.Is(err, errOverrideTargetUnknown) { + t.Fatalf("OverrideBalancer(unknown target) error = %v, want errOverrideTargetUnknown", err) + } + if got := liveBalancerOverride(t, process, "b1"); got != "" { + t.Fatalf("core override after a refused target = %q, want none", got) + } +} + +// Mid-apply, the core has already dropped an outbound its config snapshot still +// lists; an override landing then pinned the balancer to that missing tag. +func TestOverrideBalancerDuringConfigApplyChecksTheAppliedConfig(t *testing.T) { + process, apiPort := startBalancerTestXray(t, balancerTestConfig{outbounds: allBalancerTestOutbounds}) + withoutProxyB := balancerTestConfig{outbounds: []string{"direct", "blocked", "proxy-a"}}.build(t, apiPort) + api := xray.XrayAPI{} + if err := api.Init(apiPort); err != nil { + t.Fatal(err) + } + defer api.Close() + + lock.Lock() + if err := api.DelOutbound("proxy-b"); err != nil { + lock.Unlock() + t.Fatalf("DelOutbound: %v", err) + } + result := make(chan error, 1) + go func() { result <- (&XrayService{}).OverrideBalancer("b1", "proxy-b") }() + time.Sleep(300 * time.Millisecond) // room for an unserialized override to land mid-apply + process.SetConfig(withoutProxyB) + lock.Unlock() + + if err := <-result; !errors.Is(err, errOverrideTargetUnknown) { + t.Errorf("OverrideBalancer(removed outbound) error = %v, want errOverrideTargetUnknown", err) + } + if got := liveBalancerOverride(t, process, "b1"); got != "" { + t.Errorf("core override = %q, want none: proxy-b is gone from the core", got) + } +} + +// A restart is documented to clear overrides; a routing save on the new core +// must not resurrect one set on the previous process. +func TestBalancerOverrideNotCarriedAcrossRestart(t *testing.T) { + _, _ = startBalancerTestXray(t, balancerTestConfig{outbounds: allBalancerTestOutbounds}) + if err := (&XrayService{}).OverrideBalancer("b1", "proxy-b"); err != nil { + t.Fatalf("OverrideBalancer: %v", err) + } + + restarted, apiPort := startBalancerTestXray(t, balancerTestConfig{outbounds: allBalancerTestOutbounds}) + newCfg := balancerTestConfig{outbounds: allBalancerTestOutbounds, extraRule: true}.build(t, apiPort) + if !(&XrayService{}).tryHotApply(restarted, newCfg) { + t.Fatal("tryHotApply = false, want the routing change applied live") + } + + if got := liveBalancerOverride(t, restarted, "b1"); got != "" { + t.Fatalf("override on the restarted core = %q, want none", got) + } +} diff --git a/internal/web/service/xray_balancer_override_test.go b/internal/web/service/xray_balancer_override_test.go new file mode 100644 index 000000000..c9df5f636 --- /dev/null +++ b/internal/web/service/xray_balancer_override_test.go @@ -0,0 +1,54 @@ +package service + +import ( + "errors" + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/xray" +) + +// The core takes any string as an override and closes every connection routed +// to a tag it lacks, so each refused case here is traffic the panel would drop. +func TestResolveOverrideTarget(t *testing.T) { + cfg := &xray.Config{ + RouterConfig: []byte(`{ + "rules": [ + {"type": "field", "inboundTag": ["_bl_b2"], "balancerTag": "b2"}, + {"type": "field", "port": "7777", "balancerTag": "b3"}, + {"type": "field", "inboundTag": ["_bl_b4"], "balancerTag": "b4"} + ], + "balancers": [ + {"tag": "b1", "selector": ["proxy-"]}, + {"tag": "b2", "selector": ["proxy-"]}, + {"tag": "b3", "selector": ["proxy-"]}, + {"tag": "b4", "selector": ["proxy-"]} + ] + }`), + OutboundConfigs: []byte(`[ + {"tag": "direct", "protocol": "freedom"}, + {"tag": "proxy-a", "protocol": "freedom"}, + {"tag": "_bl_b2", "protocol": "loopback", "settings": {"inboundTag": "_bl_b2"}} + ]`), + } + cases := []struct { + name, target, want string + wantErr error + }{ + {"outbound is used as is", "proxy-a", "proxy-a", nil}, + {"balancer resolves to the loopback outbound feeding it", "b2", "_bl_b2", nil}, + {"balancer without a loopback is refused", "b3", "", errOverrideTargetUnknown}, + {"loopback rule whose outbound is gone is refused", "b4", "", errOverrideTargetUnknown}, + {"unknown tag is refused", "no-such-outbound", "", errOverrideTargetUnknown}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := resolveOverrideTarget(cfg, tc.target) + if !errors.Is(err, tc.wantErr) { + t.Fatalf("resolveOverrideTarget(%q) error = %v, want %v", tc.target, err, tc.wantErr) + } + if got != tc.want { + t.Fatalf("resolveOverrideTarget(%q) = %q, want %q", tc.target, got, tc.want) + } + }) + } +}