From e8171ab4f728ab8da5df051b2a797f28f055d73e Mon Sep 17 00:00:00 2001 From: nima1024m <114405577+nima1024m@users.noreply.github.com> Date: Mon, 8 Jun 2026 22:00:41 +0330 Subject: [PATCH] fix(xray): sync routing rules when outbound tag is renamed (#5006) * chore: ignore local .cursor directory * fix(xray): sync routing rules when outbound tag is renamed Renaming an outbound in the Outbounds tab only updated the outbound list, leaving routing rules pointing at the old tag. Propagate tag changes to routing rules, balancer selectors, and sockopt dialerProxy references, matching the behavior already used for balancer and WARP/Nord renames. * test: mock HttpUtil to fix unhandled vitest rejections * test(frontend): mock axios globally to prevent flaky network errors on CI * test(frontend): fix eslint any errors in component test setup --------- Co-authored-by: Rqzbeh --- .gitignore | 1 + frontend/src/pages/xray/XrayPage.tsx | 8 +-- frontend/src/pages/xray/basics/helpers.ts | 33 ++++++++++ .../src/pages/xray/outbounds/OutboundsTab.tsx | 8 ++- frontend/src/test/outbound-tag-rename.test.ts | 61 +++++++++++++++++++ frontend/src/test/setup.components.ts | 16 +++++ 6 files changed, 121 insertions(+), 6 deletions(-) create mode 100644 frontend/src/test/outbound-tag-rename.test.ts diff --git a/.gitignore b/.gitignore index 6ea141728..7af8aae5c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Ignore editor and IDE settings .idea/ .vscode/ +.cursor/ .claude/ .cache/ .sync* diff --git a/frontend/src/pages/xray/XrayPage.tsx b/frontend/src/pages/xray/XrayPage.tsx index b11dc4ff3..d2fb7afd6 100644 --- a/frontend/src/pages/xray/XrayPage.tsx +++ b/frontend/src/pages/xray/XrayPage.tsx @@ -29,6 +29,7 @@ import { JsonEditor } from '@/components/form'; import { setMessageInstance } from '@/utils/messageBus'; import { BasicsTab } from './basics'; +import { propagateOutboundTagRename } from './basics/helpers'; import { RoutingTab } from './routing'; import { OutboundsTab } from './outbounds'; import { BalancersTab } from './balancers'; @@ -118,11 +119,8 @@ export default function XrayPage() { mutate((tt) => { if (!tt.outbounds || payload.index < 0) return; tt.outbounds[payload.index] = payload.outbound as never; - if (payload.oldTag && payload.newTag && payload.oldTag !== payload.newTag) { - const rules = tt.routing?.rules || []; - for (const r of rules) { - if (r?.outboundTag === payload.oldTag) r.outboundTag = payload.newTag; - } + if (payload.oldTag && payload.newTag) { + propagateOutboundTagRename(tt, payload.oldTag, payload.newTag); } }); } diff --git a/frontend/src/pages/xray/basics/helpers.ts b/frontend/src/pages/xray/basics/helpers.ts index eb400da1f..48df3e0c9 100644 --- a/frontend/src/pages/xray/basics/helpers.ts +++ b/frontend/src/pages/xray/basics/helpers.ts @@ -54,3 +54,36 @@ export function syncOutbound(t: XraySettingsValue, tag: string, settings: Record if (!haveRules && idx > 0) t.outbounds.splice(idx, 1); if (haveRules && idx < 0) t.outbounds.push(settings as never); } + +export function propagateOutboundTagRename( + t: XraySettingsValue, + oldTag: string, + newTag: string, +): void { + if (!oldTag || !newTag || oldTag === newTag) return; + + const rules = t.routing?.rules; + if (Array.isArray(rules)) { + for (const rule of rules) { + if (rule?.outboundTag === oldTag) rule.outboundTag = newTag; + } + } + + const balancers = t.routing?.balancers; + if (Array.isArray(balancers)) { + for (const balancer of balancers) { + if (balancer?.fallbackTag === oldTag) balancer.fallbackTag = newTag; + if (Array.isArray(balancer?.selector)) { + balancer.selector = balancer.selector.map((sel) => (sel === oldTag ? newTag : sel)); + } + } + } + + if (Array.isArray(t.outbounds)) { + for (const outbound of t.outbounds) { + const sockopt = (outbound as { streamSettings?: { sockopt?: { dialerProxy?: string } } }) + ?.streamSettings?.sockopt; + if (sockopt?.dialerProxy === oldTag) sockopt.dialerProxy = newTag; + } + } +} diff --git a/frontend/src/pages/xray/outbounds/OutboundsTab.tsx b/frontend/src/pages/xray/outbounds/OutboundsTab.tsx index be8f4c9c6..ae932c37f 100644 --- a/frontend/src/pages/xray/outbounds/OutboundsTab.tsx +++ b/frontend/src/pages/xray/outbounds/OutboundsTab.tsx @@ -38,6 +38,7 @@ import { import { HttpUtil } from '@/utils'; import OutboundFormModal from './OutboundFormModal'; +import { propagateOutboundTagRename } from '../basics/helpers'; import type { XraySettingsValue, SetTemplate, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting'; import './OutboundsTab.css'; @@ -172,11 +173,16 @@ export default function OutboundsTab({ function onConfirm(outbound: Record) { mutate((tt) => { if (!Array.isArray(tt.outbounds)) tt.outbounds = []; + const newTag = typeof outbound.tag === 'string' ? outbound.tag : ''; if (editingIndex == null) { - if (!outbound.tag) return; + if (!newTag) return; tt.outbounds.push(outbound as never); } else { + const oldTag = tt.outbounds[editingIndex]?.tag; tt.outbounds[editingIndex] = outbound as never; + if (oldTag && newTag && oldTag !== newTag) { + propagateOutboundTagRename(tt, oldTag, newTag); + } } }); setModalOpen(false); diff --git a/frontend/src/test/outbound-tag-rename.test.ts b/frontend/src/test/outbound-tag-rename.test.ts new file mode 100644 index 000000000..fbf7ff6c3 --- /dev/null +++ b/frontend/src/test/outbound-tag-rename.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from 'vitest'; + +import type { XraySettingsValue } from '@/hooks/useXraySetting'; +import { propagateOutboundTagRename } from '@/pages/xray/basics/helpers'; + +function baseTemplate(): XraySettingsValue { + return { + outbounds: [ + { tag: 'To-External-Proxy', protocol: 'vless' }, + { tag: 'direct', protocol: 'freedom' }, + ], + routing: { + rules: [ + { + type: 'field', + inboundTag: ['iran-in'], + outboundTag: 'To-External-Proxy', + }, + ], + balancers: [ + { + tag: 'lb-1', + selector: ['To-External-Proxy', 'direct'], + fallbackTag: 'To-External-Proxy', + }, + ], + }, + } as XraySettingsValue; +} + +describe('propagateOutboundTagRename', () => { + it('updates routing rule outboundTag when outbound is renamed', () => { + const t = baseTemplate(); + propagateOutboundTagRename(t, 'To-External-Proxy', 'external-vps'); + expect(t.routing?.rules?.[0]?.outboundTag).toBe('external-vps'); + }); + + it('updates balancer selector and fallbackTag', () => { + const t = baseTemplate(); + propagateOutboundTagRename(t, 'To-External-Proxy', 'external-vps'); + expect(t.routing?.balancers?.[0]?.selector).toEqual(['external-vps', 'direct']); + expect(t.routing?.balancers?.[0]?.fallbackTag).toBe('external-vps'); + }); + + it('updates sockopt dialerProxy references in other outbounds', () => { + const t = baseTemplate(); + (t.outbounds![1] as { streamSettings?: { sockopt?: { dialerProxy?: string } } }).streamSettings = { + sockopt: { dialerProxy: 'To-External-Proxy' }, + }; + propagateOutboundTagRename(t, 'To-External-Proxy', 'external-vps'); + const dialerProxy = (t.outbounds![1] as { streamSettings?: { sockopt?: { dialerProxy?: string } } }) + .streamSettings?.sockopt?.dialerProxy; + expect(dialerProxy).toBe('external-vps'); + }); + + it('is a no-op when old and new tags are equal', () => { + const t = baseTemplate(); + propagateOutboundTagRename(t, 'To-External-Proxy', 'To-External-Proxy'); + expect(t.routing?.rules?.[0]?.outboundTag).toBe('To-External-Proxy'); + }); +}); diff --git a/frontend/src/test/setup.components.ts b/frontend/src/test/setup.components.ts index c2f6e0a64..62f1a347f 100644 --- a/frontend/src/test/setup.components.ts +++ b/frontend/src/test/setup.components.ts @@ -74,3 +74,19 @@ afterEach(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); } }); + +import { HttpUtil } from '@/utils'; + +vi.mock('axios', () => { + return { + default: { + get: vi.fn().mockResolvedValue({ data: { success: true, obj: {} } }), + post: vi.fn().mockResolvedValue({ data: { success: true, obj: {} } }), + } + }; +}); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +vi.spyOn(HttpUtil, 'post').mockResolvedValue({ success: true, obj: {} } as any); +// eslint-disable-next-line @typescript-eslint/no-explicit-any +vi.spyOn(HttpUtil, 'get').mockResolvedValue({ success: true, obj: {} } as any);