mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-14 08:36:07 +00:00
fix(xray): clean stale routing references when a balancer or outbound is deleted (#5648)
* feat(xray): reference-cleanup helpers for entity deletion
When an outbound or balancer is deleted on the Xray page, routing rules and
balancers that reference it must be repaired in the same edit, or the saved
config breaks the core: a dangling balancerTag stops Router.Init (whole core
down), a dangling outboundTag black-holes matched traffic at the dispatcher.
Add pure plan*/apply* helpers that compute and apply the cleanup. A rule is
kept when a destination (outboundTag or balancerTag) remains and dropped when
none does. Deleting an outbound cascades: emptying a balancer selector removes
that balancer too, then repairs its rules in one pass against the full removed
set; fallbackTag and dialerProxy references are cleared and observatories
re-synced.
* fix(balancers): clean routing rules referencing a deleted balancer
Deleting a balancer left routing rules pointing at its balancerTag. xray-core's
Router.Init then fails ("balancer <tag> not found"), the core won't restart and
every inbound drops — the saved config passes CheckXrayConfig (JSON shape only),
so it breaks only on the next restart.
The delete confirm now lists the affected rules (modified vs removed) next to
the existing observatory warning and applies planBalancerDeletion's cleanup: a
rule keeps its outboundTag when present, otherwise the whole rule is dropped.
Adds the shared DeletionImpactList and refCleanup strings across all 13 locales.
* fix(outbounds): clean rules, balancer selectors and dialerProxy on outbound delete
Deleting an outbound left routing rules pointing at its outboundTag (matched
traffic black-holed at the dispatcher), plus stale references in balancer
selectors / fallbackTag and other outbounds' dialerProxy.
The delete confirm now shows planOutboundDeletion's impact and applies the
cascade: rules keep a remaining balancerTag (else are dropped), the tag is
pulled from balancer selectors and fallbacks, dialerProxy references are
cleared, and a balancer whose selector is emptied is removed along with its
own now-targetless rules.
* refactor(xray): share one rule classifier across preview and apply
Code review flagged that the keep/drop predicate was transcribed twice — in
ruleImpacts (the delete-modal preview) and in applyCleanup (the mutation) — kept
in sync only by a parity test. Extract a single classifyRule() that both call,
so the preview can never disagree with what apply actually does.
Also harden balancersEmptiedBy to skip tagless balancers: an empty/missing tag
would otherwise enter the removed set as "" and silently drop every other
tagless balancer (only reachable via a hand-edited config, but a silent data
loss). And remove observersRemovedByDeletingBalancer, orphaned once BalancersTab
switched to planBalancerDeletion.
* fix(xray): null-guard reference cleanup against unvalidated configs
The PR review noted that classifyRule and applyCleanup dereferenced rule /
balancer entries directly, while the sibling propagateOutboundTagRename uses
optional chaining — because fetchXrayConfig falls back to the unvalidated parsed
object when Zod validation fails, a stray null in rules / balancers can survive
into the editor and would throw during the delete preview/apply.
Match that defensive style: classifyRule and balancersEmptiedBy read through
optional chaining, the balancer loop skips nullish entries, and the dialerProxy
walk guards the outbound. A delete on a hand-edited config with null entries now
degrades gracefully instead of throwing.
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { DeletionImpact } from './reference-cleanup';
|
||||
|
||||
interface DeletionImpactListProps {
|
||||
impact: DeletionImpact;
|
||||
}
|
||||
|
||||
export default function DeletionImpactList({ impact }: DeletionImpactListProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const lines: string[] = [];
|
||||
for (const rule of impact.rules) {
|
||||
lines.push(
|
||||
rule.fate === 'removed'
|
||||
? t('pages.xray.refCleanup.ruleRemoved', { label: rule.label })
|
||||
: t('pages.xray.refCleanup.ruleModified', { label: rule.label, keeps: rule.keeps ?? '' }),
|
||||
);
|
||||
}
|
||||
for (const balancer of impact.balancers) {
|
||||
lines.push(t('pages.xray.refCleanup.balancerRemoved', { tag: balancer.tag }));
|
||||
}
|
||||
if (impact.observatory) lines.push(t('pages.xray.observatory.deleteAlsoObservatory'));
|
||||
if (impact.burst) lines.push(t('pages.xray.observatory.deleteAlsoBurst'));
|
||||
|
||||
if (lines.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p style={{ marginBottom: 8 }}>{t('pages.xray.refCleanup.header')}</p>
|
||||
<ul style={{ margin: 0, paddingInlineStart: 20 }}>
|
||||
{lines.map((line, i) => (
|
||||
<li key={i}>
|
||||
<bdi>{line}</bdi>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,9 @@ import type { ColumnsType } from 'antd/es/table';
|
||||
|
||||
import BalancerFormModal from './BalancerFormModal';
|
||||
import type { BalancerFormValue } from './BalancerFormModal';
|
||||
import { syncObservatories, observersRemovedByDeletingBalancer } from './balancer-helpers';
|
||||
import { syncObservatories } from './balancer-helpers';
|
||||
import { planBalancerDeletion, applyBalancerDeletion } from '../reference-cleanup';
|
||||
import DeletionImpactList from '../DeletionImpactList';
|
||||
import ObservatorySettingsTab from './ObservatorySettingsTab';
|
||||
import { catTabLabel } from '@/pages/settings/catTabLabel';
|
||||
import { HttpUtil } from '@/utils';
|
||||
@@ -185,24 +187,16 @@ export default function BalancersTab({
|
||||
}
|
||||
|
||||
function confirmDelete(idx: number) {
|
||||
const removed = templateSettings
|
||||
? observersRemovedByDeletingBalancer(templateSettings, idx)
|
||||
: { observatory: false, burst: false };
|
||||
const warnings: string[] = [];
|
||||
if (removed.observatory) warnings.push(t('pages.xray.observatory.deleteAlsoObservatory'));
|
||||
if (removed.burst) warnings.push(t('pages.xray.observatory.deleteAlsoBurst'));
|
||||
const impact = templateSettings
|
||||
? planBalancerDeletion(templateSettings, idx)
|
||||
: { rules: [], balancers: [], observatory: false, burst: false };
|
||||
modal.confirm({
|
||||
title: `${t('delete')} ${t('pages.xray.Balancers')} #${idx + 1}?`,
|
||||
content: warnings.length ? warnings.join(' ') : undefined,
|
||||
content: <DeletionImpactList impact={impact} />,
|
||||
okText: t('delete'),
|
||||
okType: 'danger',
|
||||
cancelText: t('cancel'),
|
||||
onOk: () => mutate((tt) => {
|
||||
if (tt.routing?.balancers) {
|
||||
tt.routing.balancers.splice(idx, 1);
|
||||
syncObservatories(tt);
|
||||
}
|
||||
}),
|
||||
onOk: () => mutate((tt) => applyBalancerDeletion(tt, idx)),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -72,19 +72,3 @@ export function syncObservatories(t: XraySettingsValue) {
|
||||
delete t.burstObservatory;
|
||||
}
|
||||
}
|
||||
|
||||
export function observersRemovedByDeletingBalancer(
|
||||
t: XraySettingsValue,
|
||||
idx: number,
|
||||
): { observatory: boolean; burst: boolean } {
|
||||
const hadObservatory = !!t.observatory;
|
||||
const hadBurst = !!t.burstObservatory;
|
||||
if (!hadObservatory && !hadBurst) return { observatory: false, burst: false };
|
||||
const clone = JSON.parse(JSON.stringify(t)) as XraySettingsValue;
|
||||
if (clone.routing?.balancers) clone.routing.balancers.splice(idx, 1);
|
||||
syncObservatories(clone);
|
||||
return {
|
||||
observatory: hadObservatory && !clone.observatory,
|
||||
burst: hadBurst && !clone.burstObservatory,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -43,6 +43,8 @@ import TextModal from '@/components/feedback/TextModal';
|
||||
|
||||
import OutboundFormModal from './OutboundFormModal';
|
||||
import { propagateOutboundTagRename } from '../basics/helpers';
|
||||
import { planOutboundDeletion, applyOutboundDeletion } from '../reference-cleanup';
|
||||
import DeletionImpactList from '../DeletionImpactList';
|
||||
import type { XraySettingsValue, SetTemplate, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
|
||||
import './OutboundsTab.css';
|
||||
|
||||
@@ -208,16 +210,16 @@ export default function OutboundsTab({
|
||||
}
|
||||
|
||||
function confirmDelete(idx: number) {
|
||||
const impact = templateSettings
|
||||
? planOutboundDeletion(templateSettings, idx)
|
||||
: { rules: [], balancers: [], observatory: false, burst: false };
|
||||
modal.confirm({
|
||||
title: `${t('delete')} ${t('pages.xray.Outbounds')} #${idx + 1}?`,
|
||||
content: <DeletionImpactList impact={impact} />,
|
||||
okText: t('delete'),
|
||||
okType: 'danger',
|
||||
cancelText: t('cancel'),
|
||||
onOk: () => {
|
||||
mutate((tt) => {
|
||||
tt.outbounds?.splice(idx, 1);
|
||||
});
|
||||
},
|
||||
onOk: () => mutate((tt) => applyOutboundDeletion(tt, idx)),
|
||||
});
|
||||
}
|
||||
function setFirst(idx: number) {
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import type { XraySettingsValue } from '@/hooks/useXraySetting';
|
||||
import type { BalancerObject, RuleObject } from '@/schemas/routing';
|
||||
import { syncObservatories } from './balancers/balancer-helpers';
|
||||
|
||||
/**
|
||||
* Reference cleanup for the Xray-config blob: when an outbound or balancer is
|
||||
* deleted, routing rules and balancers that point at it must be repaired in the
|
||||
* same edit, or the saved config breaks the core (a dangling balancerTag stops
|
||||
* Router.Init; a dangling outboundTag black-holes matched traffic).
|
||||
*
|
||||
* Keep/drop a rule by its destination: after the deletion, a rule that still has
|
||||
* an outboundTag or balancerTag is kept (the dead reference is dropped); a rule
|
||||
* left with neither is removed, since a destination-less rule black-holes the
|
||||
* traffic it matches. Deleting an outbound cascades: if it empties a balancer's
|
||||
* selector, that balancer is removed too, and its rules are repaired the same way.
|
||||
*/
|
||||
|
||||
export type RuleFate = 'removed' | 'modified';
|
||||
|
||||
export interface RuleImpact {
|
||||
index: number;
|
||||
label: string;
|
||||
fate: RuleFate;
|
||||
keeps?: string;
|
||||
}
|
||||
|
||||
export interface BalancerImpact {
|
||||
tag: string;
|
||||
reason: 'selectorEmptied';
|
||||
}
|
||||
|
||||
export interface DeletionImpact {
|
||||
rules: RuleImpact[];
|
||||
balancers: BalancerImpact[];
|
||||
observatory: boolean;
|
||||
burst: boolean;
|
||||
}
|
||||
|
||||
const emptyImpact = (): DeletionImpact => ({ rules: [], balancers: [], observatory: false, burst: false });
|
||||
|
||||
function ruleList(tt: XraySettingsValue): RuleObject[] {
|
||||
const r = tt.routing?.rules;
|
||||
return Array.isArray(r) ? r : [];
|
||||
}
|
||||
|
||||
function balancerList(tt: XraySettingsValue): BalancerObject[] {
|
||||
const b = tt.routing?.balancers;
|
||||
return Array.isArray(b) ? b : [];
|
||||
}
|
||||
|
||||
function outboundTagAt(tt: XraySettingsValue, index: number): string {
|
||||
const o = tt.outbounds?.[index];
|
||||
return typeof o?.tag === 'string' ? o.tag : '';
|
||||
}
|
||||
|
||||
function balancerTagAt(tt: XraySettingsValue, index: number): string {
|
||||
const b = balancerList(tt)[index];
|
||||
return typeof b?.tag === 'string' ? b.tag : '';
|
||||
}
|
||||
|
||||
function ruleLabel(rule: RuleObject, index: number): string {
|
||||
const tag = typeof rule.ruleTag === 'string' ? rule.ruleTag.trim() : '';
|
||||
return tag || `#${index + 1}`;
|
||||
}
|
||||
|
||||
/** Balancers whose selector is left empty once `removedOutbounds` are gone. */
|
||||
function balancersEmptiedBy(tt: XraySettingsValue, removedOutbounds: Set<string>): string[] {
|
||||
if (removedOutbounds.size === 0) return [];
|
||||
const emptied: string[] = [];
|
||||
for (const b of balancerList(tt)) {
|
||||
const tag = typeof b?.tag === 'string' ? b.tag : '';
|
||||
if (tag === '') continue;
|
||||
const selector = Array.isArray(b?.selector) ? b.selector : [];
|
||||
if (selector.length === 0) continue;
|
||||
if (selector.every((s) => removedOutbounds.has(s))) emptied.push(tag);
|
||||
}
|
||||
return emptied;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single source of truth for how a deletion affects one rule, shared by the
|
||||
* preview (`ruleImpacts`) and the mutation (`applyCleanup`) so the two can never
|
||||
* disagree. Returns null when the rule is untouched; otherwise `keeps` names the
|
||||
* surviving destination, or is '' when none remains and the rule must be dropped.
|
||||
*/
|
||||
function classifyRule(
|
||||
rule: RuleObject,
|
||||
removedOutbounds: Set<string>,
|
||||
removedBalancers: Set<string>,
|
||||
): { losesOut: boolean; losesBal: boolean; keeps: string } | null {
|
||||
const out = typeof rule?.outboundTag === 'string' ? rule.outboundTag : '';
|
||||
const bal = typeof rule?.balancerTag === 'string' ? rule.balancerTag : '';
|
||||
const losesOut = out !== '' && removedOutbounds.has(out);
|
||||
const losesBal = bal !== '' && removedBalancers.has(bal);
|
||||
if (!losesOut && !losesBal) return null;
|
||||
const keptOut = out !== '' && !losesOut ? out : '';
|
||||
const keptBal = bal !== '' && !losesBal ? bal : '';
|
||||
return { losesOut, losesBal, keeps: keptOut || keptBal };
|
||||
}
|
||||
|
||||
function ruleImpacts(
|
||||
tt: XraySettingsValue,
|
||||
removedOutbounds: Set<string>,
|
||||
removedBalancers: Set<string>,
|
||||
): RuleImpact[] {
|
||||
const impacts: RuleImpact[] = [];
|
||||
ruleList(tt).forEach((rule, index) => {
|
||||
const verdict = classifyRule(rule, removedOutbounds, removedBalancers);
|
||||
if (!verdict) return;
|
||||
impacts.push(
|
||||
verdict.keeps
|
||||
? { index, label: ruleLabel(rule, index), fate: 'modified', keeps: verdict.keeps }
|
||||
: { index, label: ruleLabel(rule, index), fate: 'removed' },
|
||||
);
|
||||
});
|
||||
return impacts;
|
||||
}
|
||||
|
||||
function applyCleanup(
|
||||
tt: XraySettingsValue,
|
||||
removedOutbounds: Set<string>,
|
||||
removedBalancers: Set<string>,
|
||||
): void {
|
||||
if (tt.routing && Array.isArray(tt.routing.rules)) {
|
||||
const next: RuleObject[] = [];
|
||||
for (const rule of tt.routing.rules) {
|
||||
const verdict = classifyRule(rule, removedOutbounds, removedBalancers);
|
||||
if (!verdict) {
|
||||
next.push(rule);
|
||||
continue;
|
||||
}
|
||||
if (verdict.losesOut) delete rule.outboundTag;
|
||||
if (verdict.losesBal) delete rule.balancerTag;
|
||||
if (verdict.keeps) next.push(rule);
|
||||
}
|
||||
tt.routing.rules = next;
|
||||
}
|
||||
|
||||
if (tt.routing && Array.isArray(tt.routing.balancers)) {
|
||||
const survivors: BalancerObject[] = [];
|
||||
for (const balancer of tt.routing.balancers) {
|
||||
if (!balancer) continue;
|
||||
if (removedBalancers.has(balancer.tag)) continue;
|
||||
if (removedOutbounds.size > 0 && Array.isArray(balancer.selector)) {
|
||||
balancer.selector = balancer.selector.filter((s) => !removedOutbounds.has(s));
|
||||
}
|
||||
if (typeof balancer.fallbackTag === 'string' && removedOutbounds.has(balancer.fallbackTag)) {
|
||||
balancer.fallbackTag = '';
|
||||
}
|
||||
survivors.push(balancer);
|
||||
}
|
||||
tt.routing.balancers = survivors;
|
||||
}
|
||||
|
||||
if (removedOutbounds.size > 0 && Array.isArray(tt.outbounds)) {
|
||||
tt.outbounds = tt.outbounds.filter(
|
||||
(o) => !(typeof o?.tag === 'string' && removedOutbounds.has(o.tag)),
|
||||
);
|
||||
for (const outbound of tt.outbounds) {
|
||||
const sockopt = (outbound as { streamSettings?: { sockopt?: { dialerProxy?: string } } })
|
||||
?.streamSettings?.sockopt;
|
||||
if (sockopt && typeof sockopt.dialerProxy === 'string' && removedOutbounds.has(sockopt.dialerProxy)) {
|
||||
delete sockopt.dialerProxy;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
syncObservatories(tt);
|
||||
}
|
||||
|
||||
function observersRemovedBy(
|
||||
tt: XraySettingsValue,
|
||||
removedOutbounds: Set<string>,
|
||||
removedBalancers: Set<string>,
|
||||
): { observatory: boolean; burst: boolean } {
|
||||
const hadObservatory = !!tt.observatory;
|
||||
const hadBurst = !!tt.burstObservatory;
|
||||
if (!hadObservatory && !hadBurst) return { observatory: false, burst: false };
|
||||
const clone = JSON.parse(JSON.stringify(tt)) as XraySettingsValue;
|
||||
applyCleanup(clone, removedOutbounds, removedBalancers);
|
||||
return {
|
||||
observatory: hadObservatory && !clone.observatory,
|
||||
burst: hadBurst && !clone.burstObservatory,
|
||||
};
|
||||
}
|
||||
|
||||
export function planBalancerDeletion(tt: XraySettingsValue, index: number): DeletionImpact {
|
||||
const tag = balancerTagAt(tt, index);
|
||||
if (!tag) return emptyImpact();
|
||||
const removedOutbounds = new Set<string>();
|
||||
const removedBalancers = new Set([tag]);
|
||||
const obs = observersRemovedBy(tt, removedOutbounds, removedBalancers);
|
||||
return {
|
||||
rules: ruleImpacts(tt, removedOutbounds, removedBalancers),
|
||||
balancers: [],
|
||||
observatory: obs.observatory,
|
||||
burst: obs.burst,
|
||||
};
|
||||
}
|
||||
|
||||
export function applyBalancerDeletion(tt: XraySettingsValue, index: number): void {
|
||||
const tag = balancerTagAt(tt, index);
|
||||
if (!tag) {
|
||||
if (tt.routing && Array.isArray(tt.routing.balancers)) tt.routing.balancers.splice(index, 1);
|
||||
syncObservatories(tt);
|
||||
return;
|
||||
}
|
||||
applyCleanup(tt, new Set<string>(), new Set([tag]));
|
||||
}
|
||||
|
||||
export function planOutboundDeletion(tt: XraySettingsValue, index: number): DeletionImpact {
|
||||
const tag = outboundTagAt(tt, index);
|
||||
if (!tag) return emptyImpact();
|
||||
const removedOutbounds = new Set([tag]);
|
||||
const cascaded = balancersEmptiedBy(tt, removedOutbounds);
|
||||
const removedBalancers = new Set(cascaded);
|
||||
const obs = observersRemovedBy(tt, removedOutbounds, removedBalancers);
|
||||
return {
|
||||
rules: ruleImpacts(tt, removedOutbounds, removedBalancers),
|
||||
balancers: cascaded.map((bTag) => ({ tag: bTag, reason: 'selectorEmptied' as const })),
|
||||
observatory: obs.observatory,
|
||||
burst: obs.burst,
|
||||
};
|
||||
}
|
||||
|
||||
export function applyOutboundDeletion(tt: XraySettingsValue, index: number): void {
|
||||
const tag = outboundTagAt(tt, index);
|
||||
if (!tag) {
|
||||
if (Array.isArray(tt.outbounds)) tt.outbounds.splice(index, 1);
|
||||
syncObservatories(tt);
|
||||
return;
|
||||
}
|
||||
const removedOutbounds = new Set([tag]);
|
||||
const removedBalancers = new Set(balancersEmptiedBy(tt, removedOutbounds));
|
||||
applyCleanup(tt, removedOutbounds, removedBalancers);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { observersRemovedByDeletingBalancer, syncObservatories } from '@/pages/xray/balancers/balancer-helpers';
|
||||
import { syncObservatories } from '@/pages/xray/balancers/balancer-helpers';
|
||||
import type { XraySettingsValue } from '@/hooks/useXraySetting';
|
||||
|
||||
function tpl(routing: Record<string, unknown>, extra: Record<string, unknown> = {}): XraySettingsValue {
|
||||
@@ -138,42 +138,3 @@ describe('syncObservatories', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('observersRemovedByDeletingBalancer', () => {
|
||||
it('reports the burst observer as removed when deleting the last leastLoad balancer', () => {
|
||||
const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'leastLoad' } }] });
|
||||
syncObservatories(t);
|
||||
expect(observersRemovedByDeletingBalancer(t, 0)).toEqual({ observatory: false, burst: true });
|
||||
});
|
||||
|
||||
it('keeps the burst observer when another balancer still needs it (overlap)', () => {
|
||||
const t = tpl({
|
||||
balancers: [
|
||||
{ tag: 'a', selector: ['prefixA', 'prefixB'], strategy: { type: 'leastLoad' } },
|
||||
{ tag: 'b', selector: ['prefixC', 'prefixB'], strategy: { type: 'leastLoad' } },
|
||||
],
|
||||
});
|
||||
syncObservatories(t);
|
||||
expect(observersRemovedByDeletingBalancer(t, 0)).toEqual({ observatory: false, burst: false });
|
||||
});
|
||||
|
||||
it('reports the regular observer as removed when deleting the last leastPing balancer', () => {
|
||||
const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'leastPing' } }] });
|
||||
syncObservatories(t);
|
||||
expect(observersRemovedByDeletingBalancer(t, 0)).toEqual({ observatory: true, burst: false });
|
||||
});
|
||||
|
||||
it('reports nothing removed when the balancer never had an observer', () => {
|
||||
const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'] }] });
|
||||
syncObservatories(t);
|
||||
expect(observersRemovedByDeletingBalancer(t, 0)).toEqual({ observatory: false, burst: false });
|
||||
});
|
||||
|
||||
it('does not mutate the template it inspects', () => {
|
||||
const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'leastLoad' } }] });
|
||||
syncObservatories(t);
|
||||
const before = JSON.stringify(t);
|
||||
observersRemovedByDeletingBalancer(t, 0);
|
||||
expect(JSON.stringify(t)).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
applyBalancerDeletion,
|
||||
applyOutboundDeletion,
|
||||
planBalancerDeletion,
|
||||
planOutboundDeletion,
|
||||
} from '@/pages/xray/reference-cleanup';
|
||||
import type { XraySettingsValue } from '@/hooks/useXraySetting';
|
||||
|
||||
function tpl(parts: Record<string, unknown>): XraySettingsValue {
|
||||
return parts as unknown as XraySettingsValue;
|
||||
}
|
||||
|
||||
function dialerProxyOf(tt: XraySettingsValue, tag: string): string | undefined {
|
||||
const o = tt.outbounds?.find((x) => x?.tag === tag);
|
||||
return (o as { streamSettings?: { sockopt?: { dialerProxy?: string } } } | undefined)
|
||||
?.streamSettings?.sockopt?.dialerProxy;
|
||||
}
|
||||
|
||||
describe('outbound deletion', () => {
|
||||
it('drops a rule whose only destination was the deleted outbound', () => {
|
||||
const tt = tpl({
|
||||
outbounds: [{ tag: 'proxy-us' }],
|
||||
routing: { rules: [{ type: 'field', inboundTag: ['in-443'], outboundTag: 'proxy-us' }], balancers: [] },
|
||||
});
|
||||
applyOutboundDeletion(tt, 0);
|
||||
expect(tt.routing!.rules).toEqual([]);
|
||||
expect(tt.outbounds).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps a rule that still has a balancer, dropping only the dead outboundTag', () => {
|
||||
const tt = tpl({
|
||||
outbounds: [{ tag: 'proxy-us' }],
|
||||
routing: {
|
||||
rules: [{ type: 'field', outboundTag: 'proxy-us', balancerTag: 'eu-pool' }],
|
||||
balancers: [{ tag: 'eu-pool', selector: ['direct'] }],
|
||||
},
|
||||
});
|
||||
applyOutboundDeletion(tt, 0);
|
||||
expect(tt.routing!.rules).toHaveLength(1);
|
||||
expect(tt.routing!.rules![0].outboundTag).toBeUndefined();
|
||||
expect(tt.routing!.rules![0].balancerTag).toBe('eu-pool');
|
||||
});
|
||||
|
||||
it('reduces a multi-target selector and leaves the balancer and its rules intact', () => {
|
||||
const tt = tpl({
|
||||
outbounds: [{ tag: 'proxy-us' }, { tag: 'proxy-uk' }],
|
||||
routing: {
|
||||
rules: [{ type: 'field', inboundTag: ['in'], balancerTag: 'pool' }],
|
||||
balancers: [{ tag: 'pool', selector: ['proxy-us', 'proxy-uk'] }],
|
||||
},
|
||||
});
|
||||
applyOutboundDeletion(tt, 0);
|
||||
expect(tt.routing!.balancers![0].selector).toEqual(['proxy-uk']);
|
||||
expect(tt.routing!.rules).toHaveLength(1);
|
||||
expect(tt.routing!.rules![0].balancerTag).toBe('pool');
|
||||
expect((tt.outbounds || []).map((o) => o?.tag)).toEqual(['proxy-uk']);
|
||||
});
|
||||
|
||||
it('cascade-removes a balancer whose selector is emptied, repairing its rules', () => {
|
||||
const tt = tpl({
|
||||
outbounds: [{ tag: 'proxy-us' }],
|
||||
routing: {
|
||||
rules: [
|
||||
{ type: 'field', inboundTag: ['in'], balancerTag: 'pool' },
|
||||
{ type: 'field', outboundTag: 'direct', balancerTag: 'pool' },
|
||||
],
|
||||
balancers: [{ tag: 'pool', selector: ['proxy-us'] }],
|
||||
},
|
||||
});
|
||||
const impact = planOutboundDeletion(tt, 0);
|
||||
expect(impact.balancers).toEqual([{ tag: 'pool', reason: 'selectorEmptied' }]);
|
||||
expect(impact.rules).toEqual([
|
||||
{ index: 0, label: '#1', fate: 'removed' },
|
||||
{ index: 1, label: '#2', fate: 'modified', keeps: 'direct' },
|
||||
]);
|
||||
|
||||
applyOutboundDeletion(tt, 0);
|
||||
expect(tt.routing!.balancers).toEqual([]);
|
||||
expect(tt.routing!.rules).toHaveLength(1);
|
||||
expect(tt.routing!.rules![0].outboundTag).toBe('direct');
|
||||
expect(tt.routing!.rules![0].balancerTag).toBeUndefined();
|
||||
});
|
||||
|
||||
it('clears a fallbackTag and a dialerProxy pointing at the deleted outbound', () => {
|
||||
const tt = tpl({
|
||||
outbounds: [
|
||||
{ tag: 'proxy-us' },
|
||||
{ tag: 'chain', streamSettings: { sockopt: { dialerProxy: 'proxy-us' } } },
|
||||
],
|
||||
routing: {
|
||||
rules: [],
|
||||
balancers: [{ tag: 'pool', selector: ['proxy-us', 'proxy-uk'], fallbackTag: 'proxy-us' }],
|
||||
},
|
||||
});
|
||||
applyOutboundDeletion(tt, 0);
|
||||
expect(tt.routing!.balancers![0].selector).toEqual(['proxy-uk']);
|
||||
expect(tt.routing!.balancers![0].fallbackTag).toBe('');
|
||||
expect(dialerProxyOf(tt, 'chain')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('never cascade-removes a tagless balancer (an empty tag must not match others)', () => {
|
||||
const tt = tpl({
|
||||
outbounds: [{ tag: 'proxy-us' }],
|
||||
routing: {
|
||||
rules: [],
|
||||
balancers: [
|
||||
{ tag: '', selector: ['proxy-us'] },
|
||||
{ tag: '', selector: ['keep-me'] },
|
||||
],
|
||||
},
|
||||
});
|
||||
applyOutboundDeletion(tt, 0);
|
||||
expect(tt.routing!.balancers).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('does not throw on null entries in rules/balancers/outbounds (unvalidated config)', () => {
|
||||
const tt = tpl({
|
||||
outbounds: [{ tag: 'proxy-us' }, null],
|
||||
routing: {
|
||||
rules: [null, { type: 'field', inboundTag: ['in'], outboundTag: 'proxy-us' }],
|
||||
balancers: [null, { tag: 'pool', selector: ['keep'] }],
|
||||
},
|
||||
});
|
||||
expect(() => planOutboundDeletion(tt, 0)).not.toThrow();
|
||||
expect(() => applyOutboundDeletion(tt, 0)).not.toThrow();
|
||||
expect(tt.routing!.balancers).toEqual([{ tag: 'pool', selector: ['keep'] }]);
|
||||
});
|
||||
|
||||
it('drops a rule that loses BOTH destinations in one cascade', () => {
|
||||
const tt = tpl({
|
||||
outbounds: [{ tag: 'proxy-us' }],
|
||||
routing: {
|
||||
rules: [{ type: 'field', outboundTag: 'proxy-us', balancerTag: 'pool' }],
|
||||
balancers: [{ tag: 'pool', selector: ['proxy-us'] }],
|
||||
},
|
||||
});
|
||||
applyOutboundDeletion(tt, 0);
|
||||
expect(tt.routing!.rules).toEqual([]);
|
||||
expect(tt.routing!.balancers).toEqual([]);
|
||||
});
|
||||
|
||||
it('cleans a disabled rule too', () => {
|
||||
const tt = tpl({
|
||||
outbounds: [{ tag: 'proxy-us' }],
|
||||
routing: {
|
||||
rules: [{ type: 'field', enabled: false, inboundTag: ['in'], outboundTag: 'proxy-us' }],
|
||||
balancers: [],
|
||||
},
|
||||
});
|
||||
applyOutboundDeletion(tt, 0);
|
||||
expect(tt.routing!.rules).toEqual([]);
|
||||
});
|
||||
|
||||
it('leaves unrelated rules and outbounds untouched', () => {
|
||||
const tt = tpl({
|
||||
outbounds: [{ tag: 'proxy-us' }, { tag: 'direct' }],
|
||||
routing: {
|
||||
rules: [
|
||||
{ type: 'field', inboundTag: ['in'], outboundTag: 'proxy-us' },
|
||||
{ type: 'field', inboundTag: ['in2'], outboundTag: 'direct' },
|
||||
],
|
||||
balancers: [],
|
||||
},
|
||||
});
|
||||
applyOutboundDeletion(tt, 0);
|
||||
expect(tt.routing!.rules).toHaveLength(1);
|
||||
expect(tt.routing!.rules![0].outboundTag).toBe('direct');
|
||||
expect((tt.outbounds || []).map((o) => o?.tag)).toEqual(['direct']);
|
||||
});
|
||||
|
||||
it('removes a referenced outbound with no rules and reports an empty impact', () => {
|
||||
const tt = tpl({ outbounds: [{ tag: 'lonely' }], routing: { rules: [], balancers: [] } });
|
||||
expect(planOutboundDeletion(tt, 0)).toEqual({ rules: [], balancers: [], observatory: false, burst: false });
|
||||
applyOutboundDeletion(tt, 0);
|
||||
expect(tt.outbounds).toEqual([]);
|
||||
});
|
||||
|
||||
it('uses ruleTag as the impact label when present', () => {
|
||||
const tt = tpl({
|
||||
outbounds: [{ tag: 'x' }],
|
||||
routing: { rules: [{ type: 'field', ruleTag: 'block-ads', outboundTag: 'x' }], balancers: [] },
|
||||
});
|
||||
expect(planOutboundDeletion(tt, 0).rules[0].label).toBe('block-ads');
|
||||
});
|
||||
|
||||
it('does not mutate the template when only planning', () => {
|
||||
const tt = tpl({
|
||||
outbounds: [{ tag: 'proxy-us' }],
|
||||
routing: {
|
||||
rules: [{ type: 'field', outboundTag: 'proxy-us', balancerTag: 'pool' }],
|
||||
balancers: [{ tag: 'pool', selector: ['proxy-us'] }],
|
||||
},
|
||||
burstObservatory: { subjectSelector: ['proxy-us'] },
|
||||
});
|
||||
const before = JSON.stringify(tt);
|
||||
planOutboundDeletion(tt, 0);
|
||||
expect(JSON.stringify(tt)).toBe(before);
|
||||
});
|
||||
|
||||
it('predicts the surviving rule count exactly (plan/apply parity)', () => {
|
||||
const make = () =>
|
||||
tpl({
|
||||
outbounds: [{ tag: 'proxy-us' }],
|
||||
routing: {
|
||||
rules: [
|
||||
{ type: 'field', inboundTag: ['a'], outboundTag: 'proxy-us' },
|
||||
{ type: 'field', outboundTag: 'proxy-us', balancerTag: 'pool' },
|
||||
{ type: 'field', inboundTag: ['b'], outboundTag: 'direct' },
|
||||
{ type: 'field', inboundTag: ['c'], balancerTag: 'pool' },
|
||||
],
|
||||
balancers: [{ tag: 'pool', selector: ['proxy-us'] }],
|
||||
},
|
||||
});
|
||||
const planned = make();
|
||||
const applied = make();
|
||||
const total = planned.routing!.rules!.length;
|
||||
const removed = planOutboundDeletion(planned, 0).rules.filter((r) => r.fate === 'removed').length;
|
||||
applyOutboundDeletion(applied, 0);
|
||||
expect(applied.routing!.rules!.length).toBe(total - removed);
|
||||
});
|
||||
});
|
||||
|
||||
describe('balancer deletion', () => {
|
||||
it('drops a rule whose only destination was the deleted balancer', () => {
|
||||
const tt = tpl({
|
||||
routing: { rules: [{ type: 'field', inboundTag: ['in'], balancerTag: 'pool' }], balancers: [{ tag: 'pool', selector: ['a'] }] },
|
||||
});
|
||||
applyBalancerDeletion(tt, 0);
|
||||
expect(tt.routing!.balancers).toEqual([]);
|
||||
expect(tt.routing!.rules).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps a rule that still has an outbound, dropping only the dead balancerTag', () => {
|
||||
const tt = tpl({
|
||||
routing: { rules: [{ type: 'field', outboundTag: 'direct', balancerTag: 'pool' }], balancers: [{ tag: 'pool', selector: ['a'] }] },
|
||||
});
|
||||
applyBalancerDeletion(tt, 0);
|
||||
expect(tt.routing!.rules).toHaveLength(1);
|
||||
expect(tt.routing!.rules![0].balancerTag).toBeUndefined();
|
||||
expect(tt.routing!.rules![0].outboundTag).toBe('direct');
|
||||
});
|
||||
|
||||
it('reports and removes the observer when deleting the last leastPing balancer', () => {
|
||||
const tt = tpl({
|
||||
routing: { rules: [], balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'leastPing' } }] },
|
||||
observatory: { subjectSelector: ['a'] },
|
||||
});
|
||||
expect(planBalancerDeletion(tt, 0).observatory).toBe(true);
|
||||
applyBalancerDeletion(tt, 0);
|
||||
expect(tt.observatory).toBeUndefined();
|
||||
expect(tt.routing!.balancers).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not report rules when the deleted balancer is unreferenced', () => {
|
||||
const tt = tpl({
|
||||
routing: { rules: [{ type: 'field', inboundTag: ['in'], outboundTag: 'direct' }], balancers: [{ tag: 'pool', selector: ['a'] }] },
|
||||
});
|
||||
expect(planBalancerDeletion(tt, 0).rules).toEqual([]);
|
||||
applyBalancerDeletion(tt, 0);
|
||||
expect(tt.routing!.rules).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user