diff --git a/frontend/src/lib/xray/outbound-form-adapter.ts b/frontend/src/lib/xray/outbound-form-adapter.ts index 9dca830f8..2c5a40b6a 100644 --- a/frontend/src/lib/xray/outbound-form-adapter.ts +++ b/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 = 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() diff --git a/frontend/src/pages/xray/basics/BasicsTab.tsx b/frontend/src/pages/xray/basics/BasicsTab.tsx index 4a464f801..068196591 100644 --- a/frontend/src/pages/xray/basics/BasicsTab.tsx +++ b/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; - (ob.settings as Record).domainStrategy = next; - } - }) - } + onChange={(next) => mutate((tt) => setDirectFreedomStrategy(tt, next))} /> } /> diff --git a/frontend/src/pages/xray/basics/constants.ts b/frontend/src/pages/xray/basics/constants.ts index ef5a62081..66295cfae 100644 --- a/frontend/src/pages/xray/basics/constants.ts +++ b/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' } }, }; diff --git a/frontend/src/pages/xray/basics/helpers.ts b/frontend/src/pages/xray/basics/helpers.ts index cde41584d..1d0f0e7b9 100644 --- a/frontend/src/pages/xray/basics/helpers.ts +++ b/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; + +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, diff --git a/frontend/src/pages/xray/outbounds/OutboundFormModal.tsx b/frontend/src/pages/xray/outbounds/OutboundFormModal.tsx index c9ff14df8..9ad8e2fd4 100644 --- a/frontend/src/pages/xray/outbounds/OutboundFormModal.tsx +++ b/frontend/src/pages/xray/outbounds/OutboundFormModal.tsx @@ -417,13 +417,17 @@ export default function OutboundFormModal({ - - + + )} {SERVER_PROTOCOLS.has(protocol) && } {protocol === 'vmess' && } @@ -541,7 +545,10 @@ export default function OutboundFormModal({ {((streamAllowed && network) || !streamAllowed || protocol === 'wireguard') && ( - + )} - - ({ + value: v, + label: v, + }))} + /> + + )} ; + +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'); + }); +}); diff --git a/frontend/src/test/freedom-strategy-placement.test.ts b/frontend/src/test/freedom-strategy-placement.test.ts new file mode 100644 index 000000000..12d1ca14f --- /dev/null +++ b/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).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).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).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'); + }); +}); diff --git a/frontend/src/test/outbound-form-adapter.test.ts b/frontend/src/test/outbound-form-adapter.test.ts index 08dccc58a..783b764f1 100644 --- a/frontend/src/test/outbound-form-adapter.test.ts +++ b/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' } }); }); }); diff --git a/frontend/src/test/outbound-form-modal.test.tsx b/frontend/src/test/outbound-form-modal.test.tsx index 77ebf6dcf..8057fddec 100644 --- a/frontend/src/test/outbound-form-modal.test.tsx +++ b/frontend/src/test/outbound-form-modal.test.tsx @@ -21,6 +21,16 @@ function renderModal(outbound: Record | 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( diff --git a/internal/database/db.go b/internal/database/db.go index f71865e30..1a46a9fbd 100644 --- a/internal/database/db.go +++ b/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 diff --git a/internal/database/freedom_domain_strategy_migration_test.go b/internal/database/freedom_domain_strategy_migration_test.go new file mode 100644 index 000000000..613339b7b --- /dev/null +++ b/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 +} diff --git a/internal/web/service/config.json b/internal/web/service/config.json index 29077fa1a..66355777f 100644 --- a/internal/web/service/config.json +++ b/internal/web/service/config.json @@ -31,7 +31,6 @@ "outbounds": [{ "protocol": "freedom", "settings": { - "domainStrategy": "AsIs", "finalRules": [ { "action": "block", "ip": ["geoip:private"] }, { "action": "allow" } diff --git a/internal/web/service/default_template_freedom_test.go b/internal/web/service/default_template_freedom_test.go new file mode 100644 index 000000000..182259084 --- /dev/null +++ b/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") + } +}