diff --git a/frontend/src/schemas/protocols/security/reality.ts b/frontend/src/schemas/protocols/security/reality.ts index efe4e77b2..8245263e9 100644 --- a/frontend/src/schemas/protocols/security/reality.ts +++ b/frontend/src/schemas/protocols/security/reality.ts @@ -14,28 +14,51 @@ export const RealityClientSettingsSchema = z.object({ }); export type RealityClientSettings = z.infer; +// 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; + 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; diff --git a/frontend/src/test/security.test.ts b/frontend/src/test/security.test.ts index f94073159..8dd15104c 100644 --- a/frontend/src/test/security.test.ts +++ b/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( './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'); + }); +});