diff --git a/frontend/src/api/queries/useOutboundTags.ts b/frontend/src/api/queries/useOutboundTags.ts index a50470bac..4e08ab1d2 100644 --- a/frontend/src/api/queries/useOutboundTags.ts +++ b/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 ?? []) { diff --git a/frontend/src/hooks/useXraySetting.ts b/frontend/src/hooks/useXraySetting.ts index 4949c2a41..e137a928c 100644 --- a/frontend/src/hooks/useXraySetting.ts +++ b/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') { diff --git a/frontend/src/pages/index/GeodataSection.tsx b/frontend/src/pages/index/GeodataSection.tsx index e9e698910..cfe2237dd 100644 --- a/frontend/src/pages/index/GeodataSection.tsx +++ b/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; - if (rec.protocol === 'blackhole') continue; + if (isOutboundProtocol(rec, 'blackhole')) continue; const tag = rec.tag; if (typeof tag === 'string' && tag) tags.add(tag); } diff --git a/frontend/src/pages/settings/GeneralTab.tsx b/frontend/src/pages/settings/GeneralTab.tsx index 29efc333f..b144a101f 100644 --- a/frontend/src/pages/settings/GeneralTab.tsx +++ b/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; - 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); } diff --git a/frontend/src/pages/xray/outbounds/OutboundsTab.tsx b/frontend/src/pages/xray/outbounds/OutboundsTab.tsx index fadadaa62..93bcf9747 100644 --- a/frontend/src/pages/xray/outbounds/OutboundsTab.tsx +++ b/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(); (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 || []) { diff --git a/frontend/src/schemas/primitives/outbound-protocol.ts b/frontend/src/schemas/primitives/outbound-protocol.ts index 3ee1ff3e0..34c60414a 100644 --- a/frontend/src/schemas/primitives/outbound-protocol.ts +++ b/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', diff --git a/frontend/src/test/outbound-protocol-case.test.ts b/frontend/src/test/outbound-protocol-case.test.ts new file mode 100644 index 000000000..53638f37a --- /dev/null +++ b/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); + }); +}); diff --git a/frontend/src/test/use-outbound-tags.test.tsx b/frontend/src/test/use-outbound-tags.test.tsx new file mode 100644 index 000000000..e1318c6e3 --- /dev/null +++ b/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 }) => ( + {children} + ); +} + +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']); + }); +});