fix(xray): adopt freedom finalRules delay and logLevel

xray-core 26.11.9 (#7329) renamed a final rule's blockDelay to delay, added
logLevel, and now refuses either on a rule whose action is not block. A
stored template with a blockDelay on an allow rule therefore stops the core
at startup after the upgrade.

A one-time seeder strips delay, blockDelay and logLevel from non-block rules
of the template's freedom outbounds and moves blockDelay to delay on block
rules. The outbound form reads both spellings (blockDelay wins, as in the
core), writes delay, gains a log level control on block rules and emits
neither key on an allow rule.
This commit is contained in:
Sanaei
2026-11-12 19:10:18 +01:00
parent 192dc93c16
commit a748f799c7
7 changed files with 263 additions and 16 deletions
+13 -3
View File
@@ -354,14 +354,23 @@ function freedomFromWire(
network,
port: asString(rr.port),
ip: asArray(rr.ip).map((x) => asString(x)),
blockDelay: asString(rr.blockDelay),
// The deprecated blockDelay wins over delay, as it does in xray-core 26.11.9.
delay: asString(rr.blockDelay) || asString(rr.delay),
logLevel: asString(rr.logLevel),
};
});
// Legacy ipsBlocked → finalRule(block) backfill
if (finalRules.length === 0) {
const ipsBlocked = asArray(raw.ipsBlocked).map((x) => asString(x));
if (ipsBlocked.length > 0) {
finalRules.push({ action: 'block', network: '', port: '', ip: ipsBlocked, blockDelay: '' });
finalRules.push({
action: 'block',
network: '',
port: '',
ip: ipsBlocked,
delay: '',
logLevel: '',
});
}
}
// Wire fragment is either missing or a populated object. Mirror the
@@ -828,7 +837,8 @@ function freedomToWire(s: FreedomOutboundFormSettings) {
network: r.network || undefined,
port: r.port || undefined,
ip: r.ip.length > 0 ? r.ip : undefined,
blockDelay: r.action === 'block' && r.blockDelay ? r.blockDelay : undefined,
delay: r.action === 'block' && r.delay ? r.delay : undefined,
logLevel: r.action === 'block' && r.logLevel ? r.logLevel : undefined,
}))
: undefined,
};
@@ -6,6 +6,7 @@ import { useFieldArray, useFormContext, useWatch } from 'react-hook-form';
import { selectTagRender } from '@/components/form';
import { FormField } from '@/components/form/rhf';
import { LOG_LEVELS } from '@/pages/xray/basics/constants';
import { activateOnKey } from '@/utils/a11y';
import { OutboundDomainStrategies } from '@/schemas/primitives';
@@ -220,7 +221,14 @@ export default function FreedomFields() {
icon={<PlusOutlined aria-hidden />}
aria-label={t('add')}
onClick={() =>
appendFinalRule({ action: 'allow', network: '', port: '', ip: [], blockDelay: '' })
appendFinalRule({
action: 'allow',
network: '',
port: '',
ip: [],
delay: '',
logLevel: '',
})
}
/>
<span className="ml-8" style={{ opacity: 0.6 }}>
@@ -281,12 +289,24 @@ export default function FreedomFields() {
/>
</FormField>
{finalRulesValues[index]?.action === 'block' && (
<FormField
label={t('pages.xray.outboundForm.blockDelay')}
name={['settings', 'finalRules', index, 'blockDelay']}
>
<Input placeholder="optional: 5000-10000" />
</FormField>
<>
<FormField
label={t('pages.xray.outboundForm.blockDelay')}
name={['settings', 'finalRules', index, 'delay']}
>
<Input placeholder="optional: 5000-10000" />
</FormField>
<FormField
label={t('pages.xray.logLevel')}
name={['settings', 'finalRules', index, 'logLevel']}
>
<Select
allowClear
placeholder="info"
options={LOG_LEVELS.map((v) => ({ value: v, label: v }))}
/>
</FormField>
</>
)}
</div>
))}
+4 -4
View File
@@ -132,15 +132,15 @@ export const MasqueOutboundFormSettingsSchema = z.object({
});
export type MasqueOutboundFormSettings = z.infer<typeof MasqueOutboundFormSettingsSchema>;
// FinalRule (freedom): network/port are strings; ip is string[]; blockDelay
// is only meaningful when action === 'block'. The adapter omits empty
// fields from the wire payload.
// FinalRule (freedom): the core refuses delay and logLevel on a rule whose
// action is not 'block' (xray-core 26.11.9), so the adapter drops them there.
export const FreedomFinalRuleFormSchema = z.object({
action: FreedomFinalRuleActionSchema.default('block'),
network: z.string().default(''),
port: z.string().default(''),
ip: z.array(z.string()).default([]),
blockDelay: z.string().default(''),
delay: z.string().default(''),
logLevel: z.string().default(''),
});
export type FreedomFinalRuleForm = z.infer<typeof FreedomFinalRuleFormSchema>;
@@ -45,6 +45,9 @@ export const FreedomFinalRuleSchema = z.object({
network: z.string().optional(),
port: z.string().optional(),
ip: z.array(z.string()).default([]),
delay: z.string().optional(),
logLevel: z.string().optional(),
// xray-core 26.11.9 (#7329) deprecated blockDelay in favour of delay.
blockDelay: z.string().optional(),
});
export type FreedomFinalRule = z.infer<typeof FreedomFinalRuleSchema>;
@@ -537,7 +537,14 @@ describe('outbound-form-adapter: round-trip', () => {
proxyProtocol: 0,
noises: [],
finalRules: [
{ action: 'block', network: '', port: '', ip: ['geoip:private'], blockDelay: '' },
{
action: 'block',
network: '',
port: '',
ip: ['geoip:private'],
delay: '',
logLevel: '',
},
],
},
} as unknown as Parameters<typeof formValuesToWirePayload>[0];
@@ -548,6 +555,26 @@ describe('outbound-form-adapter: round-trip', () => {
expect((back.settings as { finalRules?: unknown[] }).finalRules).toHaveLength(1);
});
it('freedom writes finalRules delay and logLevel on block rules only (xray-core 26.11.9)', () => {
const round = formValuesToWirePayload(
rawOutboundToFormValues({
protocol: 'freedom',
settings: {
finalRules: [
{ action: 'block', blockDelay: '5000-10000', delay: '1-2', logLevel: 'none' },
{ action: 'block', delay: '100-200' },
{ action: 'allow', delay: '1-2', logLevel: 'debug' },
],
},
}),
);
expect((round.settings as { finalRules?: unknown[] }).finalRules).toEqual([
{ action: 'block', delay: '5000-10000', logLevel: 'none' },
{ action: 'block', delay: '100-200' },
{ action: 'allow' },
]);
});
it('freedom omits proxyProtocol when disabled (0)', () => {
const round = formValuesToWirePayload(
rawOutboundToFormValues({