mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-12 17:05:01 +03:00
fix(xray): keep balancer overrides across routing reloads (#6779)
* fix(xray): keep balancer overrides across routing reloads A balancer override set from the panel lived only inside the running core. Every routing hot-apply (any Xray-settings save touching rules or balancers) and every node test through an outbound calls RoutingService.AddRule with shouldAppend=false, which rebuilds all balancers, so the override vanished silently even though the API docs promised it lasted until an Xray restart. The panel now records the overrides it sets, per core process, and re-applies them after its own routing reloads. Targets are validated against the running config: the core accepts any tag and then closes every connection routed to one it lacks, so an unknown target is refused, and an override whose outbound is removed is cleared instead of black-holing the balancer. Balancer targets resolve to their _bl_ loopback from the running config rather than the saved template. Restarting Xray still clears overrides, as documented. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KrJrzn9LKaPhMq9h3Fb5TV * fix(xray): serialize overrides and the node bridge with config applies Review of #6779 found two races. Neither path took the package lock that RestartXray holds while it changes the running core: - An override landing mid hot-apply was checked against the config snapshot the apply was replacing, so it could pin a balancer to an outbound the core had just removed. OverrideBalancer now takes the lock. - The node bridge's teardown checked overrides against the snapshot it took before the probe, which can run for seconds, so it dropped an override set meanwhile onto a newly saved outbound. The same stale snapshot drove the bridge's routing restore. That restore silently reverted rule changes saved during a probe. After a restart, it also pushed the old core's rules and overrides into the new core through the shared API port. The bridge is now built and torn down under the lock, which is not held while the probe runs. At teardown it restores the routing that the core's config holds then, and it leaves alone a core that was restarted meanwhile. With every caller under the package lock, balancerOverrideSet drops its own mutex.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user