mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-09 06:06:08 +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);
|
||||
});
|
||||
});
|
||||
@@ -905,11 +905,22 @@ func (s *XrayService) GetBalancersStatus(tags []string) ([]BalancerStatus, error
|
||||
}
|
||||
|
||||
// OverrideBalancer forces a balancer in the running core to use the given
|
||||
// outbound tag; an empty target clears the override.
|
||||
// outbound tag; an empty target clears the override. When target names
|
||||
// another balancer, the override resolves to the loopback outbound that
|
||||
// routes traffic through the target balancer via the routing rules.
|
||||
func (s *XrayService) OverrideBalancer(tag, target string) error {
|
||||
if !s.IsXrayRunning() {
|
||||
return errors.New("xray is not running")
|
||||
}
|
||||
if target != "" {
|
||||
resolved, err := s.resolveOverrideTarget(target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resolved != "" {
|
||||
target = resolved
|
||||
}
|
||||
}
|
||||
if err := s.xrayAPI.Init(p.GetAPIPort()); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -917,6 +928,42 @@ func (s *XrayService) OverrideBalancer(tag, target string) error {
|
||||
return s.xrayAPI.SetBalancerTarget(tag, target)
|
||||
}
|
||||
|
||||
// resolveOverrideTarget checks if target names a balancer and, if so,
|
||||
// returns the loopback outbound tag that routes to it through the
|
||||
// routing rules. Returns empty if target is already a concrete outbound.
|
||||
func (s *XrayService) resolveOverrideTarget(target string) (string, error) {
|
||||
template, err := s.settingService.GetXrayConfigTemplate()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var cfg map[string]any
|
||||
if err := json.Unmarshal([]byte(template), &cfg); err != nil {
|
||||
return "", err
|
||||
}
|
||||
routing, _ := cfg["routing"].(map[string]any)
|
||||
if routing == nil {
|
||||
return "", nil
|
||||
}
|
||||
rules, _ := routing["rules"].([]any)
|
||||
for _, r := range rules {
|
||||
rule, ok := r.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if rule["balancerTag"] != target {
|
||||
continue
|
||||
}
|
||||
inboundTags, ok := rule["inboundTag"].([]any)
|
||||
if !ok || len(inboundTags) == 0 {
|
||||
continue
|
||||
}
|
||||
if lbTag, ok := inboundTags[0].(string); ok && strings.HasPrefix(lbTag, "_bl_") {
|
||||
return lbTag, nil
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// TestRoute asks the running core which outbound its router picks for the
|
||||
// described connection.
|
||||
func (s *XrayService) TestRoute(req xray.RouteTestRequest) (*xray.RouteTestResult, error) {
|
||||
|
||||
@@ -1755,6 +1755,7 @@
|
||||
"tagPlaceholder": "وسم موازن فريد",
|
||||
"selector": "المحدد",
|
||||
"fallback": "Fallback",
|
||||
"cycleTooltip": "حلقة: {path} → (العودة إلى {start})",
|
||||
"expected": "المتوقع",
|
||||
"expectedPlaceholder": "العدد الأمثل للعقد",
|
||||
"maxRtt": "أقصى RTT",
|
||||
@@ -1764,7 +1765,12 @@
|
||||
"balancerDesc": "ماينفعش تستخدم balancerTag و outboundTag مع بعض. لو اتستخدموا مع بعض، outboundTag هو اللي هيشتغل.",
|
||||
"costMatch": "نمط الوسم",
|
||||
"costValue": "الوزن",
|
||||
"costRegexp": "مطابقة تعبير نمطي"
|
||||
"costRegexp": "مطابقة تعبير نمطي",
|
||||
"balancerDeleteInUse": "لا يمكن حذف هذا الموزان — يتم استخدامه كبديل لـ: {names}",
|
||||
"balancerFallbackCycle": "لا يمكن تعيين هذا الموزان كبديل — سيؤدي ذلك إلى إنشاء تبعية دائرية.",
|
||||
"balancerFallbackInfo": "سيتم توجيه حركة المرور عبر: الموزان → Loopback → الخادم → الموزان المستهدف → الاتصال الخارجي. سيؤدي هذا إلى إضافة قفزة إضافية عبر الخادم، مما قد يسبب تأخيرات طفيفة.",
|
||||
"fallbackBalancerHint": "اختر موزان أحمال آخر كبديل",
|
||||
"reservedPrefix": "البادئة _bl_ محجوزة لكائنات loopback الداخلية للموازنات"
|
||||
},
|
||||
"wireguard": {
|
||||
"secretKey": "المفتاح السري",
|
||||
|
||||
@@ -1872,6 +1872,12 @@
|
||||
"tagPlaceholder": "unique balancer tag",
|
||||
"selector": "Selector",
|
||||
"fallback": "Fallback",
|
||||
"fallbackBalancerHint": "Select another balancer as fallback",
|
||||
"balancerFallbackInfo": "Traffic will be routed through: Balancer → Loopback → Server → Target Balancer → Outbound. This adds an extra hop through the server, which may introduce slight delays.",
|
||||
"balancerFallbackCycle": "Cannot set this balancer as fallback — it would create a circular dependency.",
|
||||
"balancerDeleteInUse": "Cannot delete this balancer — it is used as fallback by: {names}",
|
||||
"reservedPrefix": "_bl_ prefix is reserved for internal balancer loopback objects",
|
||||
"cycleTooltip": "Cycle: {path} → (back to {start})",
|
||||
"expected": "Expected",
|
||||
"expectedPlaceholder": "optimal node count",
|
||||
"maxRtt": "Max RTT",
|
||||
|
||||
@@ -1755,6 +1755,7 @@
|
||||
"tagPlaceholder": "etiqueta única de balanceador",
|
||||
"selector": "Selector",
|
||||
"fallback": "Fallback",
|
||||
"cycleTooltip": "Ciclo: {path} → (volver a {start})",
|
||||
"expected": "Esperado",
|
||||
"expectedPlaceholder": "número óptimo de nodos",
|
||||
"maxRtt": "Máx. RTT",
|
||||
@@ -1764,7 +1765,12 @@
|
||||
"balancerDesc": "No es posible utilizar balancerTag y outboundTag al mismo tiempo. Si se utilizan al mismo tiempo, sólo funcionará outboundTag.",
|
||||
"costMatch": "Patrón de etiqueta",
|
||||
"costValue": "Peso",
|
||||
"costRegexp": "Coincidencia por expresión regular"
|
||||
"costRegexp": "Coincidencia por expresión regular",
|
||||
"balancerDeleteInUse": "No se puede eliminar este balanceador — se usa como respaldo para: {names}",
|
||||
"balancerFallbackCycle": "No se puede establecer este balanceador como respaldo — crearía una dependencia circular.",
|
||||
"balancerFallbackInfo": "El tráfico se enrutará a través de: Balanceador → Loopback → Servidor → Balanceador destino → Conexión saliente. Esto agrega un salto adicional a través del servidor, lo que puede introducir ligeros retrasos.",
|
||||
"fallbackBalancerHint": "Seleccione otro balanceador como respaldo",
|
||||
"reservedPrefix": "El prefijo _bl_ está reservado para objetos loopback internos del balanceador"
|
||||
},
|
||||
"wireguard": {
|
||||
"secretKey": "Llave secreta",
|
||||
|
||||
@@ -1755,6 +1755,7 @@
|
||||
"tagPlaceholder": "تگ منحصربهفرد بالانسر",
|
||||
"selector": "انتخابگر",
|
||||
"fallback": "Fallback",
|
||||
"cycleTooltip": "حلقه: {path} → (بازگشت به {start})",
|
||||
"expected": "مورد انتظار",
|
||||
"expectedPlaceholder": "تعداد نود بهینه",
|
||||
"maxRtt": "حداکثر RTT",
|
||||
@@ -1764,7 +1765,12 @@
|
||||
"balancerDesc": "امکان استفاده همزمان balancerTag و outboundTag باهم وجود ندارد. درصورت استفاده همزمان فقط outboundTag عمل خواهد کرد.",
|
||||
"costMatch": "الگوی برچسب",
|
||||
"costValue": "وزن",
|
||||
"costRegexp": "تطبیق با عبارت باقاعده"
|
||||
"costRegexp": "تطبیق با عبارت باقاعده",
|
||||
"balancerDeleteInUse": "امکان حذف این بالانسر وجود ندارد — به عنوان پشتیبان برای موارد زیر استفاده میشود: {names}",
|
||||
"balancerFallbackCycle": "امکان تنظیم این بالانسر به عنوان پشتیبان وجود ندارد — وابستگی دایرهای ایجاد میکند.",
|
||||
"balancerFallbackInfo": "ترافیک از مسیر زیر مسیریابی میشود: بالانسر → Loopback → سرور → بالانسر مقصد → اتصال خروجی. این یک پرش اضافی از طریق سرور اضافه میکند که ممکن است تأخیرهای جزئی ایجاد کند.",
|
||||
"fallbackBalancerHint": "یک بالانسر دیگر به عنوان پشتیبان انتخاب کنید",
|
||||
"reservedPrefix": "پیشوند _bl_ برای اشیاء loopback داخلی بالانسر رزرو شده است"
|
||||
},
|
||||
"wireguard": {
|
||||
"secretKey": "کلید شخصی",
|
||||
|
||||
@@ -1755,6 +1755,7 @@
|
||||
"tagPlaceholder": "tag balancer unik",
|
||||
"selector": "Selector",
|
||||
"fallback": "Fallback",
|
||||
"cycleTooltip": "Siklus: {path} → (kembali ke {start})",
|
||||
"expected": "Diharapkan",
|
||||
"expectedPlaceholder": "jumlah node optimal",
|
||||
"maxRtt": "Maks. RTT",
|
||||
@@ -1764,7 +1765,12 @@
|
||||
"balancerDesc": "BalancerTag dan outboundTag tidak dapat digunakan secara bersamaan. Jika digunakan secara bersamaan, hanya outboundTag yang akan berfungsi.",
|
||||
"costMatch": "Pola tag",
|
||||
"costValue": "Bobot",
|
||||
"costRegexp": "Pencocokan ekspresi reguler"
|
||||
"costRegexp": "Pencocokan ekspresi reguler",
|
||||
"balancerDeleteInUse": "Tidak dapat menghapus load balancer ini — digunakan sebagai fallback untuk: {names}",
|
||||
"balancerFallbackCycle": "Tidak dapat mengatur load balancer ini sebagai fallback — akan menciptakan dependensi siklik.",
|
||||
"balancerFallbackInfo": "Lalu lintas akan di-routing melalui: Load Balancer → Loopback → Server → Load Balancer tujuan → Koneksi keluar. Ini menambahkan hop tambahan melalui server, yang dapat menyebabkan sedikit penundaan.",
|
||||
"fallbackBalancerHint": "Pilih load balancer lain sebagai fallback",
|
||||
"reservedPrefix": "Awalan _bl_ dicadangkan untuk objek loopback internal load balancer"
|
||||
},
|
||||
"wireguard": {
|
||||
"secretKey": "Kunci Rahasia",
|
||||
|
||||
@@ -1755,6 +1755,7 @@
|
||||
"tagPlaceholder": "一意のバランサータグ",
|
||||
"selector": "セレクター",
|
||||
"fallback": "Fallback",
|
||||
"cycleTooltip": "循環: {path} → ({start} に戻る)",
|
||||
"expected": "期待値",
|
||||
"expectedPlaceholder": "最適ノード数",
|
||||
"maxRtt": "最大 RTT",
|
||||
@@ -1764,7 +1765,12 @@
|
||||
"balancerDesc": "balancerTagとoutboundTagは同時に使用できません。同時に使用された場合、outboundTagのみが有効になります。",
|
||||
"costMatch": "タグパターン",
|
||||
"costValue": "重み",
|
||||
"costRegexp": "正規表現で一致"
|
||||
"costRegexp": "正規表現で一致",
|
||||
"balancerDeleteInUse": "このバランサーを削除できません — 以下のバランサーのフォールバックとして使用されています:{names}",
|
||||
"balancerFallbackCycle": "このバランサーをフォールバックに設定できません — 循環依存が発生します。",
|
||||
"balancerFallbackInfo": "トラフィックは以下のルートで転送されます:バランサー → Loopback → サーバー → ターゲットバランサー → アウトバウンド。これによりサーバーを経由する追加ホップが発生し、多少の遅延が生じる場合があります。",
|
||||
"fallbackBalancerHint": "フォールバックとして別のバランサーを選択してください",
|
||||
"reservedPrefix": "プレフィックス _bl_ はバランサーの内部ループバックオブジェクト用に予約されています"
|
||||
},
|
||||
"wireguard": {
|
||||
"secretKey": "シークレットキー",
|
||||
|
||||
@@ -1755,6 +1755,7 @@
|
||||
"tagPlaceholder": "tag única do balanceador",
|
||||
"selector": "Seletor",
|
||||
"fallback": "Fallback",
|
||||
"cycleTooltip": "Ciclo: {path} → (voltar para {start})",
|
||||
"expected": "Esperado",
|
||||
"expectedPlaceholder": "número ótimo de nós",
|
||||
"maxRtt": "Máx. RTT",
|
||||
@@ -1764,7 +1765,12 @@
|
||||
"balancerDesc": "Não é possível usar balancerTag e outboundTag ao mesmo tempo. Se usados simultaneamente, apenas outboundTag funcionará.",
|
||||
"costMatch": "Padrão de tag",
|
||||
"costValue": "Peso",
|
||||
"costRegexp": "Correspondência por expressão regular"
|
||||
"costRegexp": "Correspondência por expressão regular",
|
||||
"balancerDeleteInUse": "Não é possível excluir este balanceador — ele é usado como fallback para: {names}",
|
||||
"balancerFallbackCycle": "Não é possível definir este balanceador como fallback — isso criaria uma dependência circular.",
|
||||
"balancerFallbackInfo": "O tráfego será roteado através de: Balanceador → Loopback → Servidor → Balanceador de destino → Conexão de saída. Isso adiciona um salto extra pelo servidor, o que pode introduzir pequenos atrasos.",
|
||||
"fallbackBalancerHint": "Selecione outro balanceador como fallback",
|
||||
"reservedPrefix": "O prefixo _bl_ é reservado para objetos loopback internos do balanceador"
|
||||
},
|
||||
"wireguard": {
|
||||
"secretKey": "Chave Secreta",
|
||||
|
||||
@@ -1755,6 +1755,7 @@
|
||||
"tagPlaceholder": "уникальный тег балансировщика",
|
||||
"selector": "Селектор",
|
||||
"fallback": "Fallback",
|
||||
"cycleTooltip": "Цикл: {path} → (обратно к {start})",
|
||||
"expected": "Ожидаемое",
|
||||
"expectedPlaceholder": "оптимальное число узлов",
|
||||
"maxRtt": "Макс. RTT",
|
||||
@@ -1764,7 +1765,12 @@
|
||||
"balancerDesc": "Невозможно одновременно использовать balancerTag и outboundTag. При одновременном использовании будет работать только outboundTag.",
|
||||
"costMatch": "Шаблон тега",
|
||||
"costValue": "Вес",
|
||||
"costRegexp": "Совпадение по регулярному выражению"
|
||||
"costRegexp": "Совпадение по регулярному выражению",
|
||||
"balancerDeleteInUse": "Невозможно удалить этот балансировщик — он используется как запасной для: {names}",
|
||||
"balancerFallbackCycle": "Невозможно назначить этот балансировщик запасным — это создаст циклическую зависимость.",
|
||||
"balancerFallbackInfo": "Трафик будет маршрутизирован через: Балансировщик → Loopback → Сервер → Целевой балансировщик → Исходящее соединение. Это добавляет дополнительный хоп через сервер, что может вызвать небольшие задержки.",
|
||||
"fallbackBalancerHint": "Выберите другой балансировщик в качестве запасного",
|
||||
"reservedPrefix": "Префикс _bl_ зарезервирован для внутренних loopback-объектов балансеров"
|
||||
},
|
||||
"wireguard": {
|
||||
"secretKey": "Секретный ключ",
|
||||
|
||||
@@ -1755,6 +1755,7 @@
|
||||
"tagPlaceholder": "benzersiz dengeleyici etiketi",
|
||||
"selector": "Seçici",
|
||||
"fallback": "Fallback",
|
||||
"cycleTooltip": "Döngü: {path} → ({start} adresine geri dön)",
|
||||
"expected": "Beklenen",
|
||||
"expectedPlaceholder": "optimal düğüm sayısı",
|
||||
"maxRtt": "Maks. RTT",
|
||||
@@ -1764,7 +1765,12 @@
|
||||
"balancerDesc": "Dengeleyici Etiketi (balancerTag) ve Giden Bağlantı Etiketi (outboundTag) aynı anda kullanılamaz. Aynı anda kullanıldığında yalnızca giden bağlantı etiketi geçerli olur.",
|
||||
"costMatch": "Etiket deseni",
|
||||
"costValue": "Ağırlık",
|
||||
"costRegexp": "Düzenli ifade eşleşmesi"
|
||||
"costRegexp": "Düzenli ifade eşleşmesi",
|
||||
"balancerDeleteInUse": "Bu dengeleyici silinemez — şu dengeleyicilerin yedeği olarak kullanılmaktadır: {names}",
|
||||
"balancerFallbackCycle": "Bu dengeleyiciyi yedek olarak ayarlayamazsınız — döngüsel bağımlılık oluşturur.",
|
||||
"balancerFallbackInfo": "Trafik şu yoldan yönlendirilecektir: Dengeleyici → Loopback → Sunucu → Hedef dengeleyici → Bağlantı çıkışı. Bu, sunucu üzerinden ek bir atlama ekler ve hafif gecikmelere neden olabilir.",
|
||||
"fallbackBalancerHint": "Yedek olarak başka bir dengeleyici seçin",
|
||||
"reservedPrefix": " _bl_ ön eki dahili dengeleyici loopback nesneleri için ayrılmıştır"
|
||||
},
|
||||
"wireguard": {
|
||||
"secretKey": "Gizli Anahtar",
|
||||
|
||||
@@ -1755,6 +1755,7 @@
|
||||
"tagPlaceholder": "унікальний тег балансувальника",
|
||||
"selector": "Селектор",
|
||||
"fallback": "Fallback",
|
||||
"cycleTooltip": "Цикл: {path} → (назад до {start})",
|
||||
"expected": "Очікуване",
|
||||
"expectedPlaceholder": "оптимальна кількість вузлів",
|
||||
"maxRtt": "Макс. RTT",
|
||||
@@ -1764,7 +1765,12 @@
|
||||
"balancerDesc": "Неможливо використовувати balancerTag і outboundTag одночасно. Якщо використовувати одночасно, працюватиме лише outboundTag.",
|
||||
"costMatch": "Шаблон тегу",
|
||||
"costValue": "Вага",
|
||||
"costRegexp": "Збіг за регулярним виразом"
|
||||
"costRegexp": "Збіг за регулярним виразом",
|
||||
"balancerDeleteInUse": "Неможливо видалити цей балансувач — він використовується як резервний для: {names}",
|
||||
"balancerFallbackCycle": "Неможливо призначити цей балансувач резервним — це створить циклічну залежність.",
|
||||
"balancerFallbackInfo": "Трафік буде маршрутизовано через: Балансувач → Loopback → Сервер → Цільовий балансувач → Вихідне зʼєднання. Це додає додатковий хоп через сервер, що може спричинити невеликі затримки.",
|
||||
"fallbackBalancerHint": "Оберіть інший балансувач як резервний",
|
||||
"reservedPrefix": "Префікс _bl_ зарезервовано для внутрішніх loopback-об'єктів балансувальника"
|
||||
},
|
||||
"wireguard": {
|
||||
"secretKey": "Приватний ключ",
|
||||
|
||||
@@ -1755,6 +1755,7 @@
|
||||
"tagPlaceholder": "tag balancer duy nhất",
|
||||
"selector": "Selector",
|
||||
"fallback": "Fallback",
|
||||
"cycleTooltip": "Vòng lặp: {path} → (quay lại {start})",
|
||||
"expected": "Kỳ vọng",
|
||||
"expectedPlaceholder": "số node tối ưu",
|
||||
"maxRtt": "RTT tối đa",
|
||||
@@ -1764,7 +1765,12 @@
|
||||
"balancerDesc": "Không thể sử dụng balancerTag và outboundTag cùng một lúc. Nếu sử dụng cùng lúc thì chỉ outboundTag mới hoạt động.",
|
||||
"costMatch": "Mẫu thẻ",
|
||||
"costValue": "Trọng số",
|
||||
"costRegexp": "Khớp biểu thức chính quy"
|
||||
"costRegexp": "Khớp biểu thức chính quy",
|
||||
"balancerDeleteInUse": "Không thể xóa load balancer này — nó được sử dụng làm dự phòng cho: {names}",
|
||||
"balancerFallbackCycle": "Không thể đặt load balancer này làm dự phòng — sẽ tạo ra phụ thuộc vòng.",
|
||||
"balancerFallbackInfo": "Lưu lượng sẽ được định tuyến qua: Load Balancer → Loopback → Máy chủ → Load Balancer mục tiêu → kết nối ra ngoài. Điều này thêm một hop bổ sung qua máy chủ, có thể gây ra độ trễ nhẹ.",
|
||||
"fallbackBalancerHint": "Chọn một load balancer khác làm dự phòng",
|
||||
"reservedPrefix": "Tiền tố _bl_ được dành riêng cho các đối tượng loopback nội bộ của bộ cân bằng"
|
||||
},
|
||||
"wireguard": {
|
||||
"secretKey": "Khoá bí mật",
|
||||
|
||||
@@ -1755,6 +1755,7 @@
|
||||
"tagPlaceholder": "唯一均衡器标签",
|
||||
"selector": "选择器",
|
||||
"fallback": "Fallback",
|
||||
"cycleTooltip": "循环: {path} → (回到 {start})",
|
||||
"expected": "期望",
|
||||
"expectedPlaceholder": "最佳节点数",
|
||||
"maxRtt": "最大 RTT",
|
||||
@@ -1764,7 +1765,12 @@
|
||||
"balancerDesc": "无法同时使用 balancerTag 和 outboundTag。如果同时使用,则只有 outboundTag 会生效。",
|
||||
"costMatch": "标签匹配模式",
|
||||
"costValue": "权重",
|
||||
"costRegexp": "正则表达式匹配"
|
||||
"costRegexp": "正则表达式匹配",
|
||||
"balancerDeleteInUse": "无法删除此负载均衡器 — 它被用作以下负载均衡器的备用:{names}",
|
||||
"balancerFallbackCycle": "无法将此负载均衡器设置为备用 — 这会创建循环依赖。",
|
||||
"balancerFallbackInfo": "流量将通过以下路径路由:负载均衡器 → Loopback → 服务器 → 目标负载均衡器 → 出站连接。这会增加一个经过服务器的额外跳转,可能会引入轻微延迟。",
|
||||
"fallbackBalancerHint": "选择另一个负载均衡器作为备用",
|
||||
"reservedPrefix": "_bl_ 前缀保留给内部负载均衡器回环对象"
|
||||
},
|
||||
"wireguard": {
|
||||
"secretKey": "密钥",
|
||||
|
||||
@@ -1755,6 +1755,7 @@
|
||||
"tagPlaceholder": "唯一均衡器標籤",
|
||||
"selector": "選擇器",
|
||||
"fallback": "Fallback",
|
||||
"cycleTooltip": "循環: {path} → (回到 {start})",
|
||||
"expected": "期望",
|
||||
"expectedPlaceholder": "最佳節點數",
|
||||
"maxRtt": "最大 RTT",
|
||||
@@ -1764,7 +1765,12 @@
|
||||
"balancerDesc": "無法同時使用 balancerTag 和 outboundTag。如果同時使用,則只有 outboundTag 會生效。",
|
||||
"costMatch": "標籤比對模式",
|
||||
"costValue": "權重",
|
||||
"costRegexp": "正規表示式比對"
|
||||
"costRegexp": "正規表示式比對",
|
||||
"balancerDeleteInUse": "無法刪除此負載平衡器 — 它被用作以下負載平衡器的備用:{names}",
|
||||
"balancerFallbackCycle": "無法將此負載平衡器設定為備用 — 這會建立循環依賴。",
|
||||
"balancerFallbackInfo": "流量將透過以下路徑路由:負載平衡器 → Loopback → 伺服器 → 目標負載平衡器 → 出站連線。這會增加一個經過伺服器的額外跳轉,可能會引入輕微延遲。",
|
||||
"fallbackBalancerHint": "選擇另一個負載平衡器作為備用",
|
||||
"reservedPrefix": "_bl_ 前綴保留給內部負載均衡器迴圈物件"
|
||||
},
|
||||
"wireguard": {
|
||||
"secretKey": "金鑰",
|
||||
|
||||
Reference in New Issue
Block a user