Explorar el Código

fix(panel): read outbound protocol ids case-insensitively everywhere (#6523)

Six more readers compared an outbound's protocol id exactly while the core
lowercases it, so an outbound spelled "Blackhole" passed every
excludeBlackhole filter (offered as an mtproto egress, a dialerProxy
target and the geodata download egress, all of which then drop the
traffic) and one spelled "Freedom" was queued by Test All Outbounds. They
now share isOutboundProtocol.
BlindMaster24 hace 1 día
padre
commit
c0c2dd274c

+ 3 - 2
frontend/src/api/queries/useOutboundTags.ts

@@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query';
 
 import { keys } from '@/api/queryKeys';
 import { fetchXrayConfig } from '@/hooks/useXraySetting';
+import { isOutboundProtocol } from '@/schemas/primitives';
 
 // Available outbound (and balancer-eligible) tags the user can route an mtproto
 // inbound's Telegram traffic to. Shares the cached xray config query so opening
@@ -18,7 +19,7 @@ export function useOutboundTags(opts?: { excludeBlackhole?: boolean }) {
       for (const o of data?.xraySetting?.outbounds ?? []) {
         const ob = o as { tag?: string; protocol?: string } | null;
         if (!ob?.tag) continue;
-        if (excludeBlackhole && ob.protocol === 'blackhole') continue;
+        if (excludeBlackhole && isOutboundProtocol(ob, 'blackhole')) continue;
         tags.add(ob.tag);
       }
       for (const t of data?.subscriptionOutboundTags ?? []) {
@@ -56,7 +57,7 @@ export function useOutboundTagGroups(opts?: { excludeBlackhole?: boolean }) {
       for (const o of data?.xraySetting?.outbounds ?? []) {
         const ob = o as { tag?: string; protocol?: string } | null;
         if (!ob?.tag) continue;
-        if (excludeBlackhole && ob.protocol === 'blackhole') continue;
+        if (excludeBlackhole && isOutboundProtocol(ob, 'blackhole')) continue;
         outbounds.add(ob.tag);
       }
       for (const t of data?.subscriptionOutboundTags ?? []) {

+ 9 - 3
frontend/src/hooks/useXraySetting.ts

@@ -5,6 +5,7 @@ import { z } from 'zod';
 import { HttpUtil, Msg } from '@/utils';
 import { parseMsg } from '@/utils/zodValidate';
 import { keys } from '@/api/queryKeys';
+import { isOutboundProtocol } from '@/schemas/primitives';
 import {
   OutboundTrafficListSchema,
   OutboundTestResultListSchema,
@@ -386,10 +387,15 @@ export function useXraySetting(): UseXraySettingResult {
           index: number,
           tag: string,
         ) => {
-          const proto = ob?.protocol;
-          if (proto === 'blackhole' || proto === 'loopback' || ob?.tag === 'blocked') return;
+          if (
+            isOutboundProtocol(ob, 'blackhole') ||
+            isOutboundProtocol(ob, 'loopback') ||
+            ob?.tag === 'blocked'
+          ) {
+            return;
+          }
           // freedom ("direct") and dns aren't proxies — skip them in every mode.
-          if (proto === 'freedom' || proto === 'dns') return;
+          if (isOutboundProtocol(ob, 'freedom') || isOutboundProtocol(ob, 'dns')) return;
           if (kind === 'sub' && !tag) return;
           const toHttp = mode !== 'tcp' || isUdpOutbound(ob);
           if (kind === 'tpl') {

+ 2 - 1
frontend/src/pages/index/GeodataSection.tsx

@@ -4,6 +4,7 @@ import { Alert, Button, Form, Input, Modal, Select, Space, Spin, Typography, mes
 import { PlusOutlined, DeleteOutlined } from '@ant-design/icons';
 
 import { XrayConfigPayloadSchema } from '@/schemas/xray';
+import { isOutboundProtocol } from '@/schemas/primitives';
 import { HttpUtil } from '@/utils';
 
 interface GeodataAssetRow {
@@ -75,7 +76,7 @@ export default function GeodataSection({ active, onBusy, onClose }: GeodataSecti
       for (const o of outbounds) {
         if (!o || typeof o !== 'object') continue;
         const rec = o as Record<string, unknown>;
-        if (rec.protocol === 'blackhole') continue;
+        if (isOutboundProtocol(rec, 'blackhole')) continue;
         const tag = rec.tag;
         if (typeof tag === 'string' && tag) tags.add(tag);
       }

+ 2 - 1
frontend/src/pages/settings/GeneralTab.tsx

@@ -10,6 +10,7 @@ import {
   SettingOutlined,
 } from '@ant-design/icons';
 import type { AllSetting } from '@/models/setting';
+import { isOutboundProtocol } from '@/schemas/primitives';
 import { HttpUtil, LanguageManager } from '@/utils';
 import { onNumber } from '@/utils/onNumber';
 import { DefaultSettingTag, SettingListItem } from '@/components/ui';
@@ -86,7 +87,7 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
         for (const o of outbounds) {
           if (!o || typeof o !== 'object') continue;
           const rec = o as Record<string, unknown>;
-          if (rec.protocol === 'blackhole') continue; // dropping traffic is never a useful egress
+          if (isOutboundProtocol(rec, 'blackhole')) continue; // never a useful egress
           const tag = rec.tag;
           if (typeof tag === 'string' && tag) tags.add(tag);
         }

+ 2 - 1
frontend/src/pages/xray/outbounds/OutboundsTab.tsx

@@ -37,6 +37,7 @@ import {
   ImportOutlined,
 } from '@ant-design/icons';
 
+import { isOutboundProtocol } from '@/schemas/primitives';
 import { HttpUtil } from '@/utils';
 import { onNumber } from '@/utils/onNumber';
 import PromptModal from '@/components/feedback/PromptModal';
@@ -183,7 +184,7 @@ export default function OutboundsTab({
     const tags = new Set<string>();
     (templateSettings?.outbounds || []).forEach((o, i) => {
       if (i === editingIndex) return;
-      if (o?.protocol === 'blackhole') return;
+      if (isOutboundProtocol(o, 'blackhole')) return;
       if (o?.tag) tags.add(o.tag);
     });
     for (const tag of subscriptionOutboundTags || []) {

+ 10 - 0
frontend/src/schemas/primitives/outbound-protocol.ts

@@ -1,3 +1,13 @@
+// xray-core lowercases a protocol id before it resolves the handler, so a
+// template pasted as "Freedom" still runs as the freedom outbound.
+export function isOutboundProtocol(
+  outbound: { protocol?: unknown } | null | undefined,
+  id: string,
+): boolean {
+  const protocol = outbound?.protocol;
+  return typeof protocol === 'string' && protocol.toLowerCase() === id;
+}
+
 export const OutboundProtocols = Object.freeze({
   Freedom: 'freedom',
   Blackhole: 'blackhole',

+ 28 - 0
frontend/src/test/outbound-protocol-case.test.ts

@@ -0,0 +1,28 @@
+import { describe, expect, it } from 'vitest';
+
+import { isOutboundProtocol } from '@/schemas/primitives';
+
+// xray-core lowercases a protocol id in LoadWithID before it resolves the
+// handler, so every panel reader has to accept the spellings it accepts.
+describe('isOutboundProtocol', () => {
+  it.each([
+    ['canonical', { protocol: 'freedom' }],
+    ['capitalised', { protocol: 'Freedom' }],
+    ['upper', { protocol: 'FREEDOM' }],
+    ['mixed', { protocol: 'fReEdOm' }],
+  ])('matches a %s id', (_name, outbound) => {
+    expect(isOutboundProtocol(outbound, 'freedom')).toBe(true);
+  });
+
+  it.each([
+    ['another protocol', { protocol: 'blackhole' }],
+    ['another spelling of another protocol', { protocol: 'Blackhole' }],
+    ['missing', {}],
+    ['empty', { protocol: '' }],
+    ['not a string', { protocol: 42 }],
+    ['null', null],
+    ['undefined', undefined],
+  ])('rejects %s', (_name, outbound) => {
+    expect(isOutboundProtocol(outbound, 'freedom')).toBe(false);
+  });
+});

+ 67 - 0
frontend/src/test/use-outbound-tags.test.tsx

@@ -0,0 +1,67 @@
+import type { ReactNode } from 'react';
+import { renderHook, waitFor } from '@testing-library/react';
+import { QueryClientProvider } from '@tanstack/react-query';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { useOutboundTagGroups, useOutboundTags } from '@/api/queries/useOutboundTags';
+import { makeTestQueryClient } from '@/test/test-utils';
+import { HttpUtil, Msg } from '@/utils';
+
+afterEach(() => {
+  vi.restoreAllMocks();
+});
+
+// The core lowercases a protocol id before it resolves the handler, so a
+// template that spells the block outbound "Blackhole" still drops traffic.
+function mockConfig() {
+  const payload = {
+    xraySetting: {
+      outbounds: [
+        { tag: 'direct', protocol: 'freedom' },
+        { tag: 'blocked', protocol: 'Blackhole' },
+        { tag: 'warp', protocol: 'wireguard' },
+      ],
+    },
+  };
+  vi.spyOn(HttpUtil, 'post').mockResolvedValue(new Msg(true, '', JSON.stringify(payload)));
+}
+
+function wrapperFor() {
+  const queryClient = makeTestQueryClient();
+  return ({ children }: { children: ReactNode }) => (
+    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
+  );
+}
+
+describe('outbound tag pickers', () => {
+  it('excludes a block outbound whose id is spelled differently', async () => {
+    mockConfig();
+
+    const { result } = renderHook(() => useOutboundTags({ excludeBlackhole: true }), {
+      wrapper: wrapperFor(),
+    });
+
+    await waitFor(() => expect(result.current.data).toBeDefined());
+    expect(result.current.data).toEqual(['direct', 'warp']);
+  });
+
+  it('keeps the same tag in the grouped picker out of its outbound list', async () => {
+    mockConfig();
+
+    const { result } = renderHook(() => useOutboundTagGroups({ excludeBlackhole: true }), {
+      wrapper: wrapperFor(),
+    });
+
+    await waitFor(() => expect(result.current.data).toBeDefined());
+    expect(result.current.data?.outbounds).toEqual(['direct', 'warp']);
+  });
+
+  it('offers every tag when the caller does not exclude blocks', async () => {
+    mockConfig();
+
+    const { result } = renderHook(() => useOutboundTags(), { wrapper: wrapperFor() });
+
+    await waitFor(() => expect(result.current.data).toBeDefined());
+    expect(result.current.data).toEqual(['direct', 'blocked', 'warp']);
+  });
+});