From 56d5825cb51122074b46fe809d7eafc8fea0e0c0 Mon Sep 17 00:00:00 2001 From: Ssentiago Date: Fri, 26 Jun 2026 09:15:37 +0300 Subject: [PATCH] feat(balancer): add balancer-to-balancer fallback support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Xray does not natively support using a balancer as fallbackTag for another balancer. This feature automates the loopback workaround: when a user selects a balancer as fallback, the panel generates a loopback outbound + routing rule in the template. How it works: - User picks fallback balancer from dropdown - Panel creates loopback outbound _bl_{target} + routing rule - Balancer fallbackTag set to _bl_{target} - Traffic: Balancer A → loopback _bl_B → routing rule → Balancer B Key features: - Dedup: multiple balancers sharing same fallback reuse one loopback - DFS cycle detection at edit time and on save - Self-reference guard (cannot select own balancer) - Delete protection (blocks if used as fallback by others) - Cleans up routing rules referencing deleted balancers - Override resolves balancer tags through loopback mechanism - All live status tags resolved for display - Internal _bl_ objects filtered from Outbounds/Routing UI - Backward-compatible with old _bl_ naming format - Translations for all 13 locales --- frontend/src/pages/xray/XrayPage.tsx | 13 ++ .../xray/balancers/BalancerFormModal.tsx | 104 ++++++++- .../src/pages/xray/balancers/BalancersTab.tsx | 120 ++++++++++- .../pages/xray/balancers/balancer-loopback.ts | 198 ++++++++++++++++++ .../xray/outbounds/OutboundFormModal.tsx | 4 + .../src/pages/xray/outbounds/OutboundsTab.tsx | 9 +- .../src/pages/xray/routing/RoutingTab.tsx | 48 +++-- frontend/src/schemas/xray.ts | 11 +- .../src/test/balancer-form-modal.test.tsx | 3 + internal/web/service/xray.go | 50 ++++- internal/web/translation/ar-EG.json | 9 +- internal/web/translation/en-US.json | 8 +- internal/web/translation/es-ES.json | 9 +- internal/web/translation/fa-IR.json | 9 +- internal/web/translation/id-ID.json | 9 +- internal/web/translation/ja-JP.json | 9 +- internal/web/translation/pt-BR.json | 9 +- internal/web/translation/ru-RU.json | 9 +- internal/web/translation/tr-TR.json | 9 +- internal/web/translation/uk-UA.json | 9 +- internal/web/translation/vi-VN.json | 9 +- internal/web/translation/zh-CN.json | 9 +- internal/web/translation/zh-TW.json | 9 +- 23 files changed, 611 insertions(+), 65 deletions(-) create mode 100644 frontend/src/pages/xray/balancers/balancer-loopback.ts diff --git a/frontend/src/pages/xray/XrayPage.tsx b/frontend/src/pages/xray/XrayPage.tsx index 50770a086..ada6b5e46 100644 --- a/frontend/src/pages/xray/XrayPage.tsx +++ b/frontend/src/pages/xray/XrayPage.tsx @@ -30,6 +30,7 @@ import { propagateOutboundTagRename } from './basics/helpers'; import { RoutingTab } from './routing'; import { OutboundsTab } from './outbounds'; import { BalancersTab } from './balancers'; +import { cleanupOrphanedBalancerLoopbacks, ensureMissingBalancerLoopbacks, detectBalancerCycles } from './balancers/balancer-loopback'; import { DnsTab } from './dns'; import { WarpModal, NordModal } from './overrides'; import './XrayPage.css'; @@ -190,6 +191,18 @@ export default function XrayPage() { navigate('/xray#advanced'); return; } + if (templateSettings) { + const clone = JSON.parse(JSON.stringify(templateSettings)); + ensureMissingBalancerLoopbacks(clone); + cleanupOrphanedBalancerLoopbacks(clone); + const cycles = detectBalancerCycles(clone); + if (cycles.length > 0) { + const names = cycles.map((c) => c.join(' → ')).join(', '); + messageApi.error(t('pages.xray.balancer.balancerFallbackCycle') + ' (' + names + ')'); + return; + } + setTemplateSettings(clone); + } saveAll(); } diff --git a/frontend/src/pages/xray/balancers/BalancerFormModal.tsx b/frontend/src/pages/xray/balancers/BalancerFormModal.tsx index 93d6ff250..6a73d00e8 100644 --- a/frontend/src/pages/xray/balancers/BalancerFormModal.tsx +++ b/frontend/src/pages/xray/balancers/BalancerFormModal.tsx @@ -1,9 +1,10 @@ import { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Button, Form, Input, InputNumber, Modal, Select, Space, Switch } from 'antd'; +import { Alert, Button, Form, Input, InputNumber, Modal, Select, Space, Switch, Tag } from 'antd'; import { MinusOutlined, PlusOutlined } from '@ant-design/icons'; import { InputAddon } from '@/components/ui'; +import type { XraySettingsValue } from '@/hooks/useXraySetting'; import { BalancerFormSchema, type BalancerFormValues, @@ -12,7 +13,9 @@ import { BalancerStrategyTypeSchema, type BalancerStrategySettings, type BalancerStrategyType, + type BalancerObject, } from '@/schemas/routing'; +import { isBalancerLoopbackTag } from './balancer-loopback'; export type BalancerFormValue = BalancerFormValues; @@ -20,6 +23,9 @@ interface BalancerFormModalProps { open: boolean; balancer: BalancerFormValue | null; outboundTags: string[]; + balancerTags: string[]; + balancers: BalancerObject[]; + templateSettings: XraySettingsValue | null; otherTags: string[]; onClose: () => void; onConfirm: (value: BalancerFormValue) => void; @@ -62,6 +68,9 @@ export default function BalancerFormModal({ open, balancer, outboundTags, + balancerTags, + balancers, + templateSettings, otherTags, onClose, onConfirm, @@ -125,9 +134,79 @@ export default function BalancerFormModal({ const baselines = settings?.baselines ?? []; const costs = settings?.costs ?? []; - const fallbackOptions = useMemo( - () => ['', ...outboundTags].map((tg) => ({ value: tg, label: tg || `(${t('none')})` })), - [outboundTags, t], + const currentTag = state.tag.trim(); + + const availableBalancerTags = useMemo(() => { + return balancerTags.filter((tg) => tg !== currentTag); + }, [balancerTags, currentTag]); + + const cycleInfo = useMemo(() => { + const rules = (templateSettings?.routing?.rules || []) as Array<{ inboundTag?: string[]; balancerTag?: string }>; + const resolveLoopback = (tag: string): string | null => { + for (const r of rules) { + if (Array.isArray(r.inboundTag) && r.inboundTag.includes(tag) && r.balancerTag) { + return r.balancerTag; + } + } + return null; + }; + + const fallbackOf: Record = {}; + for (const b of balancers) { + if (!b.tag || !b.fallbackTag || b.tag === currentTag) continue; + const target = isBalancerLoopbackTag(b.fallbackTag) + ? resolveLoopback(b.fallbackTag) + : b.fallbackTag; + if (target) fallbackOf[b.tag] = target; + } + + const result: Record = {}; + for (const tg of availableBalancerTags) { + const visited = new Set(); + let cursor = tg; + const path = [tg]; + while (cursor && !visited.has(cursor)) { + if (cursor === currentTag) { + result[tg] = path; + break; + } + visited.add(cursor); + cursor = fallbackOf[cursor] || ''; + if (cursor) path.push(cursor); + } + } + return result; + }, [currentTag, balancers, availableBalancerTags, templateSettings?.routing?.rules]); + + const wouldCreateCycle = !!cycleInfo[state.fallbackTag]; + + const fallbackOptions = useMemo(() => { + const options: Array<{ value: string; label: React.ReactNode; disabled?: boolean; title?: string }> = [ + { value: '', label: `(${t('none')})` }, + ]; + for (const tg of outboundTags) { + options.push({ value: tg, label: tg }); + } + for (const tg of availableBalancerTags) { + const cycle = cycleInfo[tg]; + options.push({ + value: tg, + disabled: !!cycle, + title: cycle ? t('pages.xray.balancer.cycleTooltip', { path: cycle.join(' → '), start: currentTag }) : undefined, + label: ( + + {t('pages.xray.rules.balancer')} + {tg} + + ), + }); + } + return options; + }, [outboundTags, availableBalancerTags, cycleInfo, currentTag, t]); + + const isFallbackBalancer = useMemo( + () => balancerTags.includes(state.fallbackTag), + [balancerTags, state.fallbackTag], ); const title = isEdit @@ -141,6 +220,7 @@ export default function BalancerFormModal({ title={title} okText={okText} cancelText={t('close')} + okButtonProps={{ disabled: !parsed.success || duplicateTag || wouldCreateCycle }} mask={{ closable: false }} onOk={submit} onCancel={onClose} @@ -189,6 +269,22 @@ export default function BalancerFormModal({ options={fallbackOptions} /> + {isFallbackBalancer && !wouldCreateCycle && ( + + )} + {wouldCreateCycle && ( + + )} {state.strategy === 'leastLoad' && ( <> diff --git a/frontend/src/pages/xray/balancers/BalancersTab.tsx b/frontend/src/pages/xray/balancers/BalancersTab.tsx index 001e986ea..102641e15 100644 --- a/frontend/src/pages/xray/balancers/BalancersTab.tsx +++ b/frontend/src/pages/xray/balancers/BalancersTab.tsx @@ -1,12 +1,21 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Button, Dropdown, Empty, Modal, Select, Space, Table, Tabs, Tag, Tooltip } from 'antd'; +import { Button, Dropdown, Empty, Modal, Select, Space, Table, Tabs, Tag, Tooltip, message } from 'antd'; import { PlusOutlined, MoreOutlined, EditOutlined, DeleteOutlined, SyncOutlined, DeploymentUnitOutlined, RadarChartOutlined } from '@ant-design/icons'; import type { ColumnsType } from 'antd/es/table'; import BalancerFormModal from './BalancerFormModal'; import type { BalancerFormValue } from './BalancerFormModal'; import { syncObservatories, observersRemovedByDeletingBalancer } from './balancer-helpers'; +import { + isBalancerLoopbackTag, + loopbackTagFor, + resolveLoopbackFallback, + ensureBalancerLoopback, + removeBalancerLoopback, + removeBalancerLoopbackIfOrphaned, + propagateBalancerTagRename, +} from './balancer-loopback'; import ObservatorySettingsTab from './ObservatorySettingsTab'; import { catTabLabel } from '@/pages/settings/catTabLabel'; import { HttpUtil } from '@/utils'; @@ -42,6 +51,7 @@ interface BalancerRow { strategy: BalancerStrategyType; selector: string[]; fallbackTag: string; + displayFallbackTag: string; settings?: BalancerStrategySettings; } @@ -61,26 +71,33 @@ export default function BalancersTab({ }: BalancersTabProps) { const { t } = useTranslation(); const [modal, modalContextHolder] = Modal.useModal(); + const [messageApi, messageContextHolder] = message.useMessage(); const [modalOpen, setModalOpen] = useState(false); const [editingBalancer, setEditingBalancer] = useState(null); const [editingIndex, setEditingIndex] = useState(null); + const balancerObjects = useMemo( + () => (templateSettings?.routing?.balancers || []) as BalancerObject[], + [templateSettings?.routing?.balancers], + ); + const rows: BalancerRow[] = useMemo(() => { - const list = (templateSettings?.routing?.balancers || []) as BalancerRecord[]; + const list = balancerObjects; return list.map((b, idx) => ({ key: idx, tag: b.tag || '', strategy: (b.strategy?.type ?? 'random') as BalancerStrategyType, selector: b.selector || [], fallbackTag: b.fallbackTag || '', + displayFallbackTag: resolveLoopbackFallback(templateSettings!, b.fallbackTag || ''), settings: b.strategy?.settings, })); - }, [templateSettings?.routing?.balancers]); + }, [balancerObjects, templateSettings]); const outboundTags = useMemo(() => { const tags = new Set(); for (const o of templateSettings?.outbounds || []) { - if (o?.tag) tags.add(o.tag); + if (o?.tag && !isBalancerLoopbackTag(o.tag)) tags.add(o.tag); } for (const tag of clientReverseTags || []) { if (tag) tags.add(tag); @@ -96,6 +113,14 @@ export default function BalancersTab({ return rows.filter((b) => b.key !== editingIndex).map((b) => b.tag).filter(Boolean); }, [rows, editingIndex]); + const balancerTags = useMemo(() => { + return otherTags.filter((tg) => !isBalancerLoopbackTag(tg)); + }, [otherTags]); + + const overrideOptions: Array<{ value: string; label: React.ReactNode }> = useMemo(() => { + return outboundTags.map((tag) => ({ value: tag, label: tag })); + }, [outboundTags]); + const mutate = useCallback( (mutator: (next: XraySettingsValue) => void) => { setTemplateSettings((prev) => { @@ -146,7 +171,12 @@ export default function BalancersTab({ setModalOpen(true); } function openEdit(idx: number) { - setEditingBalancer(rows[idx]); + const row = rows[idx]; + const resolved: BalancerFormValue = { + ...row, + fallbackTag: resolveLoopbackFallback(templateSettings!, row.fallbackTag), + }; + setEditingBalancer(resolved); setEditingIndex(idx); setModalOpen(true); } @@ -156,10 +186,11 @@ export default function BalancersTab({ if (!tt.routing) tt.routing = { rules: [], balancers: [] }; if (!Array.isArray(tt.routing.balancers)) tt.routing.balancers = []; const list = tt.routing.balancers as BalancerRecord[]; + const wire: BalancerRecord = { tag: form.tag, selector: [...form.selector], - fallbackTag: form.fallbackTag || '', + fallbackTag: '', }; if (form.strategy && form.strategy !== 'random') { wire.strategy = { type: form.strategy }; @@ -167,16 +198,42 @@ export default function BalancersTab({ wire.strategy.settings = form.settings; } } + + const isFallbackABalancer = form.fallbackTag && balancerTags.includes(form.fallbackTag); + + if (isFallbackABalancer) { + wire.fallbackTag = loopbackTagFor(form.fallbackTag); + } else { + wire.fallbackTag = form.fallbackTag || ''; + } + if (editingIndex == null) { list.push(wire); + if (isFallbackABalancer) { + ensureBalancerLoopback(tt, form.fallbackTag); + } } else { const oldTag = list[editingIndex]?.tag; + const oldFallback = list[editingIndex]?.fallbackTag || ''; list[editingIndex] = wire; + if (oldTag && oldTag !== wire.tag) { const rules = tt.routing.rules || []; for (const rule of rules) { if (rule?.balancerTag === oldTag) rule.balancerTag = wire.tag; } + propagateBalancerTagRename(tt, oldTag, wire.tag); + } + + const oldTarget = isBalancerLoopbackTag(oldFallback) + ? (oldFallback.slice(4)) + : null; + + if (oldTarget && oldTarget !== form.fallbackTag) { + removeBalancerLoopbackIfOrphaned(tt, oldTarget); + } + if (isFallbackABalancer) { + ensureBalancerLoopback(tt, form.fallbackTag); } } syncObservatories(tt); @@ -185,6 +242,15 @@ export default function BalancersTab({ } function confirmDelete(idx: number) { + const deletedTag = rows[idx]?.tag; + const lbTag = loopbackTagFor(deletedTag); + const dependents = (templateSettings?.routing?.balancers || []) + .filter((b) => b.tag !== deletedTag && b.fallbackTag === lbTag) + .map((b) => b.tag); + if (dependents.length > 0) { + messageApi.error(t('pages.xray.balancer.balancerDeleteInUse', { names: dependents.join(', ') })); + return; + } const removed = templateSettings ? observersRemovedByDeletingBalancer(templateSettings, idx) : { observatory: false, burst: false }; @@ -199,7 +265,14 @@ export default function BalancersTab({ cancelText: t('cancel'), onOk: () => mutate((tt) => { if (tt.routing?.balancers) { + const deletedTag = tt.routing.balancers[idx]?.tag; + removeBalancerLoopback(tt, deletedTag); tt.routing.balancers.splice(idx, 1); + if (tt.routing?.rules) { + tt.routing.rules = tt.routing.rules.filter( + (r) => r?.balancerTag !== deletedTag, + ); + } syncObservatories(tt); } }), @@ -278,7 +351,7 @@ export default function BalancersTab({ )), }, - { title: 'Fallback', dataIndex: 'fallbackTag', key: 'fallbackTag', align: 'center', width: 160 }, + { title: 'Fallback', dataIndex: 'displayFallbackTag', key: 'displayFallbackTag', align: 'center', width: 160 }, { title: t('pages.xray.balancerLive'), key: 'live', @@ -293,9 +366,13 @@ export default function BalancersTab({ ); } - const picked = live.override || live.selected?.[0] || record.fallbackTag; + const resolve = (tag: string) => isBalancerLoopbackTag(tag) ? resolveLoopbackFallback(templateSettings!, tag) : tag; + const picked = live.override ? resolve(live.override) : live.selected?.[0] ? resolve(live.selected[0]) : record.displayFallbackTag; + const tooltipText = live.override + ? resolve(live.override) + : (live.selected || []).map(resolve).join(', '); return ( - + {picked || '—'} ); @@ -308,6 +385,23 @@ export default function BalancersTab({ width: 200, render: (_v, record) => { const live = liveStatus[record.tag]; + const resolvedFB = record.displayFallbackTag; + let options = overrideOptions; + if (resolvedFB && !outboundTags.includes(resolvedFB)) { + options = [...overrideOptions, { + value: resolvedFB, + label: ( + + {t('pages.xray.rules.balancer')} + {resolvedFB} + + ), + }]; + } + const rawOverride = live?.override || undefined; + const resolvedOverride = rawOverride && isBalancerLoopbackTag(rawOverride) + ? resolveLoopbackFallback(templateSettings!, rawOverride) + : rawOverride; return (