Przeglądaj źródła

fix(xray): place the freedom domain strategy where the core reads it (#6515)

* fix(xray): place the freedom domain strategy where the core reads it

freedom resolves through the socket layer, so xray-core reads
sockopt.domainStrategy and treats both other placements as legacy: it warns on
every config load for the outbound-root targetStrategy it migrates itself, and
again for the settings-level domainStrategy it deprecates. The panel wrote
exactly those two keys from its Freedom Protocol Strategy select, the outbound
form card, and the IPv4 routing helper, so any install that had configured a
strategy logged a deprecation warning on every start.

The strategy now travels in streamSettings.sockopt everywhere the panel emits
it: the Basics select, the outbound form (including the JSON tab, which shares
the same adapter), the shipped default template, and the IPv4 outbound the
routing helper injects. Reading mirrors the loader's own order — root
targetStrategy, then the settings keys, then sockopt — so the card keeps showing
the value the core would actually run with, and saving drops the legacy keys
instead of leaving them behind.

A seeder moves the keys for configs already stored in the database, following
OutboundRemovedKeysFix. The shared outbound-root Target Strategy field is hidden
for freedom, since the core migrates that key into the very sockopt value the
card writes and two knobs for one value would race.

Tests: placement round-trips and the migration table run through the real
vendored core (a captured log handler proves the warning is gone after the
rewrite and present before it), and the modal asserts freedom offers a single
strategy field.

* test(database): seed the template row the seeder test needs

A fresh InitDB creates no xrayTemplateConfig row — the panel's setting defaults
live in the service layer — so the test has to insert the legacy template itself
and then assert the seeder's history gate stops a second pass from rewriting it.

* fix(xray): keep one strategy control per outbound, seed the row in tests

Review findings: the Transport tab's Sockopts block renders for freedom too, so
its Domain Strategy select and the freedom card wrote one sockopt value between
them and the card won on save — the field is hidden for freedom now, leaving the
card as the single control. The seeder is also pre-marked on a fresh install so
it does not run on the second start, and the seeder test seeds the template row
itself (a fresh InitDB has none) and asserts the rewrite structurally instead of
grepping for a key name that sockopt also uses.
BlindMaster24 5 godzin temu
rodzic
commit
826e29e2de

+ 50 - 10
frontend/src/lib/xray/outbound-form-adapter.ts

@@ -71,6 +71,24 @@ function targetStrategyFromWire(value: unknown): OutboundDomainStrategy | '' {
   );
 }
 
+// Mirrors the order the loader migrates freedom's legacy strategy keys in:
+// root targetStrategy, settings targetStrategy, settings domainStrategy, sockopt.
+export function freedomDomainStrategyFromWire(outbound: {
+  targetStrategy?: unknown;
+  settings?: unknown;
+  streamSettings?: unknown;
+}): OutboundDomainStrategy | '' {
+  const settings = asObject(outbound.settings);
+  const sockopt = asObject(asObject(outbound.streamSettings).sockopt);
+  const root = targetStrategyFromWire(outbound.targetStrategy);
+  const settingsKey = asString(settings.targetStrategy)
+    ? settings.targetStrategy
+    : settings.domainStrategy;
+  const legacy = root && root !== 'AsIs' ? root : targetStrategyFromWire(settingsKey);
+  if (legacy && legacy !== 'AsIs') return legacy;
+  return targetStrategyFromWire(sockopt.domainStrategy);
+}
+
 const SNIFFING_DEST_VALUES: readonly SniffingDest[] = ['http', 'tls', 'quic', 'fakedns'];
 
 const SNIFFING_DEFAULT: Sniffing = {
@@ -262,7 +280,10 @@ function hysteriaFromWire(raw: Raw): HysteriaOutboundFormSettings {
   };
 }
 
-function freedomFromWire(raw: Raw): FreedomOutboundFormSettings {
+function freedomFromWire(
+  raw: Raw,
+  domainStrategy: OutboundDomainStrategy | '',
+): FreedomOutboundFormSettings {
   const fragment = asObject(raw.fragment);
   const noises = asArray(raw.noises).map((n) => {
     const nn = asObject(n);
@@ -306,9 +327,7 @@ function freedomFromWire(raw: Raw): FreedomOutboundFormSettings {
   const wireHasFragment =
     raw.fragment != null && typeof raw.fragment === 'object' && Object.keys(fragment).length > 0;
   return {
-    domainStrategy: targetStrategyFromWire(
-      asString(raw.targetStrategy) || asString(raw.domainStrategy),
-    ),
+    domainStrategy,
     redirect: asString(raw.redirect),
     userLevel: asNumber(raw.userLevel, 0),
     proxyProtocol: ((): FreedomOutboundFormSettings['proxyProtocol'] => {
@@ -527,6 +546,7 @@ export function rawOutboundToFormValues(raw: RawOutboundRow): OutboundFormValues
   const tag = asString(raw.tag);
   const sendThrough = asString(raw.sendThrough);
   const targetStrategy = targetStrategyFromWire(raw.targetStrategy);
+  const freedomStrategy = freedomDomainStrategyFromWire(raw);
   const mux = muxFromWire(raw.mux);
   const hasStream =
     raw.streamSettings &&
@@ -564,7 +584,10 @@ export function rawOutboundToFormValues(raw: RawOutboundRow): OutboundFormValues
       typed = { protocol: 'hysteria', settings: hysteriaFromWire(settings) };
       break;
     case 'freedom':
-      typed = { protocol: 'freedom', settings: freedomFromWire(settings) };
+      typed = {
+        protocol: 'freedom',
+        settings: freedomFromWire(settings, freedomStrategy),
+      };
       break;
     case 'blackhole':
       typed = { protocol: 'blackhole', settings: blackholeFromWire(settings) };
@@ -583,7 +606,9 @@ export function rawOutboundToFormValues(raw: RawOutboundRow): OutboundFormValues
     ...typed,
     tag,
     sendThrough,
-    targetStrategy,
+    // The freedom card owns the strategy for freedom, so the shared root field
+    // stays empty and cannot disagree with what the card is showing.
+    targetStrategy: protocol === 'freedom' ? '' : targetStrategy,
     mux,
     streamSettings,
   };
@@ -717,8 +742,6 @@ function hysteriaToWire(s: HysteriaOutboundFormSettings) {
 }
 
 function freedomToWire(s: FreedomOutboundFormSettings) {
-  // The strategy is emitted under the legacy domainStrategy key: new cores
-  // fall back to it when targetStrategy is absent, old cores only know it.
   // Legacy semantics: emit fragment only when the user actually populated
   // at least one of the four sub-fields. Defaults like packets='1-3' alone
   // are not enough — the modal's Fragment Switch sets all four together.
@@ -727,8 +750,9 @@ function freedomToWire(s: FreedomOutboundFormSettings) {
   const fragment: Partial<FreedomOutboundFormSettings['fragment']> = s.fragment ?? {};
   const fragmentEntries = Object.entries(fragment).filter(([, v]) => v !== '' && v != null);
   const fragmentEnabled = !!fragment.length || !!fragment.interval || !!fragment.maxSplit;
+  // domainStrategy is absent here on purpose: formValuesToWirePayload hoists it
+  // into streamSettings.sockopt, the only placement freedom resolves with.
   return {
-    domainStrategy: s.domainStrategy || undefined,
     redirect: s.redirect || undefined,
     userLevel: s.userLevel || undefined,
     proxyProtocol: s.proxyProtocol || undefined,
@@ -881,7 +905,9 @@ export function formValuesToWirePayload(values: OutboundFormValues): WireOutboun
     settings,
   };
   if (values.tag) result.tag = values.tag;
-  if (values.targetStrategy) result.targetStrategy = values.targetStrategy;
+  if (values.targetStrategy && values.protocol !== 'freedom') {
+    result.targetStrategy = values.targetStrategy;
+  }
 
   // streamSettings emission gates on canEnableStream — non-stream protocols
   // still emit just `sockopt` if that key is present (legacy behavior).
@@ -894,6 +920,20 @@ export function formValuesToWirePayload(values: OutboundFormValues): WireOutboun
     }
   }
 
+  // Freedom only honours sockopt.domainStrategy; the root and settings keys are
+  // legacy aliases the loader warns about on every start (infra/conf/xray.go).
+  if (values.protocol === 'freedom') {
+    const stream = (result.streamSettings ?? {}) as Raw;
+    const sockopt = asObject(stream.sockopt);
+    const strategy = values.settings.domainStrategy || values.targetStrategy;
+    if (strategy && strategy !== 'AsIs') sockopt.domainStrategy = strategy;
+    else delete sockopt.domainStrategy;
+    if (Object.keys(sockopt).length > 0) stream.sockopt = sockopt;
+    else delete stream.sockopt;
+    if (Object.keys(stream).length > 0) result.streamSettings = stream;
+    else delete result.streamSettings;
+  }
+
   if (values.sendThrough) result.sendThrough = values.sendThrough;
   // mux may be absent when the modal didn't render the Mux switch (non-
   // stream protocols or when isMuxAllowed gated it out). validateFields()

+ 3 - 24
frontend/src/pages/xray/basics/BasicsTab.tsx

@@ -25,6 +25,7 @@ import {
   MASK_ADDRESS,
   ROUTING_DOMAIN_STRATEGIES,
 } from './constants';
+import { directFreedomStrategy, setDirectFreedomStrategy } from './helpers';
 
 interface BasicsTabProps {
   templateSettings: XraySettingsValue | null;
@@ -109,11 +110,7 @@ export default function BasicsTab({
     });
   }
 
-  const freedomStrategy =
-    (
-      templateSettings?.outbounds?.find((o) => o?.protocol === 'freedom' && o?.tag === 'direct')
-        ?.settings as { domainStrategy?: string } | undefined
-    )?.domainStrategy ?? 'AsIs';
+  const freedomStrategy = directFreedomStrategy(templateSettings);
 
   const directFreedomOutbound = templateSettings?.outbounds?.find(
     (o) => o?.protocol === 'freedom' && o?.tag === 'direct',
@@ -186,25 +183,7 @@ export default function BasicsTab({
                 value={freedomStrategy}
                 style={{ width: '100%' }}
                 options={OutboundDomainStrategies.map((s) => ({ value: s, label: s }))}
-                onChange={(next) =>
-                  mutate((tt) => {
-                    if (!tt.outbounds) tt.outbounds = [];
-                    const idx = tt.outbounds.findIndex(
-                      (o) => o?.protocol === 'freedom' && o?.tag === 'direct',
-                    );
-                    if (idx < 0) {
-                      tt.outbounds.push({
-                        protocol: 'freedom',
-                        tag: 'direct',
-                        settings: { domainStrategy: next },
-                      });
-                    } else {
-                      const ob = tt.outbounds[idx];
-                      ob.settings = (ob.settings || {}) as Record<string, unknown>;
-                      (ob.settings as Record<string, unknown>).domainStrategy = next;
-                    }
-                  })
-                }
+                onChange={(next) => mutate((tt) => setDirectFreedomStrategy(tt, next))}
               />
             }
           />

+ 3 - 1
frontend/src/pages/xray/basics/constants.ts

@@ -61,8 +61,10 @@ export const SERVICES_OPTIONS = [
 
 export const directSettings = { tag: 'direct', protocol: 'freedom' };
 export const blockedSettings = { tag: 'blocked', protocol: 'blackhole', settings: {} };
+// The strategy rides on sockopt: freedom resolves through the socket layer, and
+// the settings-level alias makes the core warn on every config load.
 export const ipv4Settings = {
   tag: 'IPv4',
   protocol: 'freedom',
-  settings: { domainStrategy: 'UseIPv4' },
+  streamSettings: { sockopt: { domainStrategy: 'UseIPv4' } },
 };

+ 42 - 0
frontend/src/pages/xray/basics/helpers.ts

@@ -1,6 +1,48 @@
 import type { XraySettingsValue } from '@/hooks/useXraySetting';
+import { freedomDomainStrategyFromWire } from '@/lib/xray/outbound-form-adapter';
 import { blockedSettings, directSettings } from './constants';
 
+// Freedom resolves through the socket layer, so the outbound root and its own
+// settings only hold legacy aliases the core warns about (infra/conf/xray.go).
+const LEGACY_FREEDOM_STRATEGY_KEYS = ['domainStrategy', 'targetStrategy'] as const;
+
+type Outbound = Record<string, unknown>;
+
+function directFreedom(t: XraySettingsValue | null): Outbound | undefined {
+  return t?.outbounds?.find((o) => o?.protocol === 'freedom' && o?.tag === 'direct') as
+    | Outbound
+    | undefined;
+}
+
+export function directFreedomStrategy(t: XraySettingsValue | null): string {
+  const outbound = directFreedom(t);
+  if (!outbound) return 'AsIs';
+  return freedomDomainStrategyFromWire(outbound) || 'AsIs';
+}
+
+export function setDirectFreedomStrategy(t: XraySettingsValue, next: string): void {
+  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;
+  // 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;
+  for (const key of LEGACY_FREEDOM_STRATEGY_KEYS) delete settings[key];
+  ob.settings = settings;
+  const stream = (ob.streamSettings ?? {}) as Outbound;
+  const sockopt = (stream.sockopt ?? {}) as Outbound;
+  if (next === 'AsIs') delete sockopt.domainStrategy;
+  else sockopt.domainStrategy = next;
+  if (Object.keys(sockopt).length === 0) delete stream.sockopt;
+  else stream.sockopt = sockopt;
+  if (Object.keys(stream).length === 0) delete ob.streamSettings;
+  else ob.streamSettings = stream;
+}
+
 export function ruleGetter(
   t: XraySettingsValue | null,
   outboundTag: string,

+ 15 - 8
frontend/src/pages/xray/outbounds/OutboundFormModal.tsx

@@ -417,13 +417,17 @@ export default function OutboundFormModal({
                         <Input placeholder={t('pages.xray.outboundForm.localIpPlaceholder')} />
                       </FormField>
 
-                      <FormField
-                        label={t('pages.xray.outbound.targetStrategy')}
-                        name="targetStrategy"
-                        tooltip={t('pages.xray.outboundForm.targetStrategyHint')}
-                      >
-                        <Select allowClear placeholder="AsIs" options={TARGET_STRATEGY_OPTIONS} />
-                      </FormField>
+                      {/* Freedom's own card owns the strategy — the core migrates this
+                          root key into the same sockopt value, so two knobs would race. */}
+                      {protocol !== 'freedom' && (
+                        <FormField
+                          label={t('pages.xray.outbound.targetStrategy')}
+                          name="targetStrategy"
+                          tooltip={t('pages.xray.outboundForm.targetStrategyHint')}
+                        >
+                          <Select allowClear placeholder="AsIs" options={TARGET_STRATEGY_OPTIONS} />
+                        </FormField>
+                      )}
 
                       {SERVER_PROTOCOLS.has(protocol) && <ServerTarget />}
                       {protocol === 'vmess' && <VmessFields />}
@@ -541,7 +545,10 @@ export default function OutboundFormModal({
                       {((streamAllowed && network) ||
                         !streamAllowed ||
                         protocol === 'wireguard') && (
-                        <SockoptForm outboundTags={dialerProxyTags ?? existingTags} />
+                        <SockoptForm
+                          outboundTags={dialerProxyTags ?? existingTags}
+                          showDomainStrategy={protocol !== 'freedom'}
+                        />
                       )}
 
                       <Controller

+ 24 - 12
frontend/src/pages/xray/outbounds/transport/sockopt.tsx

@@ -12,7 +12,17 @@ import {
 
 import { ADDRESS_PORT_STRATEGY_OPTIONS } from '../outbound-form-constants';
 
-export default function SockoptForm({ outboundTags = [] }: { outboundTags?: string[] }) {
+interface SockoptFormProps {
+  outboundTags?: string[];
+  showDomainStrategy?: boolean;
+}
+
+// Freedom's own card writes the strategy into this same sockopt key, so it hides
+// this field rather than letting two controls fight over one value.
+export default function SockoptForm({
+  outboundTags = [],
+  showDomainStrategy = true,
+}: SockoptFormProps) {
   const { t } = useTranslation();
   const { control, setValue } = useFormContext();
   const sockopt = useWatch({ control, name: 'streamSettings.sockopt' });
@@ -51,17 +61,19 @@ export default function SockoptForm({ outboundTags = [] }: { outboundTags?: stri
               options={dialerProxyOptions}
             />
           </FormField>
-          <FormField
-            label={t('pages.xray.wireguard.domainStrategy')}
-            name={['streamSettings', 'sockopt', 'domainStrategy']}
-          >
-            <Select
-              options={Object.values(DOMAIN_STRATEGY_OPTION).map((v) => ({
-                value: v,
-                label: v,
-              }))}
-            />
-          </FormField>
+          {showDomainStrategy && (
+            <FormField
+              label={t('pages.xray.wireguard.domainStrategy')}
+              name={['streamSettings', 'sockopt', 'domainStrategy']}
+            >
+              <Select
+                options={Object.values(DOMAIN_STRATEGY_OPTION).map((v) => ({
+                  value: v,
+                  label: v,
+                }))}
+              />
+            </FormField>
+          )}
           <FormField
             label={t('pages.inbounds.form.addressPortStrategy')}
             name={['streamSettings', 'sockopt', 'addressPortStrategy']}

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

@@ -0,0 +1,98 @@
+import { describe, it, expect } from 'vitest';
+
+import { directFreedomStrategy, setDirectFreedomStrategy } from '@/pages/xray/basics/helpers';
+import type { XraySettingsValue } from '@/hooks/useXraySetting';
+
+type Outbound = Record<string, unknown>;
+
+function settingsWithDirect(settings: Outbound, stream?: Outbound): XraySettingsValue {
+  return {
+    outbounds: [
+      {
+        protocol: 'freedom',
+        tag: 'direct',
+        settings,
+        ...(stream ? { streamSettings: stream } : {}),
+      },
+    ],
+  } as unknown as XraySettingsValue;
+}
+
+function directOutbound(t: XraySettingsValue): Outbound {
+  return t.outbounds?.[0] as Outbound;
+}
+
+// This select used to write the deprecated settings key (issue #6482), which is
+// what made the core warn on every load; it has to write sockopt instead.
+describe('BasicsTab freedom strategy', () => {
+  it('writes sockopt and clears both legacy placements', () => {
+    const t = settingsWithDirect({ domainStrategy: 'UseIPv6', targetStrategy: 'UseIP' });
+
+    setDirectFreedomStrategy(t, 'UseIPv4');
+
+    const outbound = directOutbound(t);
+    expect(outbound.settings).toEqual({});
+    expect(outbound.streamSettings).toEqual({ sockopt: { domainStrategy: 'UseIPv4' } });
+  });
+
+  it('creates the direct outbound when the config has none', () => {
+    const t = { outbounds: [] } as unknown as XraySettingsValue;
+
+    setDirectFreedomStrategy(t, 'UseIPv4');
+
+    expect(directOutbound(t)).toEqual({
+      protocol: 'freedom',
+      tag: 'direct',
+      settings: {},
+      streamSettings: { sockopt: { domainStrategy: 'UseIPv4' } },
+    });
+  });
+
+  it('keeps other sockopt keys the transport form already set', () => {
+    const t = settingsWithDirect({}, { sockopt: { tcpFastOpen: true } });
+
+    setDirectFreedomStrategy(t, 'ForceIPv4');
+
+    expect(directOutbound(t).streamSettings).toEqual({
+      sockopt: { tcpFastOpen: true, domainStrategy: 'ForceIPv4' },
+    });
+  });
+
+  it('drops the key again when AsIs is chosen', () => {
+    const t = settingsWithDirect({}, { sockopt: { domainStrategy: 'UseIPv4' } });
+
+    setDirectFreedomStrategy(t, 'AsIs');
+
+    expect(directOutbound(t).streamSettings).toBeUndefined();
+  });
+
+  it('shows the value the core will run with: legacy settings outrank sockopt', () => {
+    expect(
+      directFreedomStrategy(
+        settingsWithDirect(
+          { domainStrategy: 'UseIPv6' },
+          { sockopt: { domainStrategy: 'UseIPv4' } },
+        ),
+      ),
+    ).toBe('UseIPv6');
+    expect(directFreedomStrategy(settingsWithDirect({ targetStrategy: 'ForceIPv6' }))).toBe(
+      'ForceIPv6',
+    );
+    expect(directFreedomStrategy(settingsWithDirect({ domainStrategy: 'UseIPv4v6' }))).toBe(
+      'UseIPv4v6',
+    );
+    expect(
+      directFreedomStrategy(settingsWithDirect({}, { sockopt: { domainStrategy: 'UseIPv4' } })),
+    ).toBe('UseIPv4');
+    expect(directFreedomStrategy(settingsWithDirect({}))).toBe('AsIs');
+    expect(directFreedomStrategy(null)).toBe('AsIs');
+  });
+
+  it('does not let an inert AsIs alias mask the sockopt value', () => {
+    expect(
+      directFreedomStrategy(
+        settingsWithDirect({ domainStrategy: 'AsIs' }, { sockopt: { domainStrategy: 'UseIPv4' } }),
+      ),
+    ).toBe('UseIPv4');
+  });
+});

+ 154 - 0
frontend/src/test/freedom-strategy-placement.test.ts

@@ -0,0 +1,154 @@
+import { describe, it, expect } from 'vitest';
+
+import { formValuesToWirePayload, rawOutboundToFormValues } from '@/lib/xray/outbound-form-adapter';
+
+// A freedom outbound resolves through sockopt.domainStrategy, and the core warns
+// on every load for both legacy placements it migrates there (infra/conf/xray.go).
+describe('freedom domain strategy placement', () => {
+  it('emits the freedom card strategy into sockopt instead of settings', () => {
+    const wire = formValuesToWirePayload(
+      rawOutboundToFormValues({
+        protocol: 'freedom',
+        tag: 'direct',
+        settings: { domainStrategy: 'UseIPv4' },
+      }),
+    );
+
+    expect((wire.settings as Record<string, unknown>).domainStrategy).toBeUndefined();
+    expect(wire.targetStrategy).toBeUndefined();
+    expect(wire.streamSettings).toEqual({ sockopt: { domainStrategy: 'UseIPv4' } });
+  });
+
+  it('migrates a legacy settings key into sockopt on the next emit', () => {
+    const wire = formValuesToWirePayload(
+      rawOutboundToFormValues({
+        protocol: 'freedom',
+        tag: 'direct',
+        settings: { domainStrategy: 'UseIPv6' },
+      }),
+    );
+
+    expect(wire.streamSettings).toEqual({ sockopt: { domainStrategy: 'UseIPv6' } });
+    expect((wire.settings as Record<string, unknown>).domainStrategy).toBeUndefined();
+  });
+
+  it('migrates a legacy outbound-root targetStrategy into sockopt', () => {
+    const wire = formValuesToWirePayload(
+      rawOutboundToFormValues({
+        protocol: 'freedom',
+        tag: 'direct',
+        targetStrategy: 'ForceIPv4',
+        settings: {},
+      }),
+    );
+
+    expect(wire.targetStrategy).toBeUndefined();
+    expect(wire.streamSettings).toEqual({ sockopt: { domainStrategy: 'ForceIPv4' } });
+  });
+
+  it('reads the sockopt strategy back into the freedom card', () => {
+    const values = rawOutboundToFormValues({
+      protocol: 'freedom',
+      tag: 'direct',
+      streamSettings: { sockopt: { domainStrategy: 'UseIPv4v6' } },
+      settings: {},
+    });
+
+    expect((values.settings as { domainStrategy?: string }).domainStrategy).toBe('UseIPv4v6');
+  });
+
+  it('keeps the freedom card empty rather than showing a stale legacy value twice', () => {
+    const values = rawOutboundToFormValues({
+      protocol: 'freedom',
+      tag: 'direct',
+      targetStrategy: 'UseIPv4',
+      settings: {},
+    });
+
+    expect(values.targetStrategy).toBe('');
+    expect((values.settings as { domainStrategy?: string }).domainStrategy).toBe('UseIPv4');
+  });
+
+  it('shows the strategy the core will run with when a legacy key outranks sockopt', () => {
+    const values = rawOutboundToFormValues({
+      protocol: 'freedom',
+      tag: 'direct',
+      targetStrategy: 'UseIPv4',
+      streamSettings: { sockopt: { domainStrategy: 'UseIPv6' } },
+      settings: {},
+    });
+
+    expect((values.settings as { domainStrategy?: string }).domainStrategy).toBe('UseIPv4');
+    const wire = formValuesToWirePayload(values);
+    expect(wire.targetStrategy).toBeUndefined();
+    expect(wire.streamSettings).toEqual({ sockopt: { domainStrategy: 'UseIPv4' } });
+  });
+
+  it('drops the sockopt key when the card is cleared', () => {
+    const values = rawOutboundToFormValues({
+      protocol: 'freedom',
+      tag: 'direct',
+      streamSettings: { sockopt: { domainStrategy: 'UseIPv4', tcpFastOpen: true } },
+      settings: {},
+    });
+    (values.settings as { domainStrategy?: string }).domainStrategy = '';
+
+    const wire = formValuesToWirePayload(values);
+
+    expect(wire.streamSettings).toEqual({ sockopt: { tcpFastOpen: true } });
+  });
+
+  it('normalizes the sockopt spelling the core matches case-insensitively', () => {
+    const wire = formValuesToWirePayload(
+      rawOutboundToFormValues({
+        protocol: 'freedom',
+        tag: 'direct',
+        streamSettings: { sockopt: { domainStrategy: 'useipv4v6' } },
+        settings: {},
+      }),
+    );
+
+    expect(wire.streamSettings).toEqual({ sockopt: { domainStrategy: 'UseIPv4v6' } });
+  });
+
+  it('leaves AsIs out of the wire entirely', () => {
+    const wire = formValuesToWirePayload(
+      rawOutboundToFormValues({
+        protocol: 'freedom',
+        tag: 'direct',
+        settings: { domainStrategy: 'AsIs' },
+      }),
+    );
+
+    expect(wire.streamSettings).toBeUndefined();
+    expect((wire.settings as Record<string, unknown>).domainStrategy).toBeUndefined();
+  });
+
+  it('merges into a sockopt the transport form already carries', () => {
+    const wire = formValuesToWirePayload(
+      rawOutboundToFormValues({
+        protocol: 'freedom',
+        tag: 'direct',
+        settings: { domainStrategy: 'UseIPv4' },
+        streamSettings: { sockopt: { tcpFastOpen: true } },
+      }),
+    );
+
+    expect(wire.streamSettings).toEqual({
+      sockopt: { tcpFastOpen: true, domainStrategy: 'UseIPv4' },
+    });
+  });
+
+  it('still emits the root targetStrategy for protocols that use it there', () => {
+    const wire = formValuesToWirePayload(
+      rawOutboundToFormValues({
+        protocol: 'vless',
+        tag: 'proxy',
+        targetStrategy: 'UseIPv4',
+        settings: { address: 'example.com', port: 443, id: 'x', encryption: 'none' },
+      }),
+    );
+
+    expect(wire.targetStrategy).toBe('UseIPv4');
+  });
+});

+ 8 - 4
frontend/src/test/outbound-form-adapter.test.ts

@@ -376,8 +376,9 @@ describe('outbound-form-adapter: round-trip', () => {
         },
       }),
     );
+    // The strategy no longer rides in settings; see freedom-strategy-placement.test.ts.
+    expect(filled.streamSettings).toEqual({ sockopt: { domainStrategy: 'UseIPv4' } });
     expect(filled.settings).toMatchObject({
-      domainStrategy: 'UseIPv4',
       redirect: '1.1.1.1',
       userLevel: 3,
       proxyProtocol: 2,
@@ -556,7 +557,7 @@ describe('outbound-form-adapter: targetStrategy', () => {
 
   it('normalizes wire case to the canonical spelling (core matches case-insensitively)', () => {
     const form = rawOutboundToFormValues({
-      protocol: 'freedom',
+      protocol: 'vless',
       settings: {},
       targetStrategy: 'useipv4v6',
     });
@@ -582,7 +583,7 @@ describe('outbound-form-adapter: targetStrategy', () => {
     expect(invalid).not.toHaveProperty('targetStrategy');
   });
 
-  it('freedom prefers settings.targetStrategy over domainStrategy and emits the legacy key', () => {
+  it('freedom prefers settings.targetStrategy over domainStrategy and moves it to sockopt', () => {
     const form = rawOutboundToFormValues({
       protocol: 'freedom',
       settings: { targetStrategy: 'UseIPv6', domainStrategy: 'UseIPv4' },
@@ -591,8 +592,11 @@ describe('outbound-form-adapter: targetStrategy', () => {
       expect(form.settings.domainStrategy).toBe('UseIPv6');
     }
     const back = formValuesToWirePayload(form);
-    expect(back.settings).toMatchObject({ domainStrategy: 'UseIPv6' });
+    // Neither legacy key may survive: the core warns about both, and sockopt is
+    // the only placement freedom resolves with.
+    expect(back.settings).not.toHaveProperty('domainStrategy');
     expect(back.settings).not.toHaveProperty('targetStrategy');
+    expect(back.streamSettings).toEqual({ sockopt: { domainStrategy: 'UseIPv6' } });
   });
 });
 

+ 48 - 0
frontend/src/test/outbound-form-modal.test.tsx

@@ -21,6 +21,16 @@ function renderModal(outbound: Record<string, unknown> | null = null) {
   );
 }
 
+function toggleSockoptsSwitch() {
+  const item = Array.from(document.querySelectorAll('.ant-form-item')).find(
+    (el) =>
+      (el.querySelector('.ant-form-item-label label')?.textContent ?? '').trim() === 'Sockopts',
+  );
+  const control = item?.querySelector('.ant-switch');
+  if (!control) throw new Error('Sockopts switch not found');
+  fireEvent.click(control);
+}
+
 describe('OutboundFormModal', () => {
   it('renders add mode without crashing', () => {
     renderModal(null);
@@ -57,6 +67,44 @@ describe('OutboundFormModal', () => {
     }
   }, 30000); // iterates every protocol, re-rendering a heavy modal each time — slow on CI runners
 
+  // Freedom's card and the Transport tab's Sockopts block both write
+  // sockopt.domainStrategy, so freedom must show only one control for it.
+  it('hides the Transport sockopt strategy for freedom', () => {
+    renderModal({ protocol: 'freedom', tag: 'direct', settings: {} });
+    toggleSockoptsSwitch();
+
+    expect(fieldLabels()).toContain('Sockopts');
+    expect(fieldLabels()).not.toContain('Domain Strategy');
+    expect(fieldLabels()).toContain('Strategy');
+  });
+
+  it('keeps the Transport sockopt strategy for protocols without a card field', () => {
+    renderModal({ protocol: 'vless', tag: 'proxy', settings: {} });
+    toggleSockoptsSwitch();
+
+    expect(fieldLabels()).toContain('Domain Strategy');
+  });
+
+  // The core migrates freedom's outbound-root targetStrategy into the very same
+  // sockopt.domainStrategy the card writes, so the modal must not offer both.
+  it('offers freedom one strategy knob and other protocols the root one', async () => {
+    renderModal(null);
+
+    chooseSelectOption('protocol', 'freedom');
+    await act(async () => {
+      await new Promise((r) => setTimeout(r, 0));
+    });
+    const freedomLabels = fieldLabels();
+    expect(freedomLabels).toContain('Strategy');
+    expect(freedomLabels).not.toContain('Target Strategy');
+
+    chooseSelectOption('protocol', 'vless');
+    await act(async () => {
+      await new Promise((r) => setTimeout(r, 0));
+    });
+    expect(fieldLabels()).toContain('Target Strategy');
+  });
+
   it('saves a vless reverse outbound while reverse sniffing stays disabled', async () => {
     const onConfirm = vi.fn();
     renderWithProviders(

+ 120 - 1
internal/database/db.go

@@ -1254,7 +1254,7 @@ func runSeeders(isUsersEmpty bool) error {
 	}
 
 	if empty && isUsersEmpty {
-		seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix2", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "FreedomFinalRulesPrivateEgressBlock", "InboundRealityFinalmaskTcpStrip", "ApiTokensHash", "LegacyProxySettingsCleanup", "OutboundRemovedKeysFix", "WireguardPeersToClients", "MtprotoSecretsToClients", "NodeInboundsAdopted", "ResetIpLimitNoFail2ban"}
+		seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix2", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "FreedomFinalRulesPrivateEgressBlock", "InboundRealityFinalmaskTcpStrip", "ApiTokensHash", "LegacyProxySettingsCleanup", "OutboundRemovedKeysFix", "FreedomDomainStrategyFix", "WireguardPeersToClients", "MtprotoSecretsToClients", "NodeInboundsAdopted", "ResetIpLimitNoFail2ban"}
 		for _, name := range seeders {
 			if err := db.Create(&model.HistoryOfSeeders{SeederName: name}).Error; err != nil {
 				return err
@@ -1365,6 +1365,12 @@ func runSeeders(isUsersEmpty bool) error {
 		}
 	}
 
+	if !slices.Contains(seedersHistory, "FreedomDomainStrategyFix") {
+		if err := migrateFreedomDomainStrategy(); err != nil {
+			return err
+		}
+	}
+
 	if !slices.Contains(seedersHistory, "NodeInboundsAdopted") {
 		if err := seedNodeInboundsAdopted(); err != nil {
 			return err
@@ -1652,6 +1658,119 @@ func outboundSockopt(obj map[string]any, create bool) map[string]any {
 	return sockopt
 }
 
+func migrateFreedomDomainStrategy() error {
+	var setting model.Setting
+	err := db.Model(model.Setting{}).Where("key = ?", "xrayTemplateConfig").First(&setting).Error
+	if errors.Is(err, gorm.ErrRecordNotFound) {
+		return db.Create(&model.HistoryOfSeeders{SeederName: "FreedomDomainStrategyFix"}).Error
+	}
+	if err != nil {
+		return err
+	}
+
+	updated, changed, rErr := rewriteFreedomDomainStrategy(setting.Value)
+	if rErr != nil {
+		log.Printf("FreedomDomainStrategyFix: skip (invalid xrayTemplateConfig json): %v", rErr)
+		return db.Create(&model.HistoryOfSeeders{SeederName: "FreedomDomainStrategyFix"}).Error
+	}
+
+	return db.Transaction(func(tx *gorm.DB) error {
+		if changed {
+			if err := tx.Model(&model.Setting{}).Where("key = ?", "xrayTemplateConfig").
+				Update("value", updated).Error; err != nil {
+				return err
+			}
+		}
+		return tx.Create(&model.HistoryOfSeeders{SeederName: "FreedomDomainStrategyFix"}).Error
+	})
+}
+
+// rewriteFreedomDomainStrategy moves a freedom outbound's legacy strategy keys
+// into sockopt.domainStrategy, the placement the core's deprecation warning names.
+func rewriteFreedomDomainStrategy(raw string) (string, bool, error) {
+	if strings.TrimSpace(raw) == "" {
+		return raw, false, nil
+	}
+	var cfg map[string]any
+	if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
+		return raw, false, err
+	}
+	outbounds, ok := cfg["outbounds"].([]any)
+	if !ok {
+		return raw, false, nil
+	}
+	changed := false
+	for _, ob := range outbounds {
+		obj, ok := ob.(map[string]any)
+		if !ok {
+			continue
+		}
+		if proto, _ := obj["protocol"].(string); proto != "freedom" {
+			continue
+		}
+		settings, hasSettings := obj["settings"].(map[string]any)
+		_, hasRoot := obj["targetStrategy"]
+		_, hasSettingsTarget := settings["targetStrategy"]
+		_, hasSettingsDomain := settings["domainStrategy"]
+		if !hasRoot && !hasSettingsTarget && !hasSettingsDomain {
+			continue
+		}
+		strategy := freedomMigratedStrategy(obj, settings)
+		delete(obj, "targetStrategy")
+		if hasSettings {
+			delete(settings, "targetStrategy")
+			delete(settings, "domainStrategy")
+		}
+		if strategy != "" {
+			outboundSockopt(obj, true)["domainStrategy"] = strategy
+		}
+		changed = true
+	}
+	if !changed {
+		return raw, false, nil
+	}
+	out, err := json.MarshalIndent(cfg, "", "  ")
+	if err != nil {
+		return raw, false, err
+	}
+	return string(out), true, nil
+}
+
+// freedomMigratedStrategy clones the core's own resolution order for a freedom
+// outbound (infra/conf/freedom.go), returning "" when none of them holds one.
+func freedomMigratedStrategy(obj, settings map[string]any) string {
+	if s, ok := freedomStrategyValue(obj["targetStrategy"]); ok && !strings.EqualFold(s, "asis") {
+		return s
+	}
+	legacy := settings["targetStrategy"]
+	if s, ok := legacy.(string); !ok || s == "" {
+		legacy = settings["domainStrategy"]
+	}
+	if s, ok := freedomStrategyValue(legacy); ok && !strings.EqualFold(s, "asis") {
+		return s
+	}
+	return ""
+}
+
+// freedomStrategyValue reports a strategy the core accepts -- anything else is a
+// hard load error in freedom and sockopt alike, so it cannot be migrated.
+func freedomStrategyValue(value any) (string, bool) {
+	s, ok := value.(string)
+	if !ok || s == "" {
+		return "", false
+	}
+	if !freedomDomainStrategies[strings.ToLower(s)] {
+		return "", false
+	}
+	return s, true
+}
+
+var freedomDomainStrategies = map[string]bool{
+	"asis": true, "useip": true, "useipv4": true, "useipv6": true,
+	"useipv4v6": true, "useipv6v4": true, "forceip": true, "forceipv4": true,
+	"forceipv6": true, "forceipv4v6": true, "forceipv6v4": true,
+}
+
 func normalizeSettingPaths() error {
 	pathKeys := []string{"webBasePath", "subPath", "subJsonPath", "subClashPath"}
 	var rows []model.Setting

+ 295 - 0
internal/database/freedom_domain_strategy_migration_test.go

@@ -0,0 +1,295 @@
+package database
+
+import (
+	"encoding/json"
+	"strings"
+	"testing"
+
+	corelog "github.com/xtls/xray-core/common/log"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/config"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+func TestRewriteFreedomDomainStrategy(t *testing.T) {
+	tests := []struct {
+		name         string
+		raw          string
+		wantChanged  bool
+		wantOutbound map[string]any
+	}{
+		{
+			name:        "the deprecated settings key moves to sockopt",
+			raw:         `{"outbounds":[{"protocol":"freedom","tag":"direct","settings":{"domainStrategy":"UseIPv4","finalRules":[{"action":"allow"}]}}]}`,
+			wantChanged: true,
+			wantOutbound: map[string]any{
+				"protocol": "freedom", "tag": "direct",
+				"settings":       map[string]any{"finalRules": []any{map[string]any{"action": "allow"}}},
+				"streamSettings": map[string]any{"sockopt": map[string]any{"domainStrategy": "UseIPv4"}},
+			},
+		},
+		{
+			name:        "the outbound-root targetStrategy moves to sockopt and is dropped",
+			raw:         `{"outbounds":[{"protocol":"freedom","tag":"direct","targetStrategy":"ForceIPv6","settings":{}}]}`,
+			wantChanged: true,
+			wantOutbound: map[string]any{
+				"protocol": "freedom", "tag": "direct", "settings": map[string]any{},
+				"streamSettings": map[string]any{"sockopt": map[string]any{"domainStrategy": "ForceIPv6"}},
+			},
+		},
+		{
+			name:        "the root key wins over the settings key, as in the core",
+			raw:         `{"outbounds":[{"protocol":"freedom","tag":"direct","targetStrategy":"UseIPv4","settings":{"domainStrategy":"UseIPv6"}}]}`,
+			wantChanged: true,
+			wantOutbound: map[string]any{
+				"protocol": "freedom", "tag": "direct", "settings": map[string]any{},
+				"streamSettings": map[string]any{"sockopt": map[string]any{"domainStrategy": "UseIPv4"}},
+			},
+		},
+		{
+			name:        "the settings targetStrategy wins over domainStrategy",
+			raw:         `{"outbounds":[{"protocol":"freedom","tag":"direct","settings":{"targetStrategy":"UseIPv6","domainStrategy":"UseIPv4"}}]}`,
+			wantChanged: true,
+			wantOutbound: map[string]any{
+				"protocol": "freedom", "tag": "direct", "settings": map[string]any{},
+				"streamSettings": map[string]any{"sockopt": map[string]any{"domainStrategy": "UseIPv6"}},
+			},
+		},
+		{
+			name:        "an AsIs alias is dropped and leaves the sockopt value alone",
+			raw:         `{"outbounds":[{"protocol":"freedom","tag":"direct","settings":{"domainStrategy":"AsIs"},"streamSettings":{"sockopt":{"domainStrategy":"UseIPv6","tcpFastOpen":true}}}]}`,
+			wantChanged: true,
+			wantOutbound: map[string]any{
+				"protocol": "freedom", "tag": "direct", "settings": map[string]any{},
+				"streamSettings": map[string]any{"sockopt": map[string]any{"domainStrategy": "UseIPv6", "tcpFastOpen": true}},
+			},
+		},
+		{
+			name:        "the existing sockopt spelling is preserved",
+			raw:         `{"outbounds":[{"protocol":"freedom","tag":"direct","settings":{"domainStrategy":"useipv4v6"}}]}`,
+			wantChanged: true,
+			wantOutbound: map[string]any{
+				"protocol": "freedom", "tag": "direct", "settings": map[string]any{},
+				"streamSettings": map[string]any{"sockopt": map[string]any{"domainStrategy": "useipv4v6"}},
+			},
+		},
+		{
+			name:        "a strategy the core refuses is dropped rather than moved",
+			raw:         `{"outbounds":[{"protocol":"freedom","tag":"direct","settings":{"domainStrategy":"UseIPv5"}}]}`,
+			wantChanged: true,
+			wantOutbound: map[string]any{
+				"protocol": "freedom", "tag": "direct", "settings": map[string]any{},
+			},
+		},
+		{
+			name:        "other protocols keep their outbound-root targetStrategy",
+			raw:         `{"outbounds":[{"protocol":"vless","tag":"proxy","targetStrategy":"UseIPv4","settings":{}}]}`,
+			wantChanged: false,
+			wantOutbound: map[string]any{
+				"protocol": "vless", "tag": "proxy", "targetStrategy": "UseIPv4",
+				"settings": map[string]any{},
+			},
+		},
+		{
+			name:        "a freedom outbound without a strategy is left untouched",
+			raw:         `{"outbounds":[{"protocol":"freedom","tag":"direct","settings":{"finalRules":[{"action":"allow"}]}}]}`,
+			wantChanged: false,
+			wantOutbound: map[string]any{
+				"protocol": "freedom", "tag": "direct",
+				"settings": map[string]any{"finalRules": []any{map[string]any{"action": "allow"}}},
+			},
+		},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			updated, changed, err := rewriteFreedomDomainStrategy(tc.raw)
+			if err != nil {
+				t.Fatalf("unexpected error: %v", err)
+			}
+			if changed != tc.wantChanged {
+				t.Fatalf("changed = %v, want %v", changed, tc.wantChanged)
+			}
+			var cfg struct {
+				Outbounds []map[string]any `json:"outbounds"`
+			}
+			if err := json.Unmarshal([]byte(updated), &cfg); err != nil {
+				t.Fatalf("rewritten template is not JSON: %v", err)
+			}
+			if len(cfg.Outbounds) != 1 {
+				t.Fatalf("got %d outbounds, want 1", len(cfg.Outbounds))
+			}
+			got, _ := json.Marshal(cfg.Outbounds[0])
+			want, _ := json.Marshal(tc.wantOutbound)
+			if string(got) != string(want) {
+				t.Fatalf("outbound = %s, want %s", got, want)
+			}
+		})
+	}
+}
+
+type coreLogCapture struct{ msgs []string }
+
+func (c *coreLogCapture) Handle(msg corelog.Message) { c.msgs = append(c.msgs, msg.String()) }
+
+func (c *coreLogCapture) has(sub string) bool {
+	return strings.Contains(strings.Join(c.msgs, "\n"), sub)
+}
+
+type discardLogHandler struct{}
+
+func (discardLogHandler) Handle(corelog.Message) {}
+
+// captureCoreLogs takes over the vendored core's log sink for the duration of
+// one test, which is the only way to observe a config-load warning.
+func captureCoreLogs(t *testing.T) *coreLogCapture {
+	t.Helper()
+	capture := new(coreLogCapture)
+	corelog.RegisterHandler(capture)
+	t.Cleanup(func() { corelog.RegisterHandler(discardLogHandler{}) })
+	return capture
+}
+
+// Drives the real core: a rewrite that dropped the value instead of moving it
+// would leave the config warning on every load and fail here.
+func TestRewriteFreedomDomainStrategySatisfiesCore(t *testing.T) {
+	for _, tc := range []struct {
+		name      string
+		raw       string
+		wantValue string
+	}{
+		{
+			name:      "deprecated settings key",
+			raw:       `{"protocol":"freedom","tag":"direct","settings":{"domainStrategy":"UseIPv4","finalRules":[{"action":"allow"}]}}`,
+			wantValue: `"domainStrategy": "UseIPv4"`,
+		},
+		{
+			name:      "outbound-root targetStrategy",
+			raw:       `{"protocol":"freedom","tag":"direct","targetStrategy":"ForceIPv6","settings":{}}`,
+			wantValue: `"domainStrategy": "ForceIPv6"`,
+		},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			capture := captureCoreLogs(t)
+
+			if err := xray.ValidateOutboundConfig([]byte(tc.raw)); err != nil {
+				t.Fatalf("xray-core must accept the legacy outbound: %v", err)
+			}
+			if !capture.has("sockopt.domainStrategy") {
+				t.Fatal("expected the core to warn about the legacy strategy placement")
+			}
+
+			updated, changed, err := rewriteFreedomDomainStrategy(
+				`{"outbounds":[` + tc.raw + `]}`,
+			)
+			if err != nil || !changed {
+				t.Fatalf("rewrite: changed=%v err=%v", changed, err)
+			}
+			var after struct {
+				Outbounds []json.RawMessage `json:"outbounds"`
+			}
+			if err := json.Unmarshal([]byte(updated), &after); err != nil {
+				t.Fatal(err)
+			}
+			if !strings.Contains(string(after.Outbounds[0]), tc.wantValue) {
+				t.Fatalf("rewritten outbound = %s, want it to carry %s", after.Outbounds[0], tc.wantValue)
+			}
+
+			capture.msgs = nil
+			if err := xray.ValidateOutboundConfig(after.Outbounds[0]); err != nil {
+				t.Fatalf("xray-core refused the rewritten outbound: %v", err)
+			}
+			if capture.has("sockopt.domainStrategy") {
+				t.Fatalf("rewritten outbound still warns on load: %v", capture.msgs)
+			}
+		})
+	}
+}
+
+func TestRewriteFreedomDomainStrategyInvalidJSON(t *testing.T) {
+	_, changed, err := rewriteFreedomDomainStrategy("{not json")
+	if err == nil {
+		t.Fatal("expected an error for invalid JSON")
+	}
+	if changed {
+		t.Fatal("invalid JSON must not report a change")
+	}
+}
+
+func TestMigrateFreedomDomainStrategyRewritesStoredTemplate(t *testing.T) {
+	t.Setenv("XUI_DB_FOLDER", t.TempDir())
+	// A CGO_ENABLED=0 build links a stubbed driver, so this test needs the same
+	// C compiler the rest of the package's DB tests do.
+	if err := InitDB(config.GetDBPath()); err != nil {
+		if strings.Contains(err.Error(), "CGO_ENABLED=0") {
+			t.Skipf("sqlite needs cgo: %v", err)
+		}
+		t.Fatalf("init db: %v", err)
+	}
+	t.Cleanup(func() { _ = CloseDB() })
+
+	legacy := `{"outbounds":[{"protocol":"freedom","tag":"direct","settings":{"domainStrategy":"UseIPv4"}}]}`
+	seedTemplate(t, legacy)
+	if err := db.Where("seeder_name = ?", "FreedomDomainStrategyFix").
+		Delete(&model.HistoryOfSeeders{}).Error; err != nil {
+		t.Fatalf("clear seeder history: %v", err)
+	}
+
+	if err := migrateFreedomDomainStrategy(); err != nil {
+		t.Fatalf("migrate: %v", err)
+	}
+
+	got := storedTemplate(t)
+	var cfg struct {
+		Outbounds []map[string]any `json:"outbounds"`
+	}
+	if err := json.Unmarshal([]byte(got), &cfg); err != nil {
+		t.Fatalf("stored template is not JSON: %v", err)
+	}
+	if len(cfg.Outbounds) != 1 {
+		t.Fatalf("stored outbounds = %d, want 1", len(cfg.Outbounds))
+	}
+	outbound := cfg.Outbounds[0]
+	if _, present := outbound["targetStrategy"]; present {
+		t.Errorf("stored outbound kept the root targetStrategy: %s", got)
+	}
+	settings, _ := outbound["settings"].(map[string]any)
+	if _, present := settings["domainStrategy"]; present {
+		t.Errorf("stored outbound kept the deprecated settings key: %s", got)
+	}
+	stream, _ := outbound["streamSettings"].(map[string]any)
+	sockopt, _ := stream["sockopt"].(map[string]any)
+	if sockopt["domainStrategy"] != "UseIPv4" {
+		t.Errorf("stored sockopt strategy = %v, want UseIPv4", sockopt["domainStrategy"])
+	}
+
+	// The history gate is what keeps a hand-edited template from being rewritten
+	// again on every restart, so run the real seeder list over a fresh legacy one.
+	seedTemplate(t, legacy)
+	if err := runSeeders(false); err != nil {
+		t.Fatalf("runSeeders: %v", err)
+	}
+	if got := storedTemplate(t); got != legacy {
+		t.Errorf("a completed seeder rewrote the template again: %s", got)
+	}
+}
+
+func seedTemplate(t *testing.T, value string) {
+	t.Helper()
+	if err := db.Where("key = ?", "xrayTemplateConfig").Delete(&model.Setting{}).Error; err != nil {
+		t.Fatalf("clear template: %v", err)
+	}
+	if err := db.Create(&model.Setting{Key: "xrayTemplateConfig", Value: value}).Error; err != nil {
+		t.Fatalf("seed template: %v", err)
+	}
+}
+
+func storedTemplate(t *testing.T) string {
+	t.Helper()
+	var setting model.Setting
+	if err := db.Where("key = ?", "xrayTemplateConfig").First(&setting).Error; err != nil {
+		t.Fatalf("reload template: %v", err)
+	}
+	return setting.Value
+}

+ 0 - 1
internal/web/service/config.json

@@ -31,7 +31,6 @@
   "outbounds": [{
       "protocol": "freedom",
       "settings": {
-        "domainStrategy": "AsIs",
         "finalRules": [
           { "action": "block", "ip": ["geoip:private"] },
           { "action": "allow" }

+ 37 - 0
internal/web/service/default_template_freedom_test.go

@@ -0,0 +1,37 @@
+package service
+
+import (
+	"encoding/json"
+	"testing"
+)
+
+// The embedded template is what every fresh install starts from, so it must not
+// carry the freedom strategy keys the core warns about on every config load.
+func TestDefaultXrayTemplateKeepsFreedomStrategyOutOfTheLegacyKeys(t *testing.T) {
+	var cfg struct {
+		Outbounds []map[string]any `json:"outbounds"`
+	}
+	if err := json.Unmarshal([]byte(xrayTemplateConfig), &cfg); err != nil {
+		t.Fatalf("embedded config.json is not JSON: %v", err)
+	}
+	freedom := 0
+	for _, ob := range cfg.Outbounds {
+		if ob["protocol"] != "freedom" {
+			continue
+		}
+		freedom++
+		tag := ob["tag"]
+		if _, ok := ob["targetStrategy"]; ok {
+			t.Errorf("freedom outbound %v carries the outbound-root targetStrategy", tag)
+		}
+		settings, _ := ob["settings"].(map[string]any)
+		for _, key := range []string{"domainStrategy", "targetStrategy"} {
+			if _, ok := settings[key]; ok {
+				t.Errorf("freedom outbound %v carries the deprecated settings.%s", tag, key)
+			}
+		}
+	}
+	if freedom == 0 {
+		t.Fatal("the default template has no freedom outbound to check")
+	}
+}