瀏覽代碼

fix(reality): load `dest` as `target` alias so existing inbounds aren't wiped (#5295)

xray-core accepts both `target` and `dest` for the REALITY destination
(infra/conf/transport_internet.go: REALITYConfig has json:"target" and
json:"dest"). The frontend schema only knows `target`, so an inbound whose
realitySettings use `dest` — older panel builds, external tools, or the
panel's own /panel/api/inbounds API — loads with an empty (required) Target
field even though xray is running fine. Re-saving then serializes the blank
`target` and drops the working `dest`, breaking REALITY on the next restart.

Normalize `dest` -> `target` on parse (z.preprocess) when `target` is
absent/empty, matching xray-core's alias behavior. Add unit tests covering
the schema directly and through the security discriminated union.

Co-authored-by: Volov <[email protected]>
Volov Vyacheslav 10 小時之前
父節點
當前提交
66a9a788fc
共有 2 個文件被更改,包括 77 次插入18 次删除
  1. 41 18
      frontend/src/schemas/protocols/security/reality.ts
  2. 36 0
      frontend/src/test/security.test.ts

+ 41 - 18
frontend/src/schemas/protocols/security/reality.ts

@@ -14,28 +14,51 @@ export const RealityClientSettingsSchema = z.object({
 });
 export type RealityClientSettings = z.infer<typeof RealityClientSettingsSchema>;
 
+// xray-core accepts both `target` and `dest` as the REALITY destination —
+// they are aliases (infra/conf/transport_internet.go: REALITYConfig has
+// `json:"target"` and `json:"dest"`). The panel writes `target`, but configs
+// produced by older panel builds, external tools, or the panel's own
+// `/panel/api/inbounds` API commonly use `dest`. Map `dest` -> `target` on
+// parse when `target` is absent/empty: otherwise such an inbound loads with
+// an empty (required) Target field even though it runs fine, and re-saving
+// it serializes the blank `target` and drops the working `dest` — silently
+// breaking REALITY on the next xray restart.
+const aliasRealityDest = (value: unknown): unknown => {
+  if (value && typeof value === 'object' && !Array.isArray(value)) {
+    const obj = value as Record<string, unknown>;
+    const hasTarget = typeof obj.target === 'string' && obj.target !== '';
+    if (!hasTarget && typeof obj.dest === 'string' && obj.dest !== '') {
+      return { ...obj, target: obj.dest };
+    }
+  }
+  return value;
+};
+
 // Reality stream payload. `serverNames` and `shortIds` are stored as
 // comma-joined strings in the panel class but ship as string[] on the wire
 // — fixtures round-trip through the array form. `target` is the dest host
 // Reality piggybacks on; the panel auto-generates random target+SNI when
 // blank.
-export const RealityStreamSettingsSchema = z.object({
-  show: z.boolean().default(false),
-  xver: z.number().int().min(0).default(0),
-  target: z.string().default(''),
-  serverNames: z.array(z.string()).default([]),
-  privateKey: z.string().default(''),
-  minClientVer: z.string().default(''),
-  maxClientVer: z.string().default(''),
-  maxTimediff: z.number().int().min(0).default(0),
-  shortIds: z.array(z.string()).default([]),
-  mldsa65Seed: z.string().default(''),
-  settings: RealityClientSettingsSchema.default({
-    publicKey: '',
-    fingerprint: 'chrome',
-    serverName: '',
-    spiderX: '/',
-    mldsa65Verify: '',
+export const RealityStreamSettingsSchema = z.preprocess(
+  aliasRealityDest,
+  z.object({
+    show: z.boolean().default(false),
+    xver: z.number().int().min(0).default(0),
+    target: z.string().default(''),
+    serverNames: z.array(z.string()).default([]),
+    privateKey: z.string().default(''),
+    minClientVer: z.string().default(''),
+    maxClientVer: z.string().default(''),
+    maxTimediff: z.number().int().min(0).default(0),
+    shortIds: z.array(z.string()).default([]),
+    mldsa65Seed: z.string().default(''),
+    settings: RealityClientSettingsSchema.default({
+      publicKey: '',
+      fingerprint: 'chrome',
+      serverName: '',
+      spiderX: '/',
+      mldsa65Verify: '',
+    }),
   }),
-});
+);
 export type RealityStreamSettings = z.infer<typeof RealityStreamSettingsSchema>;

+ 36 - 0
frontend/src/test/security.test.ts

@@ -2,6 +2,7 @@
 import { describe, expect, it } from 'vitest';
 
 import { SecuritySettingsSchema } from '@/schemas/protocols';
+import { RealityStreamSettingsSchema } from '@/schemas/protocols/security/reality';
 
 const securityFixtures = import.meta.glob<unknown>(
   './golden/fixtures/security/*.json',
@@ -24,3 +25,38 @@ describe('SecuritySettingsSchema fixtures', () => {
     });
   }
 });
+
+describe('RealityStreamSettingsSchema dest -> target alias', () => {
+  it('maps legacy `dest` to `target` when `target` is absent', () => {
+    const parsed = RealityStreamSettingsSchema.parse({
+      dest: 'example.com:443',
+      serverNames: ['example.com'],
+    });
+    expect(parsed.target).toBe('example.com:443');
+  });
+
+  it('keeps `target` when both keys are present', () => {
+    const parsed = RealityStreamSettingsSchema.parse({
+      target: 'example.com:443',
+      dest: 'other.com:443',
+    });
+    expect(parsed.target).toBe('example.com:443');
+  });
+
+  it('does not let an empty `target` shadow a present `dest`', () => {
+    const parsed = RealityStreamSettingsSchema.parse({
+      target: '',
+      dest: 'example.com:443',
+    });
+    expect(parsed.target).toBe('example.com:443');
+  });
+
+  it('migrates `dest` through the security discriminated union', () => {
+    const parsed = SecuritySettingsSchema.parse({
+      security: 'reality',
+      realitySettings: { dest: 'caddy:443', serverNames: ['volov.online'] },
+    });
+    if (parsed.security !== 'reality') throw new Error('expected reality branch');
+    expect(parsed.realitySettings.target).toBe('caddy:443');
+  });
+});