ソースを参照

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 <[email protected]>
nima1024m 11 時間 前
コミット
e8171ab4f7

+ 1 - 0
.gitignore

@@ -1,6 +1,7 @@
 # Ignore editor and IDE settings
 .idea/
 .vscode/
+.cursor/
 .claude/
 .cache/
 .sync*

+ 3 - 5
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);
       }
     });
   }

+ 33 - 0
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;
+    }
+  }
+}

+ 7 - 1
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<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);

+ 61 - 0
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');
+  });
+});

+ 16 - 0
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);