Explorar el Código

fix(panel): read an outbound protocol id the way the core does (#6522)

* fix(panel): read an outbound protocol id the way the core does

xray-core lowercases a protocol id before it resolves the handler, so a
template that spells the direct outbound "Freedom" is that outbound. The
outbound editor fell through to the vless default and rendered it as an
empty vless server, and the Basics tab did not find it and appended a
second "direct", which the core refuses to load with "existing tag found".

* fix(panel): never leave the direct tag on two outbounds

A "direct" tag held by a non-freedom egress made both Basics-tab setters
push a fresh freedom outbound, and the core refuses a config whose tags
repeat ("existing tag found: direct"). The tag is now checked on its own
before anything is added, matching setDefaultOutboundTag.

* fix(panel): disable the freedom controls when direct is held elsewhere

When a non-freedom outbound holds the "direct" tag, both Basics setters
drop the edit so the core never sees the tag twice, but the Freedom
Strategy select and the Happy Eyeballs switch stayed enabled and snapped
back with no sign of why. isDirectTagTaken now disables both controls in
that state.

The find-or-create-plus-guard was also copied into BasicsTab's happy
eyeballs setter with no test of its own; ensureDirectFreedomOutbound now
owns the lookup, the guard and the creation for both setters, so the
existing helper tests cover that path too.

---------

Co-authored-by: Sanaei <[email protected]>
BlindMaster24 hace 1 día
padre
commit
a5a4c9cd83

+ 3 - 1
frontend/src/lib/xray/outbound-form-adapter.ts

@@ -541,7 +541,9 @@ function hydrateStreamForm(stream: Raw): OutboundStreamFormValues {
 }
 
 export function rawOutboundToFormValues(raw: RawOutboundRow): OutboundFormValues {
-  const protocol = asString(raw.protocol, 'vless');
+  // The core lowercases a protocol id before it looks the handler up, so a
+  // template pasted as "Freedom" must not fall through to the vless default.
+  const protocol = asString(raw.protocol, 'vless').toLowerCase();
   const settings = asObject(raw.settings);
   const tag = asString(raw.tag);
   const sendThrough = asString(raw.sendThrough);

+ 14 - 10
frontend/src/pages/xray/basics/BasicsTab.tsx

@@ -25,7 +25,13 @@ import {
   MASK_ADDRESS,
   ROUTING_DOMAIN_STRATEGIES,
 } from './constants';
-import { directFreedomStrategy, setDirectFreedomStrategy } from './helpers';
+import {
+  directFreedomStrategy,
+  ensureDirectFreedomOutbound,
+  isDirectFreedomOutbound,
+  isDirectTagTaken,
+  setDirectFreedomStrategy,
+} from './helpers';
 
 interface BasicsTabProps {
   templateSettings: XraySettingsValue | null;
@@ -112,9 +118,10 @@ export default function BasicsTab({
 
   const freedomStrategy = directFreedomStrategy(templateSettings);
 
-  const directFreedomOutbound = templateSettings?.outbounds?.find(
-    (o) => o?.protocol === 'freedom' && o?.tag === 'direct',
+  const directFreedomOutbound = templateSettings?.outbounds?.find((o) =>
+    isDirectFreedomOutbound(o),
   );
+  const directTagTaken = isDirectTagTaken(templateSettings);
   const directHappyEyeballs = (() => {
     const sockopt = (
       directFreedomOutbound?.streamSettings as { sockopt?: { happyEyeballs?: unknown } } | undefined
@@ -128,13 +135,8 @@ export default function BasicsTab({
   const setDirectHappyEyeballs = useCallback(
     (next: ReturnType<typeof HappyEyeballsSchema.parse> | null) => {
       mutate((tt) => {
-        if (!tt.outbounds) tt.outbounds = [];
-        let idx = tt.outbounds.findIndex((o) => o?.protocol === 'freedom' && o?.tag === 'direct');
-        if (idx < 0) {
-          tt.outbounds.push({ protocol: 'freedom', tag: 'direct', settings: {} });
-          idx = tt.outbounds.length - 1;
-        }
-        const ob = tt.outbounds[idx];
+        const ob = ensureDirectFreedomOutbound(tt);
+        if (!ob) return;
         const stream = (ob.streamSettings ?? {}) as Record<string, unknown>;
         const sockopt = (stream.sockopt ?? {}) as Record<string, unknown>;
         if (next == null) {
@@ -181,6 +183,7 @@ export default function BasicsTab({
             control={
               <Select
                 value={freedomStrategy}
+                disabled={directTagTaken}
                 style={{ width: '100%' }}
                 options={OutboundDomainStrategies.map((s) => ({ value: s, label: s }))}
                 onChange={(next) => mutate((tt) => setDirectFreedomStrategy(tt, next))}
@@ -194,6 +197,7 @@ export default function BasicsTab({
             control={
               <Switch
                 checked={directHappyEyeballs != null}
+                disabled={directTagTaken}
                 onChange={(checked) => {
                   setDirectHappyEyeballs(checked ? HappyEyeballsSchema.parse({}) : null);
                 }}

+ 28 - 10
frontend/src/pages/xray/basics/helpers.ts

@@ -8,10 +8,17 @@ const LEGACY_FREEDOM_STRATEGY_KEYS = ['domainStrategy', 'targetStrategy'] as con
 
 type Outbound = Record<string, unknown>;
 
+// The core lowercases a protocol id before it resolves the handler, so matching
+// it exactly would append a second "direct" the core refuses to load.
+export function isDirectFreedomOutbound(o: Outbound | undefined): boolean {
+  const protocol = o?.protocol;
+  return (
+    typeof protocol === 'string' && protocol.toLowerCase() === 'freedom' && o?.tag === 'direct'
+  );
+}
+
 function directFreedom(t: XraySettingsValue | null): Outbound | undefined {
-  return t?.outbounds?.find((o) => o?.protocol === 'freedom' && o?.tag === 'direct') as
-    | Outbound
-    | undefined;
+  return t?.outbounds?.find((o) => isDirectFreedomOutbound(o)) as Outbound | undefined;
 }
 
 export function directFreedomStrategy(t: XraySettingsValue | null): string {
@@ -20,14 +27,25 @@ export function directFreedomStrategy(t: XraySettingsValue | null): string {
   return freedomDomainStrategyFromWire(outbound) || 'AsIs';
 }
 
-export function setDirectFreedomStrategy(t: XraySettingsValue, next: string): void {
+// The core refuses to load two outbounds sharing a tag, so a "direct" held by
+// a non-freedom egress keeps it and the Basics controls have nothing to edit.
+export function isDirectTagTaken(t: XraySettingsValue | null): boolean {
+  return !directFreedom(t) && !!t?.outbounds?.some((o) => o?.tag === 'direct');
+}
+
+export function ensureDirectFreedomOutbound(t: XraySettingsValue): Outbound | undefined {
   if (!Array.isArray(t.outbounds)) t.outbounds = [];
-  let idx = t.outbounds.findIndex((o) => o?.protocol === 'freedom' && o?.tag === 'direct');
-  if (idx < 0) {
-    t.outbounds.push({ protocol: 'freedom', tag: 'direct', settings: {} } as never);
-    idx = t.outbounds.length - 1;
-  }
-  const ob = t.outbounds[idx] as Outbound;
+  const found = directFreedom(t);
+  if (found) return found;
+  if (isDirectTagTaken(t)) return undefined;
+  const created: Outbound = { protocol: 'freedom', tag: 'direct', settings: {} };
+  t.outbounds.push(created as never);
+  return created;
+}
+
+export function setDirectFreedomStrategy(t: XraySettingsValue, next: string): void {
+  const ob = ensureDirectFreedomOutbound(t);
+  if (!ob) return;
   // Drop the legacy placements, or the loader keeps warning and the core keeps
   // preferring the root key it resets over the sockopt value set here.
   const settings = (ob.settings ?? {}) as Outbound;

+ 35 - 0
frontend/src/test/basics-freedom-strategy.test.ts

@@ -48,6 +48,41 @@ describe('BasicsTab freedom strategy', () => {
     });
   });
 
+  it('finds the direct outbound when the template spells it "Freedom"', () => {
+    const t = {
+      outbounds: [
+        {
+          protocol: 'Freedom',
+          tag: 'direct',
+          settings: {},
+          streamSettings: { sockopt: { domainStrategy: 'UseIPv6' } },
+        },
+      ],
+    } as unknown as XraySettingsValue;
+
+    expect(directFreedomStrategy(t)).toBe('UseIPv6');
+
+    setDirectFreedomStrategy(t, 'UseIPv4');
+
+    expect(t.outbounds).toHaveLength(1);
+    expect(directOutbound(t).streamSettings).toEqual({ sockopt: { domainStrategy: 'UseIPv4' } });
+  });
+
+  it('never leaves a taken "direct" tag on two outbounds', () => {
+    const t = {
+      outbounds: [{ protocol: 'socks', tag: 'direct', settings: { servers: [] } }],
+    } as unknown as XraySettingsValue;
+
+    setDirectFreedomStrategy(t, 'UseIPv4');
+
+    expect(t.outbounds).toHaveLength(1);
+    expect(directOutbound(t)).toEqual({
+      protocol: 'socks',
+      tag: 'direct',
+      settings: { servers: [] },
+    });
+  });
+
   it('keeps other sockopt keys the transport form already set', () => {
     const t = settingsWithDirect({}, { sockopt: { tcpFastOpen: true } });
 

+ 44 - 0
frontend/src/test/basics-tab-direct-tag.test.tsx

@@ -0,0 +1,44 @@
+import { screen } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+
+import BasicsTab from '@/pages/xray/basics/BasicsTab';
+import type { XraySettingsValue } from '@/hooks/useXraySetting';
+import { renderWithProviders } from './test-utils';
+
+function renderBasics(outbounds: Record<string, unknown>[]) {
+  renderWithProviders(
+    <BasicsTab
+      templateSettings={{ outbounds } as unknown as XraySettingsValue}
+      setTemplateSettings={vi.fn()}
+      outboundTestUrl=""
+      onChangeOutboundTestUrl={vi.fn()}
+      onResetDefault={vi.fn()}
+    />,
+  );
+  return {
+    strategy: screen.getByRole('combobox', { name: 'Freedom Protocol Strategy' }),
+    happyEyeballs: screen.getByRole('switch', { name: 'Freedom Happy Eyeballs (IPv4/IPv6)' }),
+  };
+}
+
+// Both setters drop the edit when a non-freedom outbound holds "direct", so
+// the controls must say so instead of snapping back silently.
+describe('BasicsTab with the direct tag held by a foreign outbound', () => {
+  it('disables the freedom controls', () => {
+    const { strategy, happyEyeballs } = renderBasics([
+      { protocol: 'socks', tag: 'direct', settings: { servers: [] } },
+    ]);
+
+    expect(strategy).toHaveProperty('disabled', true);
+    expect(happyEyeballs).toHaveProperty('disabled', true);
+  });
+
+  it('keeps them enabled when freedom holds the tag, whatever its spelling', () => {
+    const { strategy, happyEyeballs } = renderBasics([
+      { protocol: 'Freedom', tag: 'direct', settings: {} },
+    ]);
+
+    expect(strategy).toHaveProperty('disabled', false);
+    expect(happyEyeballs).toHaveProperty('disabled', false);
+  });
+});

+ 26 - 0
frontend/src/test/outbound-form-adapter.test.ts

@@ -535,6 +535,32 @@ describe('outbound-form-adapter: round-trip', () => {
     const form = rawOutboundToFormValues({ protocol: 'mysterious', settings: {} });
     expect(form.protocol).toBe('vless');
   });
+
+  it('reads a protocol id the way the core does, whatever its case', () => {
+    const freedom = rawOutboundToFormValues({
+      protocol: 'Freedom',
+      tag: 'direct',
+      settings: { redirect: '1.1.1.1' },
+      streamSettings: { sockopt: { domainStrategy: 'UseIPv4' } },
+    });
+    expect(freedom.protocol).toBe('freedom');
+    if (freedom.protocol === 'freedom') {
+      expect(freedom.settings.redirect).toBe('1.1.1.1');
+    }
+    const back = formValuesToWirePayload(freedom);
+    expect(back.protocol).toBe('freedom');
+    expect(back.tag).toBe('direct');
+    expect((back.settings as Record<string, unknown>).redirect).toBe('1.1.1.1');
+
+    const vless = rawOutboundToFormValues({
+      protocol: 'VLESS',
+      settings: { address: 'srv', port: 443, id: '11111111-2222-4333-8444-555555555555' },
+    });
+    expect(vless.protocol).toBe('vless');
+    if (vless.protocol === 'vless') {
+      expect(vless.settings.address).toBe('srv');
+    }
+  });
 });
 
 describe('outbound-form-adapter: targetStrategy', () => {