Bladeren bron

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 19 uur geleden
bovenliggende
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']);
+  });
+});