diff --git a/frontend/src/pages/settings/GeneralTab.tsx b/frontend/src/pages/settings/GeneralTab.tsx index bcf2150ec..87817cf9a 100644 --- a/frontend/src/pages/settings/GeneralTab.tsx +++ b/frontend/src/pages/settings/GeneralTab.tsx @@ -43,7 +43,8 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp const [lang, setLang] = useState(() => LanguageManager.getLanguage()); const [inboundOptions, setInboundOptions] = useState<{ label: string; value: string }[]>([]); - const [outboundOptions, setOutboundOptions] = useState<{ label: string; value: string }[]>([]); + const [outboundTagList, setOutboundTagList] = useState([]); + const [balancerTagList, setBalancerTagList] = useState([]); 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; 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; + const balancers = Array.isArray(routing.balancers) ? routing.balancers : []; + for (const b of balancers) { + if (!b || typeof b !== 'object') continue; + const tag = (b as Record).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) : []; diff --git a/internal/web/service/xray.go b/internal/web/service/xray.go index 26897a2f7..0b4f507b0 100644 --- a/internal/web/service/xray.go +++ b/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. diff --git a/internal/web/service/xray_config_inject_test.go b/internal/web/service/xray_config_inject_test.go index bf763de44..3c6466817 100644 --- a/internal/web/service/xray_config_inject_test.go +++ b/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,