mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-13 16:16:06 +00:00
feat(frontend): add targetStrategy field to the outbound editor
Xray-core added a top-level targetStrategy to OutboundObject that controls how the destination domain is resolved before dialing (AsIs/UseIP*/ForceIP*, any protocol). The panel neither offered a control for it nor preserved the key across the modal's JSON round trip, so hand-written values were silently dropped on save. The form now carries targetStrategy next to sendThrough as a select of the 11 canonical values; the adapter normalizes wire values to canonical case (the core matches case-insensitively) and omits the key when unset. Freedom settings additionally read the new settings-level targetStrategy with domainStrategy as fallback, mirroring the core, while still emitting the legacy domainStrategy key so configs keep working on older cores.
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
import { XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
|
||||
import { OutboundDomainStrategySchema } from '@/schemas/protocols/outbound';
|
||||
import { normalizeStreamSettingsForWire } from '@/lib/xray/stream-wire-normalize';
|
||||
import { Wireguard } from '@/utils';
|
||||
import type { Sniffing, SniffingDest } from '@/schemas/primitives';
|
||||
import type { OutboundDomainStrategy } from '@/schemas/protocols/outbound';
|
||||
|
||||
import type {
|
||||
DnsOutboundFormSettings,
|
||||
@@ -56,6 +58,16 @@ function asPort(value: unknown, fallback: number): number {
|
||||
return n;
|
||||
}
|
||||
|
||||
// xray-core matches targetStrategy/domainStrategy case-insensitively;
|
||||
// normalize the wire value to the canonical spelling or '' (= AsIs).
|
||||
function targetStrategyFromWire(value: unknown): OutboundDomainStrategy | '' {
|
||||
const s = asString(value);
|
||||
if (!s) return '';
|
||||
return OutboundDomainStrategySchema.options.find(
|
||||
(v) => v.toLowerCase() === s.toLowerCase(),
|
||||
) ?? '';
|
||||
}
|
||||
|
||||
const SNIFFING_DEST_VALUES: readonly SniffingDest[] = ['http', 'tls', 'quic', 'fakedns'];
|
||||
|
||||
const SNIFFING_DEFAULT: Sniffing = {
|
||||
@@ -285,14 +297,9 @@ function freedomFromWire(raw: Raw): FreedomOutboundFormSettings {
|
||||
&& typeof raw.fragment === 'object'
|
||||
&& Object.keys(fragment).length > 0;
|
||||
return {
|
||||
domainStrategy: ((): FreedomOutboundFormSettings['domainStrategy'] => {
|
||||
const allowed = [
|
||||
'AsIs', 'UseIP', 'UseIPv4', 'UseIPv6', 'UseIPv6v4', 'UseIPv4v6',
|
||||
'ForceIP', 'ForceIPv6v4', 'ForceIPv6', 'ForceIPv4v6', 'ForceIPv4',
|
||||
];
|
||||
const s = asString(raw.domainStrategy);
|
||||
return (allowed.includes(s) ? s : '') as FreedomOutboundFormSettings['domainStrategy'];
|
||||
})(),
|
||||
domainStrategy: targetStrategyFromWire(
|
||||
asString(raw.targetStrategy) || asString(raw.domainStrategy),
|
||||
),
|
||||
redirect: asString(raw.redirect),
|
||||
userLevel: asNumber(raw.userLevel, 0),
|
||||
proxyProtocol: ((): FreedomOutboundFormSettings['proxyProtocol'] => {
|
||||
@@ -374,6 +381,7 @@ export interface RawOutboundRow {
|
||||
tag?: string;
|
||||
protocol?: string;
|
||||
sendThrough?: string;
|
||||
targetStrategy?: string;
|
||||
settings?: unknown;
|
||||
streamSettings?: unknown;
|
||||
mux?: unknown;
|
||||
@@ -401,6 +409,7 @@ export function rawOutboundToFormValues(raw: RawOutboundRow): OutboundFormValues
|
||||
const settings = asObject(raw.settings);
|
||||
const tag = asString(raw.tag);
|
||||
const sendThrough = asString(raw.sendThrough);
|
||||
const targetStrategy = targetStrategyFromWire(raw.targetStrategy);
|
||||
const mux = muxFromWire(raw.mux);
|
||||
const hasStream = raw.streamSettings
|
||||
&& typeof raw.streamSettings === 'object'
|
||||
@@ -430,6 +439,7 @@ export function rawOutboundToFormValues(raw: RawOutboundRow): OutboundFormValues
|
||||
...typed,
|
||||
tag,
|
||||
sendThrough,
|
||||
targetStrategy,
|
||||
mux,
|
||||
streamSettings,
|
||||
};
|
||||
@@ -543,6 +553,8 @@ 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.
|
||||
@@ -672,6 +684,7 @@ export function formValuesToWirePayload(values: OutboundFormValues): WireOutboun
|
||||
settings,
|
||||
};
|
||||
if (values.tag) result.tag = values.tag;
|
||||
if (values.targetStrategy) result.targetStrategy = values.targetStrategy;
|
||||
|
||||
// streamSettings emission gates on canEnableStream — non-stream protocols
|
||||
// still emit just `sockopt` if that key is present (legacy behavior).
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
NETWORK_OPTIONS,
|
||||
PROTOCOL_OPTIONS,
|
||||
SERVER_PROTOCOLS,
|
||||
TARGET_STRATEGY_OPTIONS,
|
||||
} from './outbound-form-constants';
|
||||
import {
|
||||
applyNetworkChange,
|
||||
@@ -394,6 +395,14 @@ export default function OutboundFormModal({
|
||||
<Input placeholder={t('pages.xray.outboundForm.localIpPlaceholder')} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('pages.xray.outbound.targetStrategy')}
|
||||
name="targetStrategy"
|
||||
tooltip={t('pages.xray.outboundForm.targetStrategyHint')}
|
||||
>
|
||||
<Select allowClear placeholder="AsIs" options={TARGET_STRATEGY_OPTIONS} />
|
||||
</Form.Item>
|
||||
|
||||
{SERVER_PROTOCOLS.has(protocol) && <ServerTarget />}
|
||||
{protocol === 'vmess' && <VmessFields />}
|
||||
{protocol === 'vless' && <VlessFields />}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
USERS_SECURITY,
|
||||
UTLS_FINGERPRINT,
|
||||
} from '@/schemas/primitives';
|
||||
import { OutboundDomainStrategySchema } from '@/schemas/protocols/outbound';
|
||||
import { SSMethodSchema } from '@/schemas/protocols/shared/shadowsocks';
|
||||
|
||||
export const PROTOCOL_OPTIONS = Object.values(Protocols).map((p) => ({ value: p, label: p }));
|
||||
@@ -20,6 +21,10 @@ export const ADDRESS_PORT_STRATEGY_OPTIONS = Object.values(Address_Port_Strategy
|
||||
value: v,
|
||||
label: v,
|
||||
}));
|
||||
export const TARGET_STRATEGY_OPTIONS = OutboundDomainStrategySchema.options.map((v) => ({
|
||||
value: v,
|
||||
label: v,
|
||||
}));
|
||||
|
||||
// canEnableMux mirrors the adapter's helper but lives here so the modal
|
||||
// can show/hide the Mux section without going through the adapter.
|
||||
|
||||
@@ -219,11 +219,13 @@ export const OutboundStreamFormSchema = NetworkSettingsSchema
|
||||
.and(StreamExtrasSchema);
|
||||
export type OutboundStreamFormValues = z.infer<typeof OutboundStreamFormSchema>;
|
||||
|
||||
// Top-level form base: identity (tag, sendThrough), then the per-protocol
|
||||
// settings DU, then the stream sub-form, then mux.
|
||||
// Top-level form base: identity (tag, sendThrough, targetStrategy), then
|
||||
// the per-protocol settings DU, then the stream sub-form, then mux.
|
||||
// targetStrategy '' means AsIs (omitted from wire).
|
||||
export const OutboundFormBaseSchema = z.object({
|
||||
tag: z.string().default(''),
|
||||
sendThrough: z.string().default(''),
|
||||
targetStrategy: z.union([OutboundDomainStrategySchema, z.literal('')]).default(''),
|
||||
streamSettings: OutboundStreamFormSchema.optional(),
|
||||
mux: MuxFormSchema.default({
|
||||
enabled: false,
|
||||
|
||||
@@ -420,6 +420,54 @@ describe('outbound-form-adapter: round-trip', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('outbound-form-adapter: targetStrategy', () => {
|
||||
it('round-trips a top-level targetStrategy', () => {
|
||||
const back = formValuesToWirePayload(rawOutboundToFormValues({
|
||||
protocol: 'vless',
|
||||
settings: { address: 's', port: 443, id: '11111111-2222-4333-8444-555555555555', flow: '', encryption: 'none' },
|
||||
targetStrategy: 'ForceIPv6v4',
|
||||
}));
|
||||
expect(back.targetStrategy).toBe('ForceIPv6v4');
|
||||
});
|
||||
|
||||
it('normalizes wire case to the canonical spelling (core matches case-insensitively)', () => {
|
||||
const form = rawOutboundToFormValues({
|
||||
protocol: 'freedom',
|
||||
settings: {},
|
||||
targetStrategy: 'useipv4v6',
|
||||
});
|
||||
expect(form.targetStrategy).toBe('UseIPv4v6');
|
||||
});
|
||||
|
||||
it('omits targetStrategy when unset and drops unknown values', () => {
|
||||
const unset = formValuesToWirePayload(rawOutboundToFormValues({
|
||||
protocol: 'freedom',
|
||||
settings: {},
|
||||
}));
|
||||
expect(unset).not.toHaveProperty('targetStrategy');
|
||||
|
||||
const invalid = formValuesToWirePayload(rawOutboundToFormValues({
|
||||
protocol: 'freedom',
|
||||
settings: {},
|
||||
targetStrategy: 'UseIPv5',
|
||||
}));
|
||||
expect(invalid).not.toHaveProperty('targetStrategy');
|
||||
});
|
||||
|
||||
it('freedom prefers settings.targetStrategy over domainStrategy and emits the legacy key', () => {
|
||||
const form = rawOutboundToFormValues({
|
||||
protocol: 'freedom',
|
||||
settings: { targetStrategy: 'UseIPv6', domainStrategy: 'UseIPv4' },
|
||||
});
|
||||
if (form.protocol === 'freedom') {
|
||||
expect(form.settings.domainStrategy).toBe('UseIPv6');
|
||||
}
|
||||
const back = formValuesToWirePayload(form);
|
||||
expect(back.settings).toMatchObject({ domainStrategy: 'UseIPv6' });
|
||||
expect(back.settings).not.toHaveProperty('targetStrategy');
|
||||
});
|
||||
});
|
||||
|
||||
describe('outbound-form-adapter: xhttp xmux toggle', () => {
|
||||
const xmuxWire = {
|
||||
protocol: 'vless',
|
||||
|
||||
Reference in New Issue
Block a user