mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-16 23:27:14 +00:00
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.
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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' } });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user