Forráskód Böngészése

feat(settings): allow a balancer as the panel traffic outbound

The panel egress is injected as a routing rule, so a routing balancer is
a valid target for it (unlike the geodata download, which dials a forced
outbound tag and bypasses the router). Surface routing balancers in the
panel outbound picker as a separate group, and emit balancerTag instead
of outboundTag in the injected egress rule when the configured tag names
a balancer, so the panel's own traffic load-balances across its members.
MHSanaei 17 órája
szülő
commit
8578b229ce

+ 33 - 6
frontend/src/pages/settings/GeneralTab.tsx

@@ -43,7 +43,8 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
 
   const [lang, setLang] = useState<string>(() => LanguageManager.getLanguage());
   const [inboundOptions, setInboundOptions] = useState<{ label: string; value: string }[]>([]);
-  const [outboundOptions, setOutboundOptions] = useState<{ label: string; value: string }[]>([]);
+  const [outboundTagList, setOutboundTagList] = useState<string[]>([]);
+  const [balancerTagList, setBalancerTagList] = useState<string[]>([]);
 
   useEffect(() => {
     let cancelled = false;
@@ -69,9 +70,11 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
   useEffect(() => {
     let cancelled = false;
     (async () => {
-      // Outbound tags for the panel egress picker: template outbounds plus
-      // subscription-derived outbounds, same candidate set as the geodata
-      // download picker.
+      // Candidates for the panel egress picker: template outbounds plus
+      // subscription-derived outbounds, and routing balancers. The panel egress
+      // is injected as a routing rule, so a balancer tag is a valid target
+      // (it load-balances the panel's own traffic). The geodata picker, by
+      // contrast, dials a forced tag and can only use a concrete outbound.
       const msg = await HttpUtil.post('/panel/api/xray/', undefined, { silent: true }) as ApiMsg<string>;
       if (cancelled || !msg?.success || typeof msg.obj !== 'string') return;
       try {
@@ -90,14 +93,38 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
         for (const tag of subTags) {
           if (typeof tag === 'string' && tag) tags.add(tag);
         }
-        setOutboundOptions([...tags].map((tag) => ({ label: tag, value: tag })));
+        const balancerTags: string[] = [];
+        const routing = (template.routing || {}) as Record<string, unknown>;
+        const balancers = Array.isArray(routing.balancers) ? routing.balancers : [];
+        for (const b of balancers) {
+          if (!b || typeof b !== 'object') continue;
+          const tag = (b as Record<string, unknown>).tag;
+          if (typeof tag === 'string' && tag && !tags.has(tag)) balancerTags.push(tag);
+        }
+        setOutboundTagList([...tags]);
+        setBalancerTagList(balancerTags);
       } catch {
-        setOutboundOptions([]);
+        setOutboundTagList([]);
+        setBalancerTagList([]);
       }
     })();
     return () => { cancelled = true; };
   }, []);
 
+  // Outbound tags and balancer tags share one picker. When balancers exist they
+  // get their own labeled group so it's clear the selection routes through a
+  // balancer rather than a single outbound.
+  const outboundOptions = useMemo<
+    ({ label: string; value: string } | { label: string; options: { label: string; value: string }[] })[]
+  >(() => {
+    const outOpts = outboundTagList.map((tag) => ({ label: tag, value: tag }));
+    if (balancerTagList.length === 0) return outOpts;
+    return [
+      { label: t('pages.xray.Outbounds'), options: outOpts },
+      { label: t('pages.xray.Balancers'), options: balancerTagList.map((tag) => ({ label: tag, value: tag })) },
+    ];
+  }, [outboundTagList, balancerTagList, t]);
+
   const ldapInboundTagList = useMemo(() => {
     const csv = allSetting.ldapInboundTags || '';
     return csv.length ? csv.split(',').map((s) => s.trim()).filter(Boolean) : [];

+ 34 - 3
internal/web/service/xray.go

@@ -317,9 +317,17 @@ func injectPanelEgress(cfg *xray.Config, outboundTag string) {
 	}
 	rules, _ := routing["rules"].([]any)
 	rule := map[string]any{
-		"type":        "field",
-		"inboundTag":  []any{PanelEgressInboundTag},
-		"outboundTag": outboundTag,
+		"type":       "field",
+		"inboundTag": []any{PanelEgressInboundTag},
+	}
+	// The configured tag may name a routing balancer instead of a concrete
+	// outbound. A field rule can target either, so emit the matching key —
+	// balancerTag load-balances the panel's own traffic across the balancer's
+	// outbounds, while a plain outbound tag keeps the original behavior.
+	if routingTagIsBalancer(routing, outboundTag) {
+		rule["balancerTag"] = outboundTag
+	} else {
+		rule["outboundTag"] = outboundTag
 	}
 	routing["rules"] = append([]any{rule}, rules...)
 	newRouting, err := json.Marshal(routing)
@@ -350,6 +358,29 @@ func injectPanelEgress(cfg *xray.Config, outboundTag string) {
 	})
 }
 
+// routingTagIsBalancer reports whether tag names a balancer in the parsed
+// routing section. The panel-egress rule targets a balancer via balancerTag and
+// a concrete outbound via outboundTag, so the caller picks the key from this.
+func routingTagIsBalancer(routing map[string]any, tag string) bool {
+	if tag == "" {
+		return false
+	}
+	balancers, ok := routing["balancers"].([]any)
+	if !ok {
+		return false
+	}
+	for _, b := range balancers {
+		bm, ok := b.(map[string]any)
+		if !ok {
+			continue
+		}
+		if t, ok := bm["tag"].(string); ok && t == tag {
+			return true
+		}
+	}
+	return false
+}
+
 // mergeSubscriptionOutbounds appends the subscription outbounds to the
 // OutboundConfigs array of the xray config. It works on the already-unmarshaled
 // template so that manually configured outbounds are never overwritten.

+ 49 - 0
internal/web/service/xray_config_inject_test.go

@@ -169,6 +169,55 @@ func TestInjectPanelEgress(t *testing.T) {
 	}
 }
 
+func TestInjectPanelEgress_BalancerTag(t *testing.T) {
+	cfg := egressTestConfig()
+	cfg.RouterConfig = json_util.RawMessage(`{"domainStrategy":"AsIs","rules":[],"balancers":[{"tag":"lb","selector":["warp"]}]}`)
+
+	// A tag that names a balancer must be targeted via balancerTag so the
+	// router resolves it; an outbound tag coexisting with balancers still uses
+	// outboundTag.
+	injectPanelEgress(cfg, "lb")
+
+	var routing struct {
+		Rules []struct {
+			InboundTag  []string `json:"inboundTag"`
+			OutboundTag string   `json:"outboundTag"`
+			BalancerTag string   `json:"balancerTag"`
+			Type        string   `json:"type"`
+		} `json:"rules"`
+	}
+	if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
+		t.Fatal(err)
+	}
+	if len(routing.Rules) != 1 {
+		t.Fatalf("expected the egress rule, got %+v", routing.Rules)
+	}
+	first := routing.Rules[0]
+	if first.BalancerTag != "lb" || first.OutboundTag != "" {
+		t.Fatalf("a balancer tag must target balancerTag, not outboundTag, got %+v", first)
+	}
+	if len(first.InboundTag) != 1 || first.InboundTag[0] != PanelEgressInboundTag {
+		t.Fatalf("egress rule must bind the egress inbound, got %+v", first)
+	}
+
+	// A non-balancer tag alongside balancers keeps the plain outbound path.
+	cfg2 := egressTestConfig()
+	cfg2.RouterConfig = json_util.RawMessage(`{"rules":[],"balancers":[{"tag":"lb","selector":["warp"]}]}`)
+	injectPanelEgress(cfg2, "warp")
+	var routing2 struct {
+		Rules []struct {
+			OutboundTag string `json:"outboundTag"`
+			BalancerTag string `json:"balancerTag"`
+		} `json:"rules"`
+	}
+	if err := json.Unmarshal(cfg2.RouterConfig, &routing2); err != nil {
+		t.Fatal(err)
+	}
+	if routing2.Rules[0].OutboundTag != "warp" || routing2.Rules[0].BalancerTag != "" {
+		t.Fatalf("a concrete outbound must target outboundTag, got %+v", routing2.Rules[0])
+	}
+}
+
 func TestInjectPanelEgress_PortCollision(t *testing.T) {
 	cfg := egressTestConfig()
 	cfg.InboundConfigs = append(cfg.InboundConfigs,