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 <rqzbeh@users.noreply.github.com>
This commit is contained in:
nima1024m
2026-06-08 22:00:41 +03:30
committed by GitHub
parent 1c74b995c3
commit e8171ab4f7
6 changed files with 121 additions and 6 deletions
+1
View File
@@ -1,6 +1,7 @@
# Ignore editor and IDE settings
.idea/
.vscode/
.cursor/
.claude/
.cache/
.sync*
+3 -5
View File
@@ -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);
}
});
}
+33
View File
@@ -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;
}
}
}
@@ -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<string, unknown>) {
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);
@@ -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');
});
});
+16
View File
@@ -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);