From 7a5d6da28c028cbba5692915a495477f8e576476 Mon Sep 17 00:00:00 2001 From: nima1024m <114405577+nima1024m@users.noreply.github.com> Date: Mon, 29 Jun 2026 12:52:18 +0200 Subject: [PATCH] fix(xray): clean stale routing references when a balancer or outbound is deleted (#5648) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 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. --- .../src/pages/xray/DeletionImpactList.tsx | 40 +++ .../src/pages/xray/balancers/BalancersTab.tsx | 22 +- .../pages/xray/balancers/balancer-helpers.ts | 16 -- .../src/pages/xray/outbounds/OutboundsTab.tsx | 12 +- frontend/src/pages/xray/reference-cleanup.ts | 236 ++++++++++++++++ .../test/balancer-observatory-sync.test.ts | 41 +-- .../test/routing-reference-cleanup.test.ts | 264 ++++++++++++++++++ internal/web/translation/ar-EG.json | 6 + internal/web/translation/en-US.json | 6 + internal/web/translation/es-ES.json | 6 + internal/web/translation/fa-IR.json | 6 + internal/web/translation/id-ID.json | 6 + internal/web/translation/ja-JP.json | 6 + internal/web/translation/pt-BR.json | 6 + internal/web/translation/ru-RU.json | 6 + internal/web/translation/tr-TR.json | 6 + internal/web/translation/uk-UA.json | 6 + internal/web/translation/vi-VN.json | 6 + internal/web/translation/zh-CN.json | 6 + internal/web/translation/zh-TW.json | 6 + 20 files changed, 634 insertions(+), 75 deletions(-) create mode 100644 frontend/src/pages/xray/DeletionImpactList.tsx create mode 100644 frontend/src/pages/xray/reference-cleanup.ts create mode 100644 frontend/src/test/routing-reference-cleanup.test.ts diff --git a/frontend/src/pages/xray/DeletionImpactList.tsx b/frontend/src/pages/xray/DeletionImpactList.tsx new file mode 100644 index 000000000..fbb546ff3 --- /dev/null +++ b/frontend/src/pages/xray/DeletionImpactList.tsx @@ -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 ( +
+

{t('pages.xray.refCleanup.header')}

+ +
+ ); +} diff --git a/frontend/src/pages/xray/balancers/BalancersTab.tsx b/frontend/src/pages/xray/balancers/BalancersTab.tsx index 269c50abf..338b12429 100644 --- a/frontend/src/pages/xray/balancers/BalancersTab.tsx +++ b/frontend/src/pages/xray/balancers/BalancersTab.tsx @@ -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: , 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)), }); } diff --git a/frontend/src/pages/xray/balancers/balancer-helpers.ts b/frontend/src/pages/xray/balancers/balancer-helpers.ts index eb53d1a2c..a692f70d2 100644 --- a/frontend/src/pages/xray/balancers/balancer-helpers.ts +++ b/frontend/src/pages/xray/balancers/balancer-helpers.ts @@ -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, - }; -} diff --git a/frontend/src/pages/xray/outbounds/OutboundsTab.tsx b/frontend/src/pages/xray/outbounds/OutboundsTab.tsx index 976282add..890df06cc 100644 --- a/frontend/src/pages/xray/outbounds/OutboundsTab.tsx +++ b/frontend/src/pages/xray/outbounds/OutboundsTab.tsx @@ -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: , 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) { diff --git a/frontend/src/pages/xray/reference-cleanup.ts b/frontend/src/pages/xray/reference-cleanup.ts new file mode 100644 index 000000000..25991ae32 --- /dev/null +++ b/frontend/src/pages/xray/reference-cleanup.ts @@ -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[] { + 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, + removedBalancers: Set, +): { 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, + removedBalancers: Set, +): 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, + removedBalancers: Set, +): 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, + removedBalancers: Set, +): { 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(); + 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(), 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); +} diff --git a/frontend/src/test/balancer-observatory-sync.test.ts b/frontend/src/test/balancer-observatory-sync.test.ts index f4c00dcc2..40cfd4af9 100644 --- a/frontend/src/test/balancer-observatory-sync.test.ts +++ b/frontend/src/test/balancer-observatory-sync.test.ts @@ -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, extra: Record = {}): 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); - }); -}); diff --git a/frontend/src/test/routing-reference-cleanup.test.ts b/frontend/src/test/routing-reference-cleanup.test.ts new file mode 100644 index 000000000..277bd5ea0 --- /dev/null +++ b/frontend/src/test/routing-reference-cleanup.test.ts @@ -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): 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); + }); +}); diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index 5feaf5f88..5ede9ede8 100644 --- a/internal/web/translation/ar-EG.json +++ b/internal/web/translation/ar-EG.json @@ -1709,6 +1709,12 @@ "deleteAlsoObservatory": "هذا آخر موازن يستخدم Observatory، لذلك ستتم إزالته أيضًا.", "deleteAlsoBurst": "هذا آخر موازن يستخدم Burst Observatory، لذلك ستتم إزالته أيضًا." }, + "refCleanup": { + "header": "حذف هذا سيُحدِّث التوجيه أيضًا:", + "ruleRemoved": "القاعدة {label} — أُزيلت (لا توجد وجهة متبقية)", + "ruleModified": "القاعدة {label} — مُحتفَظ بها (تستخدم الآن {keeps})", + "balancerRemoved": "الموازن {tag} — أُزيل (لا توجد أهداف متبقية)" + }, "balancer": { "addBalancer": "أضف موازن تحميل", "editBalancer": "عدل موازن التحميل", diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index fbd228491..8e02f8df8 100644 --- a/internal/web/translation/en-US.json +++ b/internal/web/translation/en-US.json @@ -1825,6 +1825,12 @@ "deleteAlsoObservatory": "This is the last balancer using the Observatory, so it will be removed too.", "deleteAlsoBurst": "This is the last balancer using the Burst Observatory, so it will be removed too." }, + "refCleanup": { + "header": "Deleting this also updates your routing:", + "ruleRemoved": "Rule {label} — removed (no destination left)", + "ruleModified": "Rule {label} — kept (now uses {keeps})", + "balancerRemoved": "Balancer {tag} — removed (no targets left)" + }, "balancer": { "addBalancer": "Add Balancer", "editBalancer": "Edit Balancer", diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index c8b812bd6..9eef966fd 100644 --- a/internal/web/translation/es-ES.json +++ b/internal/web/translation/es-ES.json @@ -1709,6 +1709,12 @@ "deleteAlsoObservatory": "Este es el último balanceador que usa el Observatorio, por lo que también se eliminará.", "deleteAlsoBurst": "Este es el último balanceador que usa el Observatorio Burst, por lo que también se eliminará." }, + "refCleanup": { + "header": "Al eliminar esto también se actualiza tu enrutamiento:", + "ruleRemoved": "Regla {label} — eliminada (sin destino restante)", + "ruleModified": "Regla {label} — conservada (ahora usa {keeps})", + "balancerRemoved": "Balanceador {tag} — eliminado (sin destinos restantes)" + }, "balancer": { "addBalancer": "Agregar equilibrador", "editBalancer": "Editar balanceador", diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json index d1feb690d..2f10f7776 100644 --- a/internal/web/translation/fa-IR.json +++ b/internal/web/translation/fa-IR.json @@ -1709,6 +1709,12 @@ "deleteAlsoObservatory": "این آخرین بالانسری است که از Observatory استفاده می‌کند، بنابراین آن هم حذف خواهد شد.", "deleteAlsoBurst": "این آخرین بالانسری است که از Burst Observatory استفاده می‌کند، بنابراین آن هم حذف خواهد شد." }, + "refCleanup": { + "header": "حذف این مورد مسیریابی شما را هم به‌روز می‌کند:", + "ruleRemoved": "قاعده {label} — حذف شد (مقصدی باقی نماند)", + "ruleModified": "قاعده {label} — حفظ شد (اکنون از {keeps} استفاده می‌کند)", + "balancerRemoved": "بالانسر {tag} — حذف شد (هدفی باقی نماند)" + }, "balancer": { "addBalancer": "افزودن بالانسر", "editBalancer": "ویرایش بالانسر", diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json index 36711af06..ce9e8ac0c 100644 --- a/internal/web/translation/id-ID.json +++ b/internal/web/translation/id-ID.json @@ -1709,6 +1709,12 @@ "deleteAlsoObservatory": "Ini balancer terakhir yang memakai Observatory, jadi itu juga akan dihapus.", "deleteAlsoBurst": "Ini balancer terakhir yang memakai Burst Observatory, jadi itu juga akan dihapus." }, + "refCleanup": { + "header": "Menghapus ini juga memperbarui perutean Anda:", + "ruleRemoved": "Aturan {label} — dihapus (tidak ada tujuan tersisa)", + "ruleModified": "Aturan {label} — dipertahankan (kini memakai {keeps})", + "balancerRemoved": "Balancer {tag} — dihapus (tidak ada target tersisa)" + }, "balancer": { "addBalancer": "Tambahkan Penyeimbang", "editBalancer": "Sunting Penyeimbang", diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json index 8e6d87885..a00f9a5fd 100644 --- a/internal/web/translation/ja-JP.json +++ b/internal/web/translation/ja-JP.json @@ -1709,6 +1709,12 @@ "deleteAlsoObservatory": "これは Observatory を使用する最後のバランサーのため、こちらも削除されます。", "deleteAlsoBurst": "これは Burst Observatory を使用する最後のバランサーのため、こちらも削除されます。" }, + "refCleanup": { + "header": "これを削除するとルーティングも更新されます:", + "ruleRemoved": "ルール {label} — 削除(送信先が残っていません)", + "ruleModified": "ルール {label} — 保持(現在は {keeps} を使用)", + "balancerRemoved": "バランサー {tag} — 削除(対象が残っていません)" + }, "balancer": { "addBalancer": "負荷分散追加", "editBalancer": "負荷分散編集", diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json index 90bcad1a8..68f16ea22 100644 --- a/internal/web/translation/pt-BR.json +++ b/internal/web/translation/pt-BR.json @@ -1709,6 +1709,12 @@ "deleteAlsoObservatory": "Este é o último balanceador que usa o Observatório, então ele também será removido.", "deleteAlsoBurst": "Este é o último balanceador que usa o Observatório Burst, então ele também será removido." }, + "refCleanup": { + "header": "Excluir isto também atualiza o seu roteamento:", + "ruleRemoved": "Regra {label} — removida (sem destino restante)", + "ruleModified": "Regra {label} — mantida (agora usa {keeps})", + "balancerRemoved": "Balanceador {tag} — removido (sem destinos restantes)" + }, "balancer": { "addBalancer": "Adicionar Balanceador", "editBalancer": "Editar Balanceador", diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index b7267e4eb..405f58693 100644 --- a/internal/web/translation/ru-RU.json +++ b/internal/web/translation/ru-RU.json @@ -1709,6 +1709,12 @@ "deleteAlsoObservatory": "Это последний балансировщик, использующий Observatory, поэтому он тоже будет удалён.", "deleteAlsoBurst": "Это последний балансировщик, использующий Burst Observatory, поэтому он тоже будет удалён." }, + "refCleanup": { + "header": "Удаление также обновит маршрутизацию:", + "ruleRemoved": "Правило {label} — удалено (не осталось назначения)", + "ruleModified": "Правило {label} — сохранено (теперь использует {keeps})", + "balancerRemoved": "Балансировщик {tag} — удалён (не осталось целей)" + }, "balancer": { "addBalancer": "Создать балансировщик", "editBalancer": "Редактировать балансировщик", diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json index fc4b5a88b..6791bf8c1 100644 --- a/internal/web/translation/tr-TR.json +++ b/internal/web/translation/tr-TR.json @@ -1709,6 +1709,12 @@ "deleteAlsoObservatory": "Bu, Observatory kullanan son dengeleyici, bu yüzden o da kaldırılacak.", "deleteAlsoBurst": "Bu, Burst Observatory kullanan son dengeleyici, bu yüzden o da kaldırılacak." }, + "refCleanup": { + "header": "Bunu silmek yönlendirmenizi de günceller:", + "ruleRemoved": "Kural {label} — kaldırıldı (hedef kalmadı)", + "ruleModified": "Kural {label} — korundu (artık {keeps} kullanıyor)", + "balancerRemoved": "Dengeleyici {tag} — kaldırıldı (hedef kalmadı)" + }, "balancer": { "addBalancer": "Dengeleyici Ekle", "editBalancer": "Dengeleyiciyi Düzenle", diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index d41857638..23567981b 100644 --- a/internal/web/translation/uk-UA.json +++ b/internal/web/translation/uk-UA.json @@ -1709,6 +1709,12 @@ "deleteAlsoObservatory": "Це останній балансувальник, що використовує Observatory, тож його теж буде видалено.", "deleteAlsoBurst": "Це останній балансувальник, що використовує Burst Observatory, тож його теж буде видалено." }, + "refCleanup": { + "header": "Видалення також оновить маршрутизацію:", + "ruleRemoved": "Правило {label} — видалено (не залишилося призначення)", + "ruleModified": "Правило {label} — збережено (тепер використовує {keeps})", + "balancerRemoved": "Балансувальник {tag} — видалено (не залишилося цілей)" + }, "balancer": { "addBalancer": "Додати балансир", "editBalancer": "Редагувати балансир", diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json index c411f1988..e3241f09c 100644 --- a/internal/web/translation/vi-VN.json +++ b/internal/web/translation/vi-VN.json @@ -1709,6 +1709,12 @@ "deleteAlsoObservatory": "Đây là balancer cuối cùng dùng Observatory, nên nó cũng sẽ bị xóa.", "deleteAlsoBurst": "Đây là balancer cuối cùng dùng Burst Observatory, nên nó cũng sẽ bị xóa." }, + "refCleanup": { + "header": "Xóa mục này cũng cập nhật định tuyến của bạn:", + "ruleRemoved": "Quy tắc {label} — đã xóa (không còn đích đến)", + "ruleModified": "Quy tắc {label} — giữ lại (giờ dùng {keeps})", + "balancerRemoved": "Balancer {tag} — đã xóa (không còn mục tiêu)" + }, "balancer": { "addBalancer": "Thêm cân bằng", "editBalancer": "Chỉnh sửa cân bằng", diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index 6fd41ba8b..1679f66c1 100644 --- a/internal/web/translation/zh-CN.json +++ b/internal/web/translation/zh-CN.json @@ -1709,6 +1709,12 @@ "deleteAlsoObservatory": "这是最后一个使用 Observatory 的负载均衡器,因此它也会被一并移除。", "deleteAlsoBurst": "这是最后一个使用 Burst Observatory 的负载均衡器,因此它也会被一并移除。" }, + "refCleanup": { + "header": "删除此项还会更新你的路由:", + "ruleRemoved": "规则 {label} — 已移除(没有剩余出口)", + "ruleModified": "规则 {label} — 已保留(现使用 {keeps})", + "balancerRemoved": "负载均衡器 {tag} — 已移除(没有剩余目标)" + }, "balancer": { "addBalancer": "添加负载均衡", "editBalancer": "编辑负载均衡", diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json index 3c119b69e..5ffafa843 100644 --- a/internal/web/translation/zh-TW.json +++ b/internal/web/translation/zh-TW.json @@ -1709,6 +1709,12 @@ "deleteAlsoObservatory": "這是最後一個使用 Observatory 的負載平衡器,因此它也會一併被移除。", "deleteAlsoBurst": "這是最後一個使用 Burst Observatory 的負載平衡器,因此它也會一併被移除。" }, + "refCleanup": { + "header": "刪除此項也會更新你的路由:", + "ruleRemoved": "規則 {label} — 已移除(沒有剩餘出口)", + "ruleModified": "規則 {label} — 已保留(現使用 {keeps})", + "balancerRemoved": "負載平衡器 {tag} — 已移除(沒有剩餘目標)" + }, "balancer": { "addBalancer": "新增負載均衡", "editBalancer": "編輯負載均衡",