Selaa lähdekoodia

fix(outbounds): prevent freedom save crash, complete its fields (#4686)

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
MHSanaei 11 tuntia sitten
vanhempi
sitoutus
f02018cfb7

+ 7 - 2
frontend/src/lib/xray/outbound-form-adapter.ts

@@ -266,6 +266,7 @@ function freedomFromWire(raw: Raw): FreedomOutboundFormSettings {
       return (allowed.includes(s) ? s : '') as FreedomOutboundFormSettings['domainStrategy'];
     })(),
     redirect: asString(raw.redirect),
+    userLevel: asNumber(raw.userLevel, 0),
     proxyProtocol: ((): FreedomOutboundFormSettings['proxyProtocol'] => {
       const n = asNumber(raw.proxyProtocol, 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
   // 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.
-  const fragmentEntries = Object.entries(s.fragment).filter(([, v]) => v !== '' && v != null);
-  const fragmentEnabled = !!s.fragment.length || !!s.fragment.interval || !!s.fragment.maxSplit;
+  // getFieldsValue(true) may omit `fragment` when the switch is off, so the
+  // 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 {
     domainStrategy: s.domainStrategy || undefined,
     redirect: s.redirect || undefined,
+    userLevel: s.userLevel || undefined,
     proxyProtocol: s.proxyProtocol || undefined,
     fragment: fragmentEnabled ? Object.fromEntries(fragmentEntries) : undefined,
     noises: s.noises.length > 0 ? s.noises : undefined,

+ 4 - 1
frontend/src/pages/xray/outbounds/protocols/freedom.tsx

@@ -1,5 +1,5 @@
 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 { 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']}>
         <Input />
       </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']}>
         <Select
           options={[

+ 1 - 0
frontend/src/schemas/forms/outbound-form.ts

@@ -166,6 +166,7 @@ export type FreedomFinalRuleForm = z.infer<typeof FreedomFinalRuleFormSchema>;
 export const FreedomOutboundFormSettingsSchema = z.object({
   domainStrategy: z.union([OutboundDomainStrategySchema, z.literal('')]).default(''),
   redirect: z.string().default(''),
+  userLevel: z.number().int().min(0).default(0),
   proxyProtocol: z.number().int().min(0).max(2).default(0),
   fragment: FreedomFragmentSchema.default({
     packets: '1-3',

+ 2 - 1
frontend/src/schemas/protocols/outbound/freedom.ts

@@ -26,7 +26,7 @@ export const FreedomFragmentSchema = z.object({
 export type FreedomFragment = z.infer<typeof FreedomFragmentSchema>;
 
 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({
   type: FreedomNoiseTypeSchema.default('rand'),
@@ -52,6 +52,7 @@ export type FreedomFinalRule = z.infer<typeof FreedomFinalRuleSchema>;
 export const FreedomOutboundSettingsSchema = z.object({
   domainStrategy: OutboundDomainStrategySchema.optional(),
   redirect: z.string().optional(),
+  userLevel: z.number().int().min(0).optional(),
   proxyProtocol: z.number().optional(),
   fragment: FreedomFragmentSchema.optional(),
   noises: z.array(FreedomNoiseSchema).optional(),

+ 25 - 0
frontend/src/test/outbound-form-adapter.test.ts

@@ -235,18 +235,43 @@ describe('outbound-form-adapter: round-trip', () => {
       settings: {
         domainStrategy: 'UseIPv4',
         redirect: '1.1.1.1',
+        userLevel: 3,
         proxyProtocol: 2,
         fragment: { packets: 'tlshello', length: '100-200' },
+        noises: [{ type: 'rand', packet: '10-20', delay: '10-16', applyTo: 'ipv4' }],
       },
     }));
     expect(filled.settings).toMatchObject({
       domainStrategy: 'UseIPv4',
       redirect: '1.1.1.1',
+      userLevel: 3,
       proxyProtocol: 2,
       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)', () => {
     const round = formValuesToWirePayload(rawOutboundToFormValues({
       protocol: 'freedom',