Преглед на файлове

fix(panel): probe UDP outbounds and hide the block outbound from the mtproto egress picker (#6525)

* fix(panel): match the probe and egress readers to what the core loads

Two readers left over from the case-sensitivity sweep still disagreed with
the core, both raised reviewing #6523.

isUdpOutbound compared the protocol id and the transport name exactly. The
core lowercases both before it resolves them (infra/conf/loader.go:46 for
the id, TransportProtocol.Build at infra/conf/transport_internet.go:16-17
for the name), so an outbound spelled "WireGuard" or a stream named "KCP"
still built a UDP handler but was probed with a dial-only TCP request, and
Test All Outbounds reported a working outbound as down.

The mtproto egress picker asked for outbound tags without excludeBlackhole,
so the block outbound stayed selectable there. Choosing it looks like a
working selection and discards that inbound's Telegram traffic.

* fix(panel): recognise the mkcp transport alias and pin the picker's field id

Review findings on #6525.

TransportProtocol.Build resolves both "kcp" and "mkcp" to the same mKCP
transport, so comparing the transport name against "kcp" alone left a
template spelling "network": "mkcp" in the TCP lane and reported a working
outbound as down — the same trigger this PR already fixed for the "KCP"
capitalisation.

The egress picker now carries an explicit id, the way the inbound form's
protocol select does, so the test addresses that field rather than the first
searchable select on the page and reuses the shared dropdown helper instead
of duplicating it.
BlindMaster24 преди 19 часа
родител
ревизия
84c5aef4a1

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

@@ -26,20 +26,24 @@ function normalizeOutboundTestUrl(url: string) {
   return url || DEFAULT_TEST_URL;
 }
 
+// The core lowercases a protocol id and a transport name before resolving
+// either, so "WireGuard"/"KCP" still build a UDP handler a TCP dial misreports.
 export function isUdpOutbound(outbound: unknown): boolean {
   const o = outbound as
-    | { protocol?: string; streamSettings?: { network?: string } }
+    | { protocol?: unknown; streamSettings?: { network?: unknown } }
     | null
     | undefined;
-  const p = o?.protocol;
-  const n = o?.streamSettings?.network;
+  const rawNetwork = o?.streamSettings?.network;
+  const network = typeof rawNetwork === 'string' ? rawNetwork.toLowerCase() : '';
   return (
-    p === 'wireguard' ||
-    p === 'hysteria' ||
-    p === 'amneziawg' ||
-    n === 'hysteria' ||
-    n === 'kcp' ||
-    n === 'quic'
+    isOutboundProtocol(o, 'wireguard') ||
+    isOutboundProtocol(o, 'hysteria') ||
+    isOutboundProtocol(o, 'amneziawg') ||
+    network === 'hysteria' ||
+    network === 'kcp' ||
+    // The core resolves "kcp" and "mkcp" to the same mKCP transport.
+    network === 'mkcp' ||
+    network === 'quic'
   );
 }
 

+ 2 - 1
frontend/src/pages/inbounds/form/protocols/mtproto.tsx

@@ -11,7 +11,7 @@ export default function MtprotoFields() {
   const routeThroughXray = useWatch({ control, name: 'settings.routeThroughXray' }) as
     | boolean
     | undefined;
-  const { data: outboundTags } = useOutboundTags();
+  const { data: outboundTags } = useOutboundTags({ excludeBlackhole: true });
   return (
     <>
       <FormField
@@ -89,6 +89,7 @@ export default function MtprotoFields() {
           tooltip={t('pages.inbounds.form.mtgRouteOutboundHint')}
         >
           <Select
+            id="mtprotoOutboundTag"
             allowClear
             showSearch
             placeholder={t('pages.inbounds.form.mtgRouteOutboundPlaceholder')}

+ 58 - 0
frontend/src/test/mtproto-egress-picker.test.tsx

@@ -0,0 +1,58 @@
+import type { ReactNode } from 'react';
+import { Form } from 'antd';
+import { waitFor } from '@testing-library/react';
+import { FormProvider, useForm } from 'react-hook-form';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import MtprotoFields from '@/pages/inbounds/form/protocols/mtproto';
+import { HttpUtil, Msg } from '@/utils';
+import { listSelectOptions, renderWithProviders } from './test-utils';
+
+afterEach(() => {
+  vi.restoreAllMocks();
+});
+
+// The picker exists so Telegram traffic can be sent through a proxy; offering
+// the block outbound there looks like a working selection and drops the traffic.
+function mockConfigWithBlockOutbound() {
+  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 Harness({ children }: { children: ReactNode }) {
+  const methods = useForm({ defaultValues: { settings: { routeThroughXray: true } } });
+  return (
+    <FormProvider {...methods}>
+      <Form>{children}</Form>
+    </FormProvider>
+  );
+}
+
+// The field carries an explicit id (like the inbound form's protocol select), so
+// the assertions can't drift onto another select that happens to be nearby.
+const EGRESS_FIELD = 'mtprotoOutboundTag';
+
+describe('mtproto egress picker', () => {
+  it('offers the routable tags and not the block outbound', async () => {
+    mockConfigWithBlockOutbound();
+    renderWithProviders(
+      <Harness>
+        <MtprotoFields />
+      </Harness>,
+    );
+
+    await waitFor(() => expect(listSelectOptions(EGRESS_FIELD)).toContain('direct'));
+
+    const options = listSelectOptions(EGRESS_FIELD);
+    expect(options).toContain('warp');
+    expect(options).not.toContain('blocked');
+  });
+});

+ 45 - 0
frontend/src/test/use-xray-setting.test.tsx

@@ -67,4 +67,49 @@ describe('useXraySetting', () => {
     expect(result.current.outboundTestUrl).toBe('');
     expect(result.current.saveDisabled).toBe(true);
   });
+
+  // The core lowercases a protocol id and a transport name before resolving
+  // either, so a differently spelled UDP outbound must still skip the TCP dial.
+  it.each<[string, Record<string, unknown>, string]>([
+    ['probes a canonical UDP outbound over HTTP', { protocol: 'wireguard', tag: 'wg' }, 'http'],
+    [
+      'probes a "WireGuard"-spelled outbound over HTTP',
+      { protocol: 'WireGuard', tag: 'wg' },
+      'http',
+    ],
+    ['probes a "HyStErIa"-spelled outbound over HTTP', { protocol: 'HyStErIa', tag: 'hy' }, 'http'],
+    [
+      'probes a "KCP" transport over HTTP',
+      { protocol: 'vless', tag: 'kcp', streamSettings: { network: 'KCP' } },
+      'http',
+    ],
+    [
+      'probes an "mkcp" transport over HTTP',
+      { protocol: 'vless', tag: 'mkcp', streamSettings: { network: 'mkcp' } },
+      'http',
+    ],
+    ['probes a plain vless outbound over TCP', { protocol: 'vless', tag: 'plain' }, 'tcp'],
+  ])('%s', async (_name, outbound, want) => {
+    const bodies: Array<Record<string, unknown>> = [];
+    vi.spyOn(HttpUtil, 'post').mockImplementation(async (url, data) => {
+      if (url === '/panel/api/xray/') {
+        return new Msg(true, '', JSON.stringify(xrayPayload()));
+      }
+      bodies.push(data as Record<string, unknown>);
+      return new Msg(true, '', [{ success: true, mode: 'http' }]);
+    });
+    const queryClient = makeTestQueryClient();
+    const wrapper = ({ children }: { children: ReactNode }) => (
+      <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
+    );
+    const { result } = renderHook(() => useXraySetting(), { wrapper });
+
+    await waitFor(() => expect(result.current.fetched).toBe(true));
+    await act(async () => {
+      await result.current.testOutbound(0, outbound, 'tcp');
+    });
+
+    expect(bodies).toHaveLength(1);
+    expect(bodies[0].mode).toBe(want);
+  });
 });