mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-31 18:24:10 +00:00
fix(outbounds): prevent freedom save crash, complete its fields (#4686)
Some checks are pending
CI / go-test (push) Waiting to run
CI / govulncheck (push) Waiting to run
CI / frontend (push) Waiting to run
CodeQL Advanced / Analyze (go) (push) Waiting to run
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (push) Waiting to run
Some checks are pending
CI / go-test (push) Waiting to run
CI / govulncheck (push) Waiting to run
CI / frontend (push) Waiting to run
CodeQL Advanced / Analyze (go) (push) Waiting to run
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (push) Waiting to run
freedomToWire called Object.entries(s.fragment), but getFieldsValue(true) returns freedom settings without a fragment object when the Fragment switch is off (its sub-fields never register). That threw 'Cannot convert undefined or null to object' and silently killed the save. Guard fragment with a fallback so an unset value is treated as empty. While verifying against xray-core's freedom config, also: - add the missing userLevel field (schema, form schema, adapter, UI) - fix noise applyTo enum to ip/ipv4/ipv6 (xray rejects the old host/all) Closes #4686
This commit is contained in:
parent
c20ee00fa3
commit
f02018cfb7
5 changed files with 39 additions and 4 deletions
|
|
@ -266,6 +266,7 @@ function freedomFromWire(raw: Raw): FreedomOutboundFormSettings {
|
||||||
return (allowed.includes(s) ? s : '') as FreedomOutboundFormSettings['domainStrategy'];
|
return (allowed.includes(s) ? s : '') as FreedomOutboundFormSettings['domainStrategy'];
|
||||||
})(),
|
})(),
|
||||||
redirect: asString(raw.redirect),
|
redirect: asString(raw.redirect),
|
||||||
|
userLevel: asNumber(raw.userLevel, 0),
|
||||||
proxyProtocol: ((): FreedomOutboundFormSettings['proxyProtocol'] => {
|
proxyProtocol: ((): FreedomOutboundFormSettings['proxyProtocol'] => {
|
||||||
const n = asNumber(raw.proxyProtocol, 0);
|
const n = asNumber(raw.proxyProtocol, 0);
|
||||||
return (n === 1 || n === 2) ? n : 0;
|
return (n === 1 || n === 2) ? n : 0;
|
||||||
|
|
@ -506,11 +507,15 @@ function freedomToWire(s: FreedomOutboundFormSettings) {
|
||||||
// Legacy semantics: emit fragment only when the user actually populated
|
// Legacy semantics: emit fragment only when the user actually populated
|
||||||
// at least one of the four sub-fields. Defaults like packets='1-3' alone
|
// 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.
|
// are not enough — the modal's Fragment Switch sets all four together.
|
||||||
const fragmentEntries = Object.entries(s.fragment).filter(([, v]) => v !== '' && v != null);
|
// getFieldsValue(true) may omit `fragment` when the switch is off, so the
|
||||||
const fragmentEnabled = !!s.fragment.length || !!s.fragment.interval || !!s.fragment.maxSplit;
|
// fallback keeps Object.entries from throwing on undefined (issue #4686).
|
||||||
|
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;
|
||||||
return {
|
return {
|
||||||
domainStrategy: s.domainStrategy || undefined,
|
domainStrategy: s.domainStrategy || undefined,
|
||||||
redirect: s.redirect || undefined,
|
redirect: s.redirect || undefined,
|
||||||
|
userLevel: s.userLevel || undefined,
|
||||||
proxyProtocol: s.proxyProtocol || undefined,
|
proxyProtocol: s.proxyProtocol || undefined,
|
||||||
fragment: fragmentEnabled ? Object.fromEntries(fragmentEntries) : undefined,
|
fragment: fragmentEnabled ? Object.fromEntries(fragmentEntries) : undefined,
|
||||||
noises: s.noises.length > 0 ? s.noises : undefined,
|
noises: s.noises.length > 0 ? s.noises : undefined,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Button, Form, Input, Select, Switch, type FormInstance } from 'antd';
|
import { Button, Form, Input, InputNumber, Select, Switch, type FormInstance } from 'antd';
|
||||||
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
|
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
import { OutboundDomainStrategies } from '@/schemas/primitives';
|
import { OutboundDomainStrategies } from '@/schemas/primitives';
|
||||||
|
|
@ -20,6 +20,9 @@ export default function FreedomFields({ form }: { form: FormInstance<OutboundFor
|
||||||
<Form.Item label={t('pages.xray.outboundForm.redirect')} name={['settings', 'redirect']}>
|
<Form.Item label={t('pages.xray.outboundForm.redirect')} name={['settings', 'redirect']}>
|
||||||
<Input />
|
<Input />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item label={t('pages.xray.tun.userLevel')} name={['settings', 'userLevel']}>
|
||||||
|
<InputNumber min={0} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
<Form.Item label={t('pages.xray.outboundForm.proxyProtocol')} name={['settings', 'proxyProtocol']}>
|
<Form.Item label={t('pages.xray.outboundForm.proxyProtocol')} name={['settings', 'proxyProtocol']}>
|
||||||
<Select
|
<Select
|
||||||
options={[
|
options={[
|
||||||
|
|
|
||||||
|
|
@ -166,6 +166,7 @@ export type FreedomFinalRuleForm = z.infer<typeof FreedomFinalRuleFormSchema>;
|
||||||
export const FreedomOutboundFormSettingsSchema = z.object({
|
export const FreedomOutboundFormSettingsSchema = z.object({
|
||||||
domainStrategy: z.union([OutboundDomainStrategySchema, z.literal('')]).default(''),
|
domainStrategy: z.union([OutboundDomainStrategySchema, z.literal('')]).default(''),
|
||||||
redirect: z.string().default(''),
|
redirect: z.string().default(''),
|
||||||
|
userLevel: z.number().int().min(0).default(0),
|
||||||
proxyProtocol: z.number().int().min(0).max(2).default(0),
|
proxyProtocol: z.number().int().min(0).max(2).default(0),
|
||||||
fragment: FreedomFragmentSchema.default({
|
fragment: FreedomFragmentSchema.default({
|
||||||
packets: '1-3',
|
packets: '1-3',
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ export const FreedomFragmentSchema = z.object({
|
||||||
export type FreedomFragment = z.infer<typeof FreedomFragmentSchema>;
|
export type FreedomFragment = z.infer<typeof FreedomFragmentSchema>;
|
||||||
|
|
||||||
export const FreedomNoiseTypeSchema = z.enum(['rand', 'str', 'base64', 'hex']);
|
export const FreedomNoiseTypeSchema = z.enum(['rand', 'str', 'base64', 'hex']);
|
||||||
export const FreedomNoiseApplyToSchema = z.enum(['ip', 'host', 'all']);
|
export const FreedomNoiseApplyToSchema = z.enum(['ip', 'ipv4', 'ipv6']);
|
||||||
|
|
||||||
export const FreedomNoiseSchema = z.object({
|
export const FreedomNoiseSchema = z.object({
|
||||||
type: FreedomNoiseTypeSchema.default('rand'),
|
type: FreedomNoiseTypeSchema.default('rand'),
|
||||||
|
|
@ -52,6 +52,7 @@ export type FreedomFinalRule = z.infer<typeof FreedomFinalRuleSchema>;
|
||||||
export const FreedomOutboundSettingsSchema = z.object({
|
export const FreedomOutboundSettingsSchema = z.object({
|
||||||
domainStrategy: OutboundDomainStrategySchema.optional(),
|
domainStrategy: OutboundDomainStrategySchema.optional(),
|
||||||
redirect: z.string().optional(),
|
redirect: z.string().optional(),
|
||||||
|
userLevel: z.number().int().min(0).optional(),
|
||||||
proxyProtocol: z.number().optional(),
|
proxyProtocol: z.number().optional(),
|
||||||
fragment: FreedomFragmentSchema.optional(),
|
fragment: FreedomFragmentSchema.optional(),
|
||||||
noises: z.array(FreedomNoiseSchema).optional(),
|
noises: z.array(FreedomNoiseSchema).optional(),
|
||||||
|
|
|
||||||
|
|
@ -235,18 +235,43 @@ describe('outbound-form-adapter: round-trip', () => {
|
||||||
settings: {
|
settings: {
|
||||||
domainStrategy: 'UseIPv4',
|
domainStrategy: 'UseIPv4',
|
||||||
redirect: '1.1.1.1',
|
redirect: '1.1.1.1',
|
||||||
|
userLevel: 3,
|
||||||
proxyProtocol: 2,
|
proxyProtocol: 2,
|
||||||
fragment: { packets: 'tlshello', length: '100-200' },
|
fragment: { packets: 'tlshello', length: '100-200' },
|
||||||
|
noises: [{ type: 'rand', packet: '10-20', delay: '10-16', applyTo: 'ipv4' }],
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
expect(filled.settings).toMatchObject({
|
expect(filled.settings).toMatchObject({
|
||||||
domainStrategy: 'UseIPv4',
|
domainStrategy: 'UseIPv4',
|
||||||
redirect: '1.1.1.1',
|
redirect: '1.1.1.1',
|
||||||
|
userLevel: 3,
|
||||||
proxyProtocol: 2,
|
proxyProtocol: 2,
|
||||||
fragment: { packets: 'tlshello', length: '100-200' },
|
fragment: { packets: 'tlshello', length: '100-200' },
|
||||||
|
noises: [{ type: 'rand', packet: '10-20', delay: '10-16', applyTo: 'ipv4' }],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('freedom tolerates settings without a fragment object (issue #4686)', () => {
|
||||||
|
const values = {
|
||||||
|
protocol: 'freedom',
|
||||||
|
tag: 'direct',
|
||||||
|
settings: {
|
||||||
|
domainStrategy: '',
|
||||||
|
redirect: '',
|
||||||
|
proxyProtocol: 0,
|
||||||
|
noises: [],
|
||||||
|
finalRules: [
|
||||||
|
{ action: 'block', network: '', port: '', ip: ['geoip:private'], blockDelay: '' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
} as unknown as Parameters<typeof formValuesToWirePayload>[0];
|
||||||
|
|
||||||
|
expect(() => formValuesToWirePayload(values)).not.toThrow();
|
||||||
|
const back = formValuesToWirePayload(values);
|
||||||
|
expect((back.settings as { fragment?: unknown }).fragment).toBeUndefined();
|
||||||
|
expect((back.settings as { finalRules?: unknown[] }).finalRules).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('freedom omits proxyProtocol when disabled (0)', () => {
|
it('freedom omits proxyProtocol when disabled (0)', () => {
|
||||||
const round = formValuesToWirePayload(rawOutboundToFormValues({
|
const round = formValuesToWirePayload(rawOutboundToFormValues({
|
||||||
protocol: 'freedom',
|
protocol: 'freedom',
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue