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:
BlindMaster24
2026-09-14 13:08:38 +03:00
committed by GitHub
parent 032ddcb29f
commit 826e29e2de
14 changed files with 897 additions and 61 deletions
+50 -10
View File
@@ -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()