mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-31 15:37:14 +00:00
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.
This commit is contained in:
@@ -43,7 +43,8 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
|
|||||||
|
|
||||||
const [lang, setLang] = useState<string>(() => LanguageManager.getLanguage());
|
const [lang, setLang] = useState<string>(() => LanguageManager.getLanguage());
|
||||||
const [inboundOptions, setInboundOptions] = useState<{ label: string; value: string }[]>([]);
|
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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -69,9 +70,11 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
// Outbound tags for the panel egress picker: template outbounds plus
|
// Candidates for the panel egress picker: template outbounds plus
|
||||||
// subscription-derived outbounds, same candidate set as the geodata
|
// subscription-derived outbounds, and routing balancers. The panel egress
|
||||||
// download picker.
|
// 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>;
|
const msg = await HttpUtil.post('/panel/api/xray/', undefined, { silent: true }) as ApiMsg<string>;
|
||||||
if (cancelled || !msg?.success || typeof msg.obj !== 'string') return;
|
if (cancelled || !msg?.success || typeof msg.obj !== 'string') return;
|
||||||
try {
|
try {
|
||||||
@@ -90,14 +93,38 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
|
|||||||
for (const tag of subTags) {
|
for (const tag of subTags) {
|
||||||
if (typeof tag === 'string' && tag) tags.add(tag);
|
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 {
|
} catch {
|
||||||
setOutboundOptions([]);
|
setOutboundTagList([]);
|
||||||
|
setBalancerTagList([]);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
return () => { cancelled = true; };
|
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 ldapInboundTagList = useMemo(() => {
|
||||||
const csv = allSetting.ldapInboundTags || '';
|
const csv = allSetting.ldapInboundTags || '';
|
||||||
return csv.length ? csv.split(',').map((s) => s.trim()).filter(Boolean) : [];
|
return csv.length ? csv.split(',').map((s) => s.trim()).filter(Boolean) : [];
|
||||||
|
|||||||
@@ -317,9 +317,17 @@ func injectPanelEgress(cfg *xray.Config, outboundTag string) {
|
|||||||
}
|
}
|
||||||
rules, _ := routing["rules"].([]any)
|
rules, _ := routing["rules"].([]any)
|
||||||
rule := map[string]any{
|
rule := map[string]any{
|
||||||
"type": "field",
|
"type": "field",
|
||||||
"inboundTag": []any{PanelEgressInboundTag},
|
"inboundTag": []any{PanelEgressInboundTag},
|
||||||
"outboundTag": outboundTag,
|
}
|
||||||
|
// 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...)
|
routing["rules"] = append([]any{rule}, rules...)
|
||||||
newRouting, err := json.Marshal(routing)
|
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
|
// mergeSubscriptionOutbounds appends the subscription outbounds to the
|
||||||
// OutboundConfigs array of the xray config. It works on the already-unmarshaled
|
// OutboundConfigs array of the xray config. It works on the already-unmarshaled
|
||||||
// template so that manually configured outbounds are never overwritten.
|
// template so that manually configured outbounds are never overwritten.
|
||||||
|
|||||||
@@ -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) {
|
func TestInjectPanelEgress_PortCollision(t *testing.T) {
|
||||||
cfg := egressTestConfig()
|
cfg := egressTestConfig()
|
||||||
cfg.InboundConfigs = append(cfg.InboundConfigs,
|
cfg.InboundConfigs = append(cfg.InboundConfigs,
|
||||||
|
|||||||
Reference in New Issue
Block a user