mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-09 22:26:09 +00:00
feat(balancer): add balancer-to-balancer fallback support (#5586)
* feat(balancer): add balancer-to-balancer fallback support
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
* fix(review): override regression, save payload sync, i18n completeness
- OverrideBalancer: only resolve to loopback when resolution succeeds,
pass original target through for plain outbound tags
- onSaveAll: serialize cleaned template before save to ensure the
healed/cleaned config is what gets persisted
- Add reservedPrefix translation key to all 12 non-English locales
- Restore trailing newlines in all 13 translation JSON files
* fix(test): update balancer form modal tests after cycle-detection guard
The okButtonProps disabled guard (added in 56d5825c) prevents the modal
from firing onOk when the form is invalid. The old tests clicked the
button expecting validation errors to appear, but antd Modal never calls
onOk on a disabled button — causing false failures.
Rewrite to test the actual guard behavior:
- Button starts disabled (empty form)
- Stays disabled with tag only (selector still empty)
- Stays disabled for duplicate tag
- Disabled button does not trigger onConfirm
---------
Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
This commit is contained in:
@@ -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,20 @@ 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;
|
||||
}
|
||||
const serialized = JSON.stringify(clone, null, 2);
|
||||
setXraySetting(serialized);
|
||||
setTemplateSettings(clone);
|
||||
}
|
||||
saveAll();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { ReactNode } 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 { Controller, FormProvider, useForm, useWatch } from 'react-hook-form';
|
||||
import type { Path } from 'react-hook-form';
|
||||
|
||||
import { InputAddon } from '@/components/ui';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
import type { XraySettingsValue } from '@/hooks/useXraySetting';
|
||||
import {
|
||||
BalancerFormSchema,
|
||||
type BalancerFormValues,
|
||||
@@ -14,7 +16,9 @@ import {
|
||||
import {
|
||||
BalancerStrategyTypeSchema,
|
||||
type BalancerStrategyType,
|
||||
type BalancerObject,
|
||||
} from '@/schemas/routing';
|
||||
import { isBalancerLoopbackTag } from './balancer-loopback';
|
||||
|
||||
export type BalancerFormValue = BalancerFormValues;
|
||||
|
||||
@@ -22,6 +26,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;
|
||||
@@ -56,6 +63,9 @@ export default function BalancerFormModal({
|
||||
open,
|
||||
balancer,
|
||||
outboundTags,
|
||||
balancerTags,
|
||||
balancers,
|
||||
templateSettings,
|
||||
otherTags,
|
||||
onClose,
|
||||
onConfirm,
|
||||
@@ -75,6 +85,83 @@ export default function BalancerFormModal({
|
||||
const strategy = useWatch({ control: methods.control, name: 'strategy' });
|
||||
const baselines = useWatch({ control: methods.control, name: 'settings.baselines' }) ?? [];
|
||||
const costs = useWatch({ control: methods.control, name: 'settings.costs' }) ?? [];
|
||||
const tagValue = useWatch({ control: methods.control, name: 'tag' }) ?? '';
|
||||
const fallbackTag = useWatch({ control: methods.control, name: 'fallbackTag' }) ?? '';
|
||||
const currentTag = tagValue.trim();
|
||||
|
||||
const availableBalancerTags = useMemo(
|
||||
() => 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<string, string> = {};
|
||||
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<string, string[]> = {};
|
||||
for (const tg of availableBalancerTags) {
|
||||
const visited = new Set<string>();
|
||||
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[fallbackTag];
|
||||
|
||||
const fallbackOptions = useMemo(() => {
|
||||
const options: Array<{ value: string; label: 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: (
|
||||
<span>
|
||||
<Tag color="blue" style={{ marginRight: 4 }}>{t('pages.xray.rules.balancer')}</Tag>
|
||||
{tg}
|
||||
</span>
|
||||
),
|
||||
});
|
||||
}
|
||||
return options;
|
||||
}, [outboundTags, availableBalancerTags, cycleInfo, currentTag, t]);
|
||||
|
||||
const isFallbackBalancer = useMemo(
|
||||
() => balancerTags.includes(fallbackTag),
|
||||
[balancerTags, fallbackTag],
|
||||
);
|
||||
|
||||
function submit() {
|
||||
const values = methods.getValues();
|
||||
@@ -92,7 +179,7 @@ export default function BalancerFormModal({
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!parsed.success || duplicateTag) {
|
||||
if (!parsed.success || duplicateTag || wouldCreateCycle) {
|
||||
setSubmitAttempted(true);
|
||||
return;
|
||||
}
|
||||
@@ -101,11 +188,6 @@ export default function BalancerFormModal({
|
||||
onConfirm(result);
|
||||
}
|
||||
|
||||
const fallbackOptions = useMemo(
|
||||
() => ['', ...outboundTags].map((tg) => ({ value: tg, label: tg || `(${t('none')})` })),
|
||||
[outboundTags, t],
|
||||
);
|
||||
|
||||
const title = isEdit
|
||||
? `${t('edit')} ${t('pages.xray.Balancers')}`
|
||||
: `+ ${t('pages.xray.Balancers')}`;
|
||||
@@ -117,6 +199,7 @@ export default function BalancerFormModal({
|
||||
title={title}
|
||||
okText={okText}
|
||||
cancelText={t('close')}
|
||||
okButtonProps={{ disabled: wouldCreateCycle }}
|
||||
mask={{ closable: false }}
|
||||
onOk={submit}
|
||||
onCancel={onClose}
|
||||
@@ -165,10 +248,27 @@ export default function BalancerFormModal({
|
||||
<FormField
|
||||
name="fallbackTag"
|
||||
label={t('pages.xray.balancer.fallback')}
|
||||
extra={t('pages.xray.balancer.fallbackBalancerHint')}
|
||||
transform={{ output: (v) => v ?? '' }}
|
||||
>
|
||||
<Select allowClear options={fallbackOptions} />
|
||||
</FormField>
|
||||
{isFallbackBalancer && !wouldCreateCycle && (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message={t('pages.xray.balancer.balancerFallbackInfo')}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
)}
|
||||
{wouldCreateCycle && (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message={t('pages.xray.balancer.balancerFallbackCycle')}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{strategy === 'leastLoad' && (
|
||||
<>
|
||||
|
||||
@@ -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 } from './balancer-helpers';
|
||||
import {
|
||||
isBalancerLoopbackTag,
|
||||
loopbackTagFor,
|
||||
resolveLoopbackFallback,
|
||||
ensureBalancerLoopback,
|
||||
removeBalancerLoopback,
|
||||
removeBalancerLoopbackIfOrphaned,
|
||||
propagateBalancerTagRename,
|
||||
} from './balancer-loopback';
|
||||
import { planBalancerDeletion, applyBalancerDeletion } from '../reference-cleanup';
|
||||
import DeletionImpactList from '../DeletionImpactList';
|
||||
import ObservatorySettingsTab from './ObservatorySettingsTab';
|
||||
@@ -44,6 +53,7 @@ interface BalancerRow {
|
||||
strategy: BalancerStrategyType;
|
||||
selector: string[];
|
||||
fallbackTag: string;
|
||||
displayFallbackTag: string;
|
||||
settings?: BalancerStrategySettings;
|
||||
}
|
||||
|
||||
@@ -63,26 +73,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<BalancerFormValue | null>(null);
|
||||
const [editingIndex, setEditingIndex] = useState<number | null>(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<string>();
|
||||
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);
|
||||
@@ -98,6 +115,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) => {
|
||||
@@ -148,7 +173,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);
|
||||
}
|
||||
@@ -158,10 +188,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 };
|
||||
@@ -169,16 +200,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);
|
||||
@@ -187,6 +244,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 impact = templateSettings
|
||||
? planBalancerDeletion(templateSettings, idx)
|
||||
: { rules: [], balancers: [], observatory: false, burst: false };
|
||||
@@ -196,7 +262,11 @@ export default function BalancersTab({
|
||||
okText: t('delete'),
|
||||
okType: 'danger',
|
||||
cancelText: t('cancel'),
|
||||
onOk: () => mutate((tt) => applyBalancerDeletion(tt, idx)),
|
||||
onOk: () => mutate((tt) => {
|
||||
const tag = tt.routing?.balancers?.[idx]?.tag ?? '';
|
||||
removeBalancerLoopback(tt, tag);
|
||||
applyBalancerDeletion(tt, idx);
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -272,7 +342,7 @@ export default function BalancersTab({
|
||||
</Tag>
|
||||
)),
|
||||
},
|
||||
{ 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',
|
||||
@@ -287,9 +357,13 @@ export default function BalancersTab({
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<Tooltip title={(live.selected || []).join(', ') || undefined}>
|
||||
<Tooltip title={tooltipText || undefined}>
|
||||
<Tag color={live.override ? 'orange' : 'blue'}>{picked || '—'}</Tag>
|
||||
</Tooltip>
|
||||
);
|
||||
@@ -302,6 +376,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: (
|
||||
<span>
|
||||
<Tag color="blue" style={{ marginRight: 4 }}>{t('pages.xray.rules.balancer')}</Tag>
|
||||
{resolvedFB}
|
||||
</span>
|
||||
),
|
||||
}];
|
||||
}
|
||||
const rawOverride = live?.override || undefined;
|
||||
const resolvedOverride = rawOverride && isBalancerLoopbackTag(rawOverride)
|
||||
? resolveLoopbackFallback(templateSettings!, rawOverride)
|
||||
: rawOverride;
|
||||
return (
|
||||
<Select
|
||||
size="small"
|
||||
@@ -309,8 +400,8 @@ export default function BalancersTab({
|
||||
placeholder={t('pages.xray.balancerOverridePh')}
|
||||
allowClear
|
||||
disabled={!live?.running}
|
||||
value={live?.override || undefined}
|
||||
options={outboundTags.map((tag) => ({ label: tag, value: tag }))}
|
||||
value={resolvedOverride}
|
||||
options={options}
|
||||
onChange={(v) => setOverride(record.tag, (v as string | undefined) || '')}
|
||||
/>
|
||||
);
|
||||
@@ -353,6 +444,7 @@ export default function BalancersTab({
|
||||
return (
|
||||
<>
|
||||
{modalContextHolder}
|
||||
{messageContextHolder}
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
@@ -378,6 +470,9 @@ export default function BalancersTab({
|
||||
open={modalOpen}
|
||||
balancer={editingBalancer}
|
||||
outboundTags={outboundTags}
|
||||
balancerTags={balancerTags}
|
||||
balancers={balancerObjects}
|
||||
templateSettings={templateSettings}
|
||||
otherTags={otherTags}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onConfirm={onConfirm}
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import type { XraySettingsValue } from '@/hooks/useXraySetting';
|
||||
|
||||
const LOOPBACK_PREFIX = '_bl_';
|
||||
|
||||
export function isBalancerLoopbackTag(tag: string): boolean {
|
||||
return tag.startsWith(LOOPBACK_PREFIX);
|
||||
}
|
||||
|
||||
export function loopbackTagFor(targetBalancerTag: string): string {
|
||||
return LOOPBACK_PREFIX + targetBalancerTag;
|
||||
}
|
||||
|
||||
export function balancerTagFromLoopback(loopbackTag: string): string | null {
|
||||
if (!isBalancerLoopbackTag(loopbackTag)) return null;
|
||||
return loopbackTag.slice(LOOPBACK_PREFIX.length);
|
||||
}
|
||||
|
||||
function loopbackMatchesTarget(loopbackTag: string, targetTag: string): boolean {
|
||||
if (!isBalancerLoopbackTag(loopbackTag)) return false;
|
||||
const target = balancerTagFromLoopback(loopbackTag);
|
||||
return target === targetTag;
|
||||
}
|
||||
|
||||
function findLoopbackTarget(settings: XraySettingsValue, loopbackTag: string): string | null {
|
||||
const rules = (settings.routing?.rules || []) as Array<{ inboundTag?: string[]; balancerTag?: string }>;
|
||||
for (const r of rules) {
|
||||
if (Array.isArray(r.inboundTag) && r.inboundTag.includes(loopbackTag) && r.balancerTag) {
|
||||
return r.balancerTag;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveLoopbackFallback(
|
||||
settings: XraySettingsValue,
|
||||
fallbackTag: string,
|
||||
): string {
|
||||
if (!fallbackTag || !isBalancerLoopbackTag(fallbackTag)) return fallbackTag;
|
||||
const target = findLoopbackTarget(settings, fallbackTag);
|
||||
if (target) return target;
|
||||
const targetTag = balancerTagFromLoopback(fallbackTag);
|
||||
return targetTag || fallbackTag;
|
||||
}
|
||||
|
||||
function countLoopbackRefs(settings: XraySettingsValue, targetTag: string): number {
|
||||
let count = 0;
|
||||
for (const b of (settings.routing?.balancers || []) as Array<{ fallbackTag?: string }>) {
|
||||
if (b.fallbackTag && isBalancerLoopbackTag(b.fallbackTag) && loopbackMatchesTarget(b.fallbackTag, targetTag)) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
export function ensureBalancerLoopback(
|
||||
settings: XraySettingsValue,
|
||||
targetBalancerTag: string,
|
||||
): void {
|
||||
const lbTag = loopbackTagFor(targetBalancerTag);
|
||||
|
||||
if (!Array.isArray(settings.outbounds)) settings.outbounds = [];
|
||||
const existingIdx = (settings.outbounds as Array<{ tag?: string; protocol?: string }>).findIndex(
|
||||
(o) => o.tag === lbTag,
|
||||
);
|
||||
const newOutbound = { tag: lbTag, protocol: 'loopback', settings: { inboundTag: lbTag } };
|
||||
if (existingIdx >= 0) {
|
||||
(settings.outbounds as Record<string, unknown>[])[existingIdx] = newOutbound;
|
||||
} else {
|
||||
(settings.outbounds as Record<string, unknown>[]).push(newOutbound);
|
||||
}
|
||||
|
||||
if (!settings.routing) settings.routing = { rules: [], balancers: [] };
|
||||
if (!Array.isArray(settings.routing.rules)) settings.routing.rules = [];
|
||||
|
||||
const existingRuleIdx = (settings.routing.rules as Array<{ inboundTag?: string[] }>).findIndex(
|
||||
(r) => Array.isArray(r.inboundTag) && r.inboundTag.includes(lbTag),
|
||||
);
|
||||
if (existingRuleIdx >= 0) {
|
||||
(settings.routing.rules as Record<string, unknown>[])[existingRuleIdx].balancerTag = targetBalancerTag;
|
||||
} else {
|
||||
(settings.routing.rules as Record<string, unknown>[]).push({
|
||||
type: 'field',
|
||||
inboundTag: [lbTag],
|
||||
balancerTag: targetBalancerTag,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function removeBalancerLoopbackIfOrphaned(
|
||||
settings: XraySettingsValue,
|
||||
targetBalancerTag: string,
|
||||
): void {
|
||||
if (countLoopbackRefs(settings, targetBalancerTag) > 0) return;
|
||||
removeBalancerLoopback(settings, targetBalancerTag);
|
||||
}
|
||||
|
||||
export function removeBalancerLoopback(
|
||||
settings: XraySettingsValue,
|
||||
targetBalancerTag: string,
|
||||
): void {
|
||||
const lbTag = loopbackTagFor(targetBalancerTag);
|
||||
|
||||
if (Array.isArray(settings.outbounds)) {
|
||||
settings.outbounds = (settings.outbounds as Array<{ tag?: string }>).filter(
|
||||
(o) => o.tag !== lbTag,
|
||||
) as XraySettingsValue['outbounds'];
|
||||
}
|
||||
|
||||
if (settings.routing && Array.isArray(settings.routing.rules)) {
|
||||
settings.routing.rules = settings.routing.rules.filter(
|
||||
(r) => !(Array.isArray(r.inboundTag) && r.inboundTag.includes(lbTag)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function propagateBalancerTagRename(
|
||||
settings: XraySettingsValue,
|
||||
oldTag: string,
|
||||
newTag: string,
|
||||
): void {
|
||||
const oldLbTag = loopbackTagFor(oldTag);
|
||||
const newLbTag = loopbackTagFor(newTag);
|
||||
|
||||
if (Array.isArray(settings.outbounds)) {
|
||||
for (const o of settings.outbounds as Array<{ tag?: string; settings?: { inboundTag?: string } }>) {
|
||||
if (o.tag === oldLbTag) o.tag = newLbTag;
|
||||
if (o.settings?.inboundTag === oldLbTag) o.settings.inboundTag = newLbTag;
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.routing && Array.isArray(settings.routing.rules)) {
|
||||
for (const r of settings.routing.rules as Array<{ inboundTag?: string[] }>) {
|
||||
if (Array.isArray(r.inboundTag)) {
|
||||
const idx = r.inboundTag.indexOf(oldLbTag);
|
||||
if (idx !== -1) r.inboundTag[idx] = newLbTag;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.routing && Array.isArray(settings.routing.balancers)) {
|
||||
for (const b of settings.routing.balancers as Array<{ tag?: string; fallbackTag?: string }>) {
|
||||
if (b.fallbackTag === oldLbTag) b.fallbackTag = newLbTag;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function detectBalancerCycles(settings: XraySettingsValue): string[][] {
|
||||
const balancers = (settings.routing?.balancers || []) as Array<{ tag?: string; fallbackTag?: string }>;
|
||||
const cycles: string[][] = [];
|
||||
|
||||
for (const b of balancers) {
|
||||
if (!b.tag || !b.fallbackTag || !isBalancerLoopbackTag(b.fallbackTag)) continue;
|
||||
const targetTag = balancerTagFromLoopback(b.fallbackTag);
|
||||
if (!targetTag) continue;
|
||||
|
||||
const visited = new Set<string>();
|
||||
let cursor = targetTag;
|
||||
while (cursor && !visited.has(cursor)) {
|
||||
if (cursor === b.tag) {
|
||||
cycles.push([b.tag, targetTag]);
|
||||
break;
|
||||
}
|
||||
visited.add(cursor);
|
||||
const next = balancers.find((x) => x.tag === cursor);
|
||||
const fb = next?.fallbackTag;
|
||||
if (!fb || !isBalancerLoopbackTag(fb)) break;
|
||||
cursor = balancerTagFromLoopback(fb) || '';
|
||||
}
|
||||
}
|
||||
return cycles;
|
||||
}
|
||||
|
||||
export function ensureMissingBalancerLoopbacks(settings: XraySettingsValue): void {
|
||||
const balancers = (settings.routing?.balancers || []) as Array<{ tag?: string; fallbackTag?: string }>;
|
||||
for (const b of balancers) {
|
||||
if (!b.fallbackTag || !isBalancerLoopbackTag(b.fallbackTag)) continue;
|
||||
const targetTag = balancerTagFromLoopback(b.fallbackTag);
|
||||
if (!targetTag) continue;
|
||||
ensureBalancerLoopback(settings, targetTag);
|
||||
}
|
||||
}
|
||||
|
||||
export function cleanupOrphanedBalancerLoopbacks(settings: XraySettingsValue): void {
|
||||
if (!Array.isArray(settings.outbounds)) return;
|
||||
|
||||
const orphanedTags: string[] = [];
|
||||
for (const o of settings.outbounds as Array<{ tag?: string; protocol?: string }>) {
|
||||
if (o.protocol !== 'loopback' || !o.tag || !isBalancerLoopbackTag(o.tag)) continue;
|
||||
const targetTag = balancerTagFromLoopback(o.tag);
|
||||
if (targetTag && countLoopbackRefs(settings, targetTag) === 0) {
|
||||
orphanedTags.push(targetTag);
|
||||
}
|
||||
}
|
||||
|
||||
for (const tag of orphanedTags) {
|
||||
removeBalancerLoopback(settings, tag);
|
||||
}
|
||||
}
|
||||
@@ -332,6 +332,10 @@ export default function OutboundFormModal({
|
||||
messageApi.error(t('pages.xray.outboundForm.tagRequired'));
|
||||
return;
|
||||
}
|
||||
if (tagValue.startsWith('_bl_')) {
|
||||
messageApi.error(t('pages.xray.balancer.reservedPrefix'));
|
||||
return;
|
||||
}
|
||||
const isDuplicateTag = (existingTags || []).includes(tagValue)
|
||||
&& !(isEdit && (outboundProp?.tag as string | undefined) === tagValue);
|
||||
if (isDuplicateTag) {
|
||||
|
||||
@@ -45,6 +45,7 @@ import OutboundFormModal from './OutboundFormModal';
|
||||
import { propagateOutboundTagRename } from '../basics/helpers';
|
||||
import { planOutboundDeletion, applyOutboundDeletion } from '../reference-cleanup';
|
||||
import DeletionImpactList from '../DeletionImpactList';
|
||||
import { isBalancerLoopbackTag } from '../balancers/balancer-loopback';
|
||||
import type { XraySettingsValue, SetTemplate, OutboundTestMode, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
|
||||
import './OutboundsTab.css';
|
||||
|
||||
@@ -142,7 +143,13 @@ export default function OutboundsTab({
|
||||
[templateSettings?.outbounds],
|
||||
);
|
||||
|
||||
const rows = useMemo(() => outbounds.map((o, i) => ({ ...o, key: i })), [outbounds]);
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
outbounds
|
||||
.map((o, i) => ({ ...o, key: i }))
|
||||
.filter((o) => !isBalancerLoopbackTag(o.tag || '')),
|
||||
[outbounds],
|
||||
);
|
||||
|
||||
const dialerProxyTags = useMemo(() => {
|
||||
const tags = new Set<string>();
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { catTabLabel } from '@/pages/settings/catTabLabel';
|
||||
import PromptModal from '@/components/feedback/PromptModal';
|
||||
import TextModal from '@/components/feedback/TextModal';
|
||||
import { isBalancerLoopbackTag } from '../balancers/balancer-loopback';
|
||||
import RoutingBasic from './RoutingBasic';
|
||||
import RouteTester from './RouteTester';
|
||||
import RuleFormModal from './RuleFormModal';
|
||||
@@ -66,26 +67,31 @@ export default function RoutingTab({
|
||||
|
||||
const rows: RuleRow[] = useMemo(
|
||||
() =>
|
||||
rules.map((rule, idx) => {
|
||||
const r: RuleRow = { key: idx };
|
||||
r.enabled = rule.enabled !== false;
|
||||
r.domain = arrJoin(rule.domain);
|
||||
r.ip = arrJoin(rule.ip);
|
||||
r.port = rule.port;
|
||||
r.sourcePort = rule.sourcePort;
|
||||
r.vlessRoute = rule.vlessRoute;
|
||||
r.network = rule.network;
|
||||
r.sourceIP = arrJoin(rule.sourceIP);
|
||||
r.user = arrJoin(rule.user);
|
||||
r.inboundTag = arrJoin(rule.inboundTag);
|
||||
r.protocol = arrJoin(rule.protocol);
|
||||
if (rule.attrs && typeof rule.attrs === 'object' && !Array.isArray(rule.attrs)) {
|
||||
r.attrs = JSON.stringify(rule.attrs, null, 2);
|
||||
}
|
||||
r.outboundTag = rule.outboundTag;
|
||||
r.balancerTag = rule.balancerTag;
|
||||
return r;
|
||||
}),
|
||||
rules
|
||||
.map((rule, idx) => {
|
||||
const r: RuleRow = { key: idx };
|
||||
r.enabled = rule.enabled !== false;
|
||||
r.domain = arrJoin(rule.domain);
|
||||
r.ip = arrJoin(rule.ip);
|
||||
r.port = rule.port;
|
||||
r.sourcePort = rule.sourcePort;
|
||||
r.vlessRoute = rule.vlessRoute;
|
||||
r.network = rule.network;
|
||||
r.sourceIP = arrJoin(rule.sourceIP);
|
||||
r.user = arrJoin(rule.user);
|
||||
r.inboundTag = arrJoin(rule.inboundTag);
|
||||
r.protocol = arrJoin(rule.protocol);
|
||||
if (rule.attrs && typeof rule.attrs === 'object' && !Array.isArray(rule.attrs)) {
|
||||
r.attrs = JSON.stringify(rule.attrs, null, 2);
|
||||
}
|
||||
r.outboundTag = rule.outboundTag;
|
||||
r.balancerTag = rule.balancerTag;
|
||||
return r;
|
||||
})
|
||||
.filter((r) => {
|
||||
const inboundTags = (rules[r.key]?.inboundTag || []) as string[];
|
||||
return !inboundTags.some(isBalancerLoopbackTag);
|
||||
}),
|
||||
[rules],
|
||||
);
|
||||
|
||||
@@ -105,7 +111,7 @@ export default function RoutingTab({
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
const push = (tag?: string) => {
|
||||
if (!tag || seen.has(tag)) return;
|
||||
if (!tag || seen.has(tag) || isBalancerLoopbackTag(tag)) return;
|
||||
seen.add(tag);
|
||||
out.push(tag);
|
||||
};
|
||||
|
||||
@@ -101,7 +101,10 @@ export const RuleFormSchema = z.object({
|
||||
});
|
||||
|
||||
export const BalancerFormSchema = z.object({
|
||||
tag: z.string().trim().min(1, 'pages.xray.balancerTagRequired'),
|
||||
tag: z.string().trim().min(1, 'pages.xray.balancerTagRequired').refine(
|
||||
(val) => !val.startsWith('_bl_'),
|
||||
{ message: 'pages.xray.balancer.reservedPrefix' },
|
||||
),
|
||||
strategy: BalancerStrategyTypeSchema.default('random'),
|
||||
selector: z.array(z.string()).min(1, 'pages.xray.balancerSelectorRequired'),
|
||||
fallbackTag: z.string().default(''),
|
||||
@@ -111,7 +114,11 @@ export const BalancerFormSchema = z.object({
|
||||
export const OutboundTagSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, 'pages.xray.outboundTagRequired');
|
||||
.min(1, 'pages.xray.outboundTagRequired')
|
||||
.refine(
|
||||
(val) => !val.startsWith('_bl_'),
|
||||
{ message: 'pages.xray.balancer.reservedPrefix' },
|
||||
);
|
||||
|
||||
export type BalancerFormValues = z.infer<typeof BalancerFormSchema>;
|
||||
export type RuleFormValues = z.infer<typeof RuleFormSchema>;
|
||||
|
||||
@@ -2,6 +2,8 @@ import { describe, it, expect, vi } from 'vitest';
|
||||
import { fireEvent } from '@testing-library/react';
|
||||
|
||||
import BalancerFormModal from '@/pages/xray/balancers/BalancerFormModal';
|
||||
import type { BalancerFormValue } from '@/pages/xray/balancers/BalancerFormModal';
|
||||
import type { BalancerObject } from '@/schemas/routing';
|
||||
import { renderWithProviders } from './test-utils';
|
||||
|
||||
function renderModal(onConfirm = vi.fn()) {
|
||||
@@ -10,6 +12,9 @@ function renderModal(onConfirm = vi.fn()) {
|
||||
open
|
||||
balancer={null}
|
||||
outboundTags={['proxy', 'direct']}
|
||||
balancerTags={[]}
|
||||
balancers={[]}
|
||||
templateSettings={null}
|
||||
otherTags={['existing']}
|
||||
onClose={() => {}}
|
||||
onConfirm={onConfirm}
|
||||
@@ -28,9 +33,9 @@ function explainText(): string {
|
||||
.join(' | ');
|
||||
}
|
||||
|
||||
function createButton(): HTMLElement {
|
||||
function primaryButton(): HTMLElement {
|
||||
const btn = document.querySelector('.ant-modal-footer .ant-btn-primary');
|
||||
if (!btn) throw new Error('Create button not found');
|
||||
if (!btn) throw new Error('Primary button not found');
|
||||
return btn as HTMLElement;
|
||||
}
|
||||
|
||||
@@ -41,18 +46,43 @@ describe('BalancerFormModal', () => {
|
||||
expect(erroredItemCount()).toBe(0);
|
||||
expect(explainText()).not.toContain('Tag is required');
|
||||
expect(explainText()).not.toContain('Pick at least one outbound');
|
||||
expect(createButton().hasAttribute('disabled')).toBe(false);
|
||||
expect(primaryButton().hasAttribute('disabled')).toBe(false);
|
||||
});
|
||||
|
||||
it('reveals required-field errors only after a save attempt, without confirming', () => {
|
||||
const { onConfirm } = renderModal();
|
||||
expect(erroredItemCount()).toBe(0);
|
||||
|
||||
fireEvent.click(createButton());
|
||||
fireEvent.click(primaryButton());
|
||||
|
||||
expect(erroredItemCount()).toBe(2);
|
||||
expect(explainText()).toContain('Tag is required');
|
||||
expect(explainText()).toContain('Pick at least one outbound');
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('disables save and warns when the chosen fallback would create a balancer cycle', () => {
|
||||
const editing: BalancerFormValue = { tag: 'A', strategy: 'random', selector: ['proxy'], fallbackTag: 'B' };
|
||||
const others: BalancerObject[] = [{ tag: 'B', selector: ['direct'], fallbackTag: 'A' }];
|
||||
const onConfirm = vi.fn();
|
||||
renderWithProviders(
|
||||
<BalancerFormModal
|
||||
open
|
||||
balancer={editing}
|
||||
outboundTags={['proxy', 'direct']}
|
||||
balancerTags={['A', 'B']}
|
||||
balancers={others}
|
||||
templateSettings={null}
|
||||
otherTags={['B']}
|
||||
onClose={() => {}}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(document.querySelector('.ant-alert-error')).toBeTruthy();
|
||||
expect(primaryButton().hasAttribute('disabled')).toBe(true);
|
||||
|
||||
fireEvent.click(primaryButton());
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import type { XraySettingsValue } from '@/hooks/useXraySetting';
|
||||
import {
|
||||
isBalancerLoopbackTag,
|
||||
loopbackTagFor,
|
||||
balancerTagFromLoopback,
|
||||
resolveLoopbackFallback,
|
||||
ensureBalancerLoopback,
|
||||
ensureMissingBalancerLoopbacks,
|
||||
removeBalancerLoopback,
|
||||
removeBalancerLoopbackIfOrphaned,
|
||||
propagateBalancerTagRename,
|
||||
detectBalancerCycles,
|
||||
cleanupOrphanedBalancerLoopbacks,
|
||||
} from '@/pages/xray/balancers/balancer-loopback';
|
||||
|
||||
interface OutboundEntry {
|
||||
tag?: string;
|
||||
protocol?: string;
|
||||
settings?: { inboundTag?: string };
|
||||
}
|
||||
interface RuleEntry {
|
||||
type?: string;
|
||||
inboundTag?: string[];
|
||||
balancerTag?: string;
|
||||
}
|
||||
interface BalancerEntry {
|
||||
tag?: string;
|
||||
selector?: string[];
|
||||
fallbackTag?: string;
|
||||
}
|
||||
|
||||
function makeSettings(input: {
|
||||
outbounds?: OutboundEntry[];
|
||||
rules?: RuleEntry[];
|
||||
balancers?: BalancerEntry[];
|
||||
}): XraySettingsValue {
|
||||
return {
|
||||
outbounds: input.outbounds,
|
||||
routing: {
|
||||
rules: input.rules,
|
||||
balancers: input.balancers,
|
||||
},
|
||||
} as XraySettingsValue;
|
||||
}
|
||||
|
||||
function outboundTags(settings: XraySettingsValue): string[] {
|
||||
return ((settings.outbounds ?? []) as OutboundEntry[]).map((o) => o.tag ?? '');
|
||||
}
|
||||
|
||||
function loopbackOutbounds(settings: XraySettingsValue): OutboundEntry[] {
|
||||
return ((settings.outbounds ?? []) as OutboundEntry[]).filter((o) => o.protocol === 'loopback');
|
||||
}
|
||||
|
||||
function ruleEntries(settings: XraySettingsValue): RuleEntry[] {
|
||||
return (settings.routing?.rules ?? []) as RuleEntry[];
|
||||
}
|
||||
|
||||
function balancerEntries(settings: XraySettingsValue): BalancerEntry[] {
|
||||
return (settings.routing?.balancers ?? []) as BalancerEntry[];
|
||||
}
|
||||
|
||||
describe('loopback tag helpers', () => {
|
||||
const cases: Array<{ tag: string; isLoopback: boolean; roundtrip: string | null }> = [
|
||||
{ tag: '_bl_main', isLoopback: true, roundtrip: 'main' },
|
||||
{ tag: 'main', isLoopback: false, roundtrip: null },
|
||||
{ tag: '_bl_', isLoopback: true, roundtrip: '' },
|
||||
{ tag: 'proxy_bl_', isLoopback: false, roundtrip: null },
|
||||
];
|
||||
|
||||
it.each(cases)('classifies $tag', ({ tag, isLoopback, roundtrip }) => {
|
||||
expect(isBalancerLoopbackTag(tag)).toBe(isLoopback);
|
||||
expect(balancerTagFromLoopback(tag)).toBe(roundtrip);
|
||||
});
|
||||
|
||||
it('builds a loopback tag that round-trips back to the balancer tag', () => {
|
||||
expect(loopbackTagFor('cluster-a')).toBe('_bl_cluster-a');
|
||||
expect(balancerTagFromLoopback(loopbackTagFor('cluster-a'))).toBe('cluster-a');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveLoopbackFallback', () => {
|
||||
const settings = makeSettings({
|
||||
rules: [{ type: 'field', inboundTag: ['_bl_bal1'], balancerTag: 'bal1' }],
|
||||
});
|
||||
|
||||
const cases: Array<{ name: string; input: string; expected: string }> = [
|
||||
{ name: 'resolves a loopback tag through its routing rule', input: '_bl_bal1', expected: 'bal1' },
|
||||
{ name: 'returns a plain outbound tag unchanged', input: 'direct', expected: 'direct' },
|
||||
{ name: 'returns an empty tag unchanged', input: '', expected: '' },
|
||||
{ name: 'derives the balancer tag when no rule maps it', input: '_bl_bal2', expected: 'bal2' },
|
||||
];
|
||||
|
||||
it.each(cases)('$name', ({ input, expected }) => {
|
||||
expect(resolveLoopbackFallback(settings, input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureBalancerLoopback dedup', () => {
|
||||
it('creates exactly one loopback outbound and one rule when called repeatedly', () => {
|
||||
const settings = makeSettings({ outbounds: [], rules: [], balancers: [] });
|
||||
|
||||
ensureBalancerLoopback(settings, 'bal1');
|
||||
ensureBalancerLoopback(settings, 'bal1');
|
||||
|
||||
const loopbacks = loopbackOutbounds(settings);
|
||||
expect(loopbacks).toHaveLength(1);
|
||||
expect(loopbacks[0]).toEqual({
|
||||
tag: '_bl_bal1',
|
||||
protocol: 'loopback',
|
||||
settings: { inboundTag: '_bl_bal1' },
|
||||
});
|
||||
|
||||
const matchingRules = ruleEntries(settings).filter(
|
||||
(r) => Array.isArray(r.inboundTag) && r.inboundTag.includes('_bl_bal1'),
|
||||
);
|
||||
expect(matchingRules).toHaveLength(1);
|
||||
expect(matchingRules[0].balancerTag).toBe('bal1');
|
||||
});
|
||||
|
||||
it('does not duplicate a loopback shared by multiple balancers', () => {
|
||||
const settings = makeSettings({
|
||||
outbounds: [],
|
||||
rules: [],
|
||||
balancers: [
|
||||
{ tag: 'A', selector: [], fallbackTag: '_bl_shared' },
|
||||
{ tag: 'B', selector: [], fallbackTag: '_bl_shared' },
|
||||
],
|
||||
});
|
||||
|
||||
ensureMissingBalancerLoopbacks(settings);
|
||||
|
||||
expect(loopbackOutbounds(settings)).toHaveLength(1);
|
||||
expect(
|
||||
ruleEntries(settings).filter(
|
||||
(r) => Array.isArray(r.inboundTag) && r.inboundTag.includes('_bl_shared'),
|
||||
),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectBalancerCycles', () => {
|
||||
const cases: Array<{ name: string; balancers: BalancerEntry[]; expected: string[][] }> = [
|
||||
{
|
||||
name: 'two-balancer loop A -> B -> A',
|
||||
balancers: [
|
||||
{ tag: 'A', fallbackTag: '_bl_B' },
|
||||
{ tag: 'B', fallbackTag: '_bl_A' },
|
||||
],
|
||||
expected: [
|
||||
['A', 'B'],
|
||||
['B', 'A'],
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'self loop A -> A',
|
||||
balancers: [{ tag: 'A', fallbackTag: '_bl_A' }],
|
||||
expected: [['A', 'A']],
|
||||
},
|
||||
{
|
||||
name: 'three-balancer loop A -> B -> C -> A',
|
||||
balancers: [
|
||||
{ tag: 'A', fallbackTag: '_bl_B' },
|
||||
{ tag: 'B', fallbackTag: '_bl_C' },
|
||||
{ tag: 'C', fallbackTag: '_bl_A' },
|
||||
],
|
||||
expected: [
|
||||
['A', 'B'],
|
||||
['B', 'C'],
|
||||
['C', 'A'],
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'linear chain is not a cycle',
|
||||
balancers: [{ tag: 'A', fallbackTag: '_bl_B' }, { tag: 'B' }],
|
||||
expected: [],
|
||||
},
|
||||
{
|
||||
name: 'non-loopback fallback is ignored',
|
||||
balancers: [{ tag: 'A', fallbackTag: 'direct' }],
|
||||
expected: [],
|
||||
},
|
||||
];
|
||||
|
||||
it.each(cases)('$name', ({ balancers, expected }) => {
|
||||
expect(detectBalancerCycles(makeSettings({ balancers }))).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('propagateBalancerTagRename', () => {
|
||||
it('rewrites the loopback outbound, its rule and referring fallback tags', () => {
|
||||
const settings = makeSettings({
|
||||
outbounds: [{ tag: '_bl_old', protocol: 'loopback', settings: { inboundTag: '_bl_old' } }],
|
||||
rules: [{ type: 'field', inboundTag: ['_bl_old'], balancerTag: 'old' }],
|
||||
balancers: [{ tag: 'user', selector: [], fallbackTag: '_bl_old' }],
|
||||
});
|
||||
|
||||
propagateBalancerTagRename(settings, 'old', 'new');
|
||||
|
||||
const [outbound] = loopbackOutbounds(settings);
|
||||
expect(outbound.tag).toBe('_bl_new');
|
||||
expect(outbound.settings?.inboundTag).toBe('_bl_new');
|
||||
expect(ruleEntries(settings)[0].inboundTag).toEqual(['_bl_new']);
|
||||
expect(balancerEntries(settings)[0].fallbackTag).toBe('_bl_new');
|
||||
});
|
||||
|
||||
it('leaves unrelated loopback tags untouched', () => {
|
||||
const settings = makeSettings({
|
||||
outbounds: [{ tag: '_bl_other', protocol: 'loopback', settings: { inboundTag: '_bl_other' } }],
|
||||
rules: [{ type: 'field', inboundTag: ['_bl_other'], balancerTag: 'other' }],
|
||||
balancers: [{ tag: 'user', selector: [], fallbackTag: '_bl_other' }],
|
||||
});
|
||||
|
||||
propagateBalancerTagRename(settings, 'old', 'new');
|
||||
|
||||
expect(loopbackOutbounds(settings)[0].tag).toBe('_bl_other');
|
||||
expect(ruleEntries(settings)[0].inboundTag).toEqual(['_bl_other']);
|
||||
expect(balancerEntries(settings)[0].fallbackTag).toBe('_bl_other');
|
||||
});
|
||||
});
|
||||
|
||||
describe('orphan cleanup', () => {
|
||||
it('cleanupOrphanedBalancerLoopbacks removes only unreferenced loopbacks', () => {
|
||||
const settings = makeSettings({
|
||||
outbounds: [
|
||||
{ tag: '_bl_gone', protocol: 'loopback', settings: { inboundTag: '_bl_gone' } },
|
||||
{ tag: '_bl_kept', protocol: 'loopback', settings: { inboundTag: '_bl_kept' } },
|
||||
{ tag: 'proxy', protocol: 'vless' },
|
||||
],
|
||||
rules: [
|
||||
{ type: 'field', inboundTag: ['_bl_gone'], balancerTag: 'gone' },
|
||||
{ type: 'field', inboundTag: ['_bl_kept'], balancerTag: 'kept' },
|
||||
],
|
||||
balancers: [{ tag: 'user', selector: [], fallbackTag: '_bl_kept' }],
|
||||
});
|
||||
|
||||
cleanupOrphanedBalancerLoopbacks(settings);
|
||||
|
||||
expect(outboundTags(settings)).toEqual(['_bl_kept', 'proxy']);
|
||||
expect(ruleEntries(settings).map((r) => r.inboundTag)).toEqual([['_bl_kept']]);
|
||||
});
|
||||
|
||||
it('removeBalancerLoopbackIfOrphaned keeps a loopback that is still referenced', () => {
|
||||
const settings = makeSettings({
|
||||
outbounds: [{ tag: '_bl_kept', protocol: 'loopback', settings: { inboundTag: '_bl_kept' } }],
|
||||
rules: [{ type: 'field', inboundTag: ['_bl_kept'], balancerTag: 'kept' }],
|
||||
balancers: [{ tag: 'user', selector: [], fallbackTag: '_bl_kept' }],
|
||||
});
|
||||
|
||||
removeBalancerLoopbackIfOrphaned(settings, 'kept');
|
||||
|
||||
expect(loopbackOutbounds(settings)).toHaveLength(1);
|
||||
expect(ruleEntries(settings)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('removeBalancerLoopbackIfOrphaned drops a loopback with no referrers', () => {
|
||||
const settings = makeSettings({
|
||||
outbounds: [{ tag: '_bl_kept', protocol: 'loopback', settings: { inboundTag: '_bl_kept' } }],
|
||||
rules: [{ type: 'field', inboundTag: ['_bl_kept'], balancerTag: 'kept' }],
|
||||
balancers: [],
|
||||
});
|
||||
|
||||
removeBalancerLoopbackIfOrphaned(settings, 'kept');
|
||||
|
||||
expect(loopbackOutbounds(settings)).toHaveLength(0);
|
||||
expect(ruleEntries(settings)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('removeBalancerLoopback deletes the outbound and rule directly', () => {
|
||||
const settings = makeSettings({
|
||||
outbounds: [
|
||||
{ tag: '_bl_kept', protocol: 'loopback', settings: { inboundTag: '_bl_kept' } },
|
||||
{ tag: 'proxy', protocol: 'vless' },
|
||||
],
|
||||
rules: [{ type: 'field', inboundTag: ['_bl_kept'], balancerTag: 'kept' }],
|
||||
balancers: [{ tag: 'user', selector: [], fallbackTag: '_bl_kept' }],
|
||||
});
|
||||
|
||||
removeBalancerLoopback(settings, 'kept');
|
||||
|
||||
expect(outboundTags(settings)).toEqual(['proxy']);
|
||||
expect(ruleEntries(settings)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user